diff --git a/README.md b/README.md index 90717850..2cc79e41 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # gerbeur -A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes. +A small invite-only social platform for sharing links and files. Users can post +URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, +comment, follow each other, build playlists, and search content. A real-time +WebSocket layer handles live presence, vote counts, and notifications. The UI is +localized (English and French) and ships multiple visual themes. ## Stack @@ -30,13 +34,16 @@ cp .env.example .env deno task dev ``` -This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently. +This starts both the API server (port 8000, with file watching) and the Vite dev +server (port 3000) concurrently. -Open [http://localhost:3000](http://localhost:3000). On first run a default `admin` / `admin` account is created — change the password immediately. +Open [http://localhost:3000](http://localhost:3000). On first run a default +`admin` / `admin` account is created — change the password immediately. ### Environment variables -See [`.env.example`](.env.example) for the full list with descriptions. Key variables: +See [`.env.example`](.env.example) for the full list with descriptions. Key +variables: | Variable | Description | Default | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | @@ -58,7 +65,10 @@ See [`.env.example`](.env.example) for the full list with descriptions. Key vari ### Docker (recommended) -The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests. +The standard deployment runs API and frontend in a single container. The API +server (Oak) serves the compiled frontend as static files, so both share the +same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the +externally-visible URL; it is automatically allowed for HTTP/WebSocket requests. ```sh docker build -t gerbeur . @@ -77,12 +87,17 @@ docker run -d \ ``` The two volumes are required for persistence: -- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized automatically on first run + +- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized + automatically on first run - `gerbeur-uploads` — user-uploaded files (`api/uploads/`) #### Separate API and frontend (optional) -If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin HTTP/WebSocket requests are accepted: +If you need to run the API on a different host than the frontend, pass the API +location as build args so it gets baked into the frontend bundle. In that setup, +add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin +HTTP/WebSocket requests are accepted: ```sh docker build \ @@ -94,7 +109,8 @@ docker build \ ### Reverse proxy -Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile: +Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. +Forward everything to port 8000. Example Caddyfile: ``` example.com { @@ -158,27 +174,65 @@ place that fetches from other sites. Tests mirror the source one file per module, so the tests of `services/chat-service.ts` are in `test/services/chat-service.test.ts`. -All three parts — `api/`, `shared/` and `src/` — are documented for `deno doc`, -the same way in every file. Each module opens with a `/** … @module */` block -saying what it is for. Every export has a JSDoc comment, and so does every field -of an exported interface; a comment that describes a declaration is always `/** -*/`, and `//` is only for notes on the code inside. Functions open with a verb -("Returns…", "Checks…", "Renders…" for a component), everything else with a noun -phrase. Parameters are named in the prose, in backticks. `{@link}` points at -symbols the file already has in scope. A function that can throw to its caller -says so with `@throws`: for an `APIException`, the statuses and when. A router's -module doc lists its endpoints, one line each: method and path, who may call it, -what it does; a page's JSDoc names its route and who may see it. The first -paragraph of a doc is its summary, which the reference shows alone in lists, so -it is a sentence that stands on its own: it never ends in a colon introducing -what follows. +### Comments + +A comment says what the code can't. If deleting it would lose nothing, it is the +wrong comment: on an export, rewrite it; anywhere else, delete it. + +**Every module and every export has a JSDoc** (`/** */`). Each gives at least +one of: + +1. **Context**: who uses it and where (a page and its route, a caller, a + WebSocket message). +2. **Usage**: what a caller must know to use it correctly, such as a + precondition, an invariant, what it leaves to the caller, or a side effect. + Add an example only when the call isn't obvious. +3. **Place in the chain**: where its input comes from and where its output goes. + +A type's doc is phrased from the value's side, naming the producer and the +consumer: "Returned to Y by X when Z", "Passed to Y by X". It is never "What X +returns to Y" or "What X passes to Y". + +Don't pack a doc into a label followed by a colon and a list ("X: one thing, +plus some other thing"). Write sentences with a subject and a verb. + +When the obvious comment is useless, write the useful one: the context is always +there. A **field** (of an interface, or a parameter) gets a doc only when it +adds something its parent's doc doesn't. Context that concerns the whole type +goes on the type, not on one of its fields. The same goes for private functions +and locals. None of these count as a doc: + +- a restatement of the name or type (`/** The dump. */`, "Whether it is + loading.", "The props of X.", "Renders the X."); +- a description of what the body, the JSX or the SQL plainly does; +- a list that goes stale (every caller of a widely used component): name the + kind of caller instead. + +When a comment is there, it is as short as it can be, often one sentence, and it +is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't +count. A module doc is a few lines at most. It opens with what the module does, +then says who calls it and what it hands back, and it doesn't repeat what its +exports' docs say. `@throws` stays on whatever throws to its caller, with the +statuses of an `APIException` and when each happens. A router's module doc lists +its endpoints (method, path, who may call it), since the code states them +nowhere in one place. A summary never ends in a colon: the reference shows it +alone. + +**`//` comments** go wherever the code isn't obvious. They explain why, not +what: a workaround, an ordering constraint, a surprising limitation, a security +reason, an event handler that must not bubble. + +**Every claim is checked against the code** (grep the callers). A wrong comment +is worse than none. + +### Reference In the frontend, every exported function states its return type, so the reference can show it: a component returns `React.JSX.Element` (or `| null`), a hook returning several things returns a documented `UseResult` interface. -A component that takes props takes an exported `Props` interface, each -prop documented. React's types are used through the global `React` namespace -rather than a default import of `react`, which `deno doc` can't follow. +A component that takes props takes an exported `Props` interface. React's +types are used through the global `React` namespace rather than a default import +of `react`, which `deno doc` can't follow. `deno task doc` builds the HTML reference into `docs/` from every module of `api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`). @@ -187,9 +241,10 @@ repository's three parts and their layers in prose; `deno doc` builds the list of every module itself, grouped by path. The `docs` workflow publishes it on every push to `main` that touches them. `deno task doc:lint` checks the rules the linter can see, and that no summary ends in a colon, through `doc-lint.ts`. -`deno doc` doesn't document npm packages, so it takes a type imported from one -(react-hook-form's `FieldValues`, say) for a private one, and those references -alone are let through. +Two kinds of diagnostic are let through. A field without a doc passes, since a +field gets one only when it adds to its type's. `deno doc` doesn't document npm +packages, so it takes a type imported from one (react-hook-form's `FieldValues`, +say) for a private one, and those references pass too. Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is split into the same domain folders — `auth`, `category`, `chat`, `comment`, diff --git a/api/config.ts b/api/config.ts index 90541f4b..bca6f373 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,6 +1,7 @@ /** - * The server's settings: those read from `GERBEUR_*` environment variables, - * with their defaults, and the fixed limits the backend applies. + * Reads the server's settings from the `GERBEUR_*` environment variables, once + * at import, and holds the fixed limits the backend applies. The deno tasks + * load `.env` with `--env-file`, so changing a setting needs a restart. * * @module */ @@ -8,12 +9,13 @@ /** The port the server listens on (`GERBEUR_PORT`, default 8000). */ export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; /** - * The SMTP server mail is sent through, as an `smtps://` URL - * (`GERBEUR_SMTPS_URL`). Empty disables email. + * The `smtps://` URL of the server email-service sends mail through + * (`GERBEUR_SMTPS_URL`). When it is empty, the welcome and password reset + * emails are silently skipped. */ export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; -/** The sender address of outgoing mail (`GERBEUR_FROM_EMAIL`). */ +/** The `From` address of the welcome and password reset emails. */ export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || ""; @@ -24,66 +26,76 @@ Hi **{{username}}**, Your account has been created successfully. Welcome aboard!`; /** - * The Markdown body of the welcome email (`GERBEUR_WELCOME_EMAIL_BODY`), with - * `{{site_name}}` and `{{username}}` placeholders. + * The Markdown of the welcome email sent on registration + * (`GERBEUR_WELCOME_EMAIL_BODY`). The users route replaces `{{site_name}}` and + * `{{username}}` in it and sends it as both text and HTML. */ export const WELCOME_EMAIL_BODY: string = Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || DEFAULT_WELCOME_EMAIL_BODY; -/** The key tokens are signed with (`GERBEUR_JWT_SECRET`). Required. */ +/** + * The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`). + * The server refuses to start without it. + */ export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || ""; /** - * The network interface the server binds to (`GERBEUR_LISTEN_HOST`). Defaults - * to 0.0.0.0 so Docker port-forwarding works out of the box; 127.0.0.1 - * restricts it to loopback. + * The interface the server binds to (`GERBEUR_LISTEN_HOST`). The default, + * 0.0.0.0, lets Docker forward the port; 127.0.0.1 keeps it to loopback. */ export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0"; /** - * The server's public-facing URL (`GERBEUR_PUBLIC_URL`), without a trailing - * slash — used for CORS, WebSocket origin checks, email links, and OG image - * URLs. Behind a reverse proxy, set it to the externally-visible URL (e.g. - * https://example.com). Defaults to http://localhost:PORT for local - * development. + * The URL visitors reach the server at (`GERBEUR_PUBLIC_URL`), without a + * trailing slash. Behind a reverse proxy it must be the outside URL: it is an + * allowed origin, it tells links to this site's own pages from other links, + * and it prefixes the image URLs in their previews. */ export const PUBLIC_URL: string = Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || `http://localhost:${PORT}`; /** - * The frontend's URL (`GERBEUR_FRONTEND_URL`). In single-container deployments - * the API serves the frontend, so it defaults to {@link PUBLIC_URL}; set it - * when the frontend runs on a separate host or port (e.g. Vite dev server or a - * CDN origin). + * The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't + * {@link PUBLIC_URL}, as with a separate Vite server. It is an allowed origin, + * and password reset links point to it. */ export const FRONTEND_URL: string = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL; /** - * The SQLite database file (`GERBEUR_DB_PATH`). Overridable so the test suite - * can run against a throwaway database (see api/test/setup.ts). + * The SQLite database file (`GERBEUR_DB_PATH`). api/test/setup.ts points it at + * a throwaway file before anything opens the database. */ export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") || "api/sql/gerbeur.db"; -// Upload/files /** - * The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`), one - * subdirectory per kind. + * The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`). Each kind has + * its subdirectory below, created by the first upload, and each file is named + * after the id of what it belongs to. */ export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") || "api/uploads"; -/** Where file dumps are stored. */ +/** Holds the file of each file dump. */ export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; -/** Where avatars are stored. */ +/** Holds each user's avatar. */ export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; -/** Where playlist cover images are stored. */ +/** Holds each playlist's cover. */ export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; -/** Where images pasted into chat, comments and descriptions are stored. */ +/** + * Holds the images pasted into chat messages, comments, dump notes and + * descriptions. The startup tasks purge those nothing links to. + */ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; -/** Where custom dump thumbnails are stored. */ +/** + * Holds the thumbnails a poster uploads (`-custom`) and those the + * thumbnails route generates from file dumps. + */ export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; -/** The largest image upload accepted: 5 MB. */ +/** + * The largest image upload accepted, in bytes. lib/upload.ts's error message + * says "5 MB", so the two change together. + */ export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; /** The image types accepted for avatars, covers, thumbnails and attachments. */ export const ALLOWED_IMAGE_MIMES: Set = new Set([ @@ -93,14 +105,17 @@ export const ALLOWED_IMAGE_MIMES: Set = new Set([ "image/webp", ]); -/** The MIME type prefixes a file dump may have. */ +/** + * The MIME type prefixes a file dump may have. dump-service also accepts the + * exact types of {@link DUMP_ALLOWED_MIME_TYPES}. + */ export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [ "text/", "image/", "video/", "audio/", ]; -/** The exact MIME types a file dump may have, beyond the prefixes. */ +/** The documents and archives a file dump may be, beyond the prefixes. */ export const DUMP_ALLOWED_MIME_TYPES: Set = new Set([ "application/pdf", "application/json", @@ -114,41 +129,44 @@ export const DUMP_ALLOWED_MIME_TYPES: Set = new Set([ "application/vnd.ms-powerpoint", ]); -// Pagination /** The page size when a request names none. */ export const PAGINATION_DEFAULT_LIMIT = 20; /** The largest page size a request may ask for. */ export const PAGINATION_MAX_LIMIT = 100; -// Startup housekeeping /** How long an attachment nothing links to is kept before startup purges it. */ export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; -/** How long an unused invite is kept before startup purges it. */ +/** + * How long an invite stays usable. invite-service refuses an older one, and + * the startup tasks purge it. + */ export const UNUSED_INVITES_RETENTION_DAYS = 7; -// SEO/OG -/** The site's name (`GERBEUR_SITE_NAME`), in page titles, OG tags and mail. */ +/** + * The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG + * tags, the icons' manifest and the emails. + */ export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; /** - * The emoji the favicon set and app header logo are generated from - * (`GERBEUR_SITE_EMOJI`). Applied at runtime (server startup and index.html - * injection), so changing it only needs a restart — no rebuild. + * The emoji that serves as the site's logo (`GERBEUR_SITE_EMOJI`). The icons + * script draws the favicons from it, and index.html carries it to the app's + * header, so changing it needs a restart but no rebuild. */ export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; /** - * A Bandcamp playback path. - * - * - `"embed"` — the bandcamp.com iframe (cannot autoplay) - * - `"native"` — resolve the page's mp3 streams and play them in the app's own - * player, which autoplays and supports albums as a queue + * How the frontend plays Bandcamp. `"embed"` uses Bandcamp's iframe, which + * can't autoplay. `"native"` resolves the page's streams through + * `GET /api/bandcamp/tracks` and plays them in the app's player, which + * autoplays and queues an album's tracks. */ export type BandcampPlayer = "embed" | "native"; /** - * The Bandcamp playback path the frontend uses (`GERBEUR_BANDCAMP_PLAYER`). - * Anything unrecognised falls back to `"embed"`, so a typo degrades safely. + * How the frontend plays Bandcamp (`GERBEUR_BANDCAMP_PLAYER`), passed to it in + * index.html. Any value but `"native"` means `"embed"`, so a typo falls back to + * the player that always works. */ export const BANDCAMP_PLAYER: BandcampPlayer = Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native" @@ -156,21 +174,21 @@ export const BANDCAMP_PLAYER: BandcampPlayer = : "embed"; /** - * The background color of generated icons and the manifest. Mirrors the - * hard-coded theme-color in index.html. + * The background of the generated Apple touch icon, the manifest's colors, and + * the accent of previews of this site's own pages. index.html's `theme-color` + * tags don't follow it. */ export const THEME_COLOR = "#111827"; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; /** - * The origins CORS and the WebSocket accept: {@link PUBLIC_URL}, - * {@link FRONTEND_URL} and the comma-separated `GERBEUR_ALLOWED_ORIGINS`. + * The origins that CORS in main.ts and the WebSocket route accept. They are + * {@link PUBLIC_URL}, {@link FRONTEND_URL} and the comma-separated + * `GERBEUR_ALLOWED_ORIGINS`. */ export const ALLOWED_ORIGINS: string[] = Array.from( new Set([ PUBLIC_URL, - // FRONTEND_URL is auto-included so the separate-frontend case only needs - // GERBEUR_FRONTEND_URL — no need to repeat it in GERBEUR_ALLOWED_ORIGINS. FRONTEND_URL, ...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean), ]), diff --git a/api/db/connection.ts b/api/db/connection.ts index 74048335..3f893f27 100644 --- a/api/db/connection.ts +++ b/api/db/connection.ts @@ -1,6 +1,7 @@ /** - * The database handle. Importing this module opens the database at - * {@link DB_PATH}, applies the pending migrations and runs the startup tasks. + * Opens the database at {@link DB_PATH} when first imported, applies the + * pending migrations and runs the startup tasks before any importer runs. Only + * the services import it. * * @module */ @@ -10,12 +11,16 @@ import { DB_PATH } from "../config.ts"; import { runMigrations } from "./migrate.ts"; import { runStartupTasks } from "./startup.ts"; -/** The one connection every service queries through. */ +/** + * The server's only connection. Its queries are synchronous, so each one + * blocks the event loop while it runs. + */ export const db: DatabaseSync = new DatabaseSync(DB_PATH); // WAL lets readers run alongside a writer, and with synchronous = NORMAL a -// commit no longer waits on an fsync (a crash can lose the last transactions, -// never corrupt the file). busy_timeout covers the init/migration scripts and -// the test harness opening the same file. +// commit doesn't wait on an fsync: a power loss can lose the last commits but +// never corrupts the file. busy_timeout makes another connection to the file +// wait for the lock instead of failing. SQLite enforces foreign keys, and so +// the schema's ON DELETE cascades, only when each connection asks. db.exec(` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; @@ -26,9 +31,10 @@ runMigrations(db); await runStartupTasks(db); /** - * Runs `fn` inside one transaction: several writes commit together (and cost - * one commit rather than one each), and a throw rolls them all back. Nested - * calls join the outer transaction. + * Runs `fn` in a transaction, so its writes commit together, in one commit + * rather than one each, and a throw rolls them all back and rethrows. A call + * inside another joins the outer transaction. `fn` must be synchronous: an + * `await` would commit before the rest of it runs. */ export function transaction(fn: () => T): T { if (db.isTransaction) return fn(); diff --git a/api/db/init.ts b/api/db/init.ts index c5d6daa5..295facec 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -1,6 +1,8 @@ /** - * Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet; - * run once before the server starts (`deno task start`). + * Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet, + * and leaves an existing one alone. `deno task start` runs it before the + * server, which then runs every migration on the new file: schema.sql already + * holds their changes, so each one finds nothing to do. * * @module */ diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 4e663f6f..cf66c3f9 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -1,6 +1,7 @@ /** - * Brings an existing database up to date with the migrations under - * migrations/, recording each applied one in `schema_migrations`. + * Brings the database up to date with the migrations under migrations/, and + * records each one it applies in `schema_migrations`. connection.ts runs it + * each time the server opens the database. * * @module */ @@ -29,10 +30,10 @@ interface Migration { } /** - * Every migration, in order. Append-only — never edit or reorder a migration - * once it has shipped. Each `up` must be idempotent: it also runs against - * fresh databases created straight from schema.sql, where the change it makes - * already exists. + * Every migration, in the order they run. A new one goes at the end, along + * with the same change to schema.sql, and a shipped one is never edited or + * moved. A database made by init.ts records none of them, so each `up` must + * find its change already made and leave it be. */ const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, @@ -57,11 +58,11 @@ const MIGRATIONS: Migration[] = [ ]; /** - * Applies each migration `db` hasn't recorded yet, each in its own + * Applies each migration `db` hasn't recorded yet, in order, each in its own * transaction. * - * @throws {Error} When a migration fails; that migration is rolled back and - * the later ones aren't run. + * @throws {Error} When a migration fails. That one is rolled back, the later + * ones don't run, and the server doesn't start. */ export function runMigrations(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( diff --git a/api/db/migrations/0001_comment_likes.ts b/api/db/migrations/0001_comment_likes.ts index 59adca68..5fb0a0f9 100644 --- a/api/db/migrations/0001_comment_likes.ts +++ b/api/db/migrations/0001_comment_likes.ts @@ -1,16 +1,17 @@ /** - * Adds comment likes: the `comment_likes` table and the `comments.like_count` - * counter. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with these columns/tables) or an existing one that predates them. + * Adds likes on comments, as the `comment_likes` table and a `like_count` on + * each comment that comment-like-service keeps in step, so that listing + * comments doesn't count their likes. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing comments start with no likes, and a + * database made from schema.sql already has both. + */ export function up(db: DatabaseSync): void { const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as { name: string; diff --git a/api/db/migrations/0002_dump_thumbnail.ts b/api/db/migrations/0002_dump_thumbnail.ts index f71363d9..b4f7b1c4 100644 --- a/api/db/migrations/0002_dump_thumbnail.ts +++ b/api/db/migrations/0002_dump_thumbnail.ts @@ -1,15 +1,17 @@ /** - * Adds `dumps.custom_thumbnail_mime`, for thumbnails uploaded by the poster. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with this column) or an existing one that predates it. + * Adds `dumps.custom_thumbnail_mime`, so a poster can upload a thumbnail that + * replaces the fetched one. It holds the uploaded image's type, and NULL means + * the dump has none. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing dumps keep their fetched thumbnails, + * and a database made from schema.sql already has the column. + */ export function up(db: DatabaseSync): void { const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as { name: string; diff --git a/api/db/migrations/0003_dump_backlinks.ts b/api/db/migrations/0003_dump_backlinks.ts index 0cc17c01..ad1f033e 100644 --- a/api/db/migrations/0003_dump_backlinks.ts +++ b/api/db/migrations/0003_dump_backlinks.ts @@ -1,16 +1,18 @@ /** - * Adds the `dump_backlinks` table, recording which dumps, comments and - * descriptions link to a dump, and indexes `dumps.url`. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with these) or an existing one that predates them. + * Adds `dump_backlinks`, which records the links from a dump's note or + * comments to other dumps, so a dump page can list the dumps related to it. + * backlink-service fills it, and the index on `dumps.url` lets it find the + * dumps an external link was posted as. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. It doesn't scan the notes and comments already + * posted, so their links are recorded only once they are edited. + */ export function up(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks ( source_type TEXT NOT NULL, diff --git a/api/db/migrations/0004_user_roles.ts b/api/db/migrations/0004_user_roles.ts index 861364ad..4056eb78 100644 --- a/api/db/migrations/0004_user_roles.ts +++ b/api/db/migrations/0004_user_roles.ts @@ -1,15 +1,16 @@ /** - * Adds `users.role` and makes every legacy `is_admin` user an admin. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with the `role` column) or an existing one that predates it. + * Adds `users.role`, which replaces the `is_admin` flag so that moderators can + * exist between users and admins. 0005 then drops the flag. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Every user becomes a `user`, except those with + * `is_admin` set, who become admins. + */ export function up(db: DatabaseSync): void { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; @@ -20,8 +21,7 @@ export function up(db: DatabaseSync): void { ); } - // Backfill existing admins to role 'admin'. Guarded because the legacy - // is_admin column is absent on fresh databases (and dropped by 0005). + // schema.sql never had is_admin. if (userCols.some((c) => c.name === "is_admin")) { db.exec( `UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`, diff --git a/api/db/migrations/0005_drop_is_admin.ts b/api/db/migrations/0005_drop_is_admin.ts index 0fea739c..a4f50be5 100644 --- a/api/db/migrations/0005_drop_is_admin.ts +++ b/api/db/migrations/0005_drop_is_admin.ts @@ -1,14 +1,15 @@ /** - * Drops the legacy is_admin column now that `role` is the single source of - * truth. Idempotent: skips when the column is already gone (fresh databases - * created from schema.sql never had it). Must run after 0004 has backfilled. + * Drops `users.is_admin`, now that 0004 has copied it into `users.role`. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`, after 0004. A database made from schema.sql + * never had the column. + */ export function up(db: DatabaseSync): void { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0006_categories.ts b/api/db/migrations/0006_categories.ts index 6f9aabe1..b7ef2a40 100644 --- a/api/db/migrations/0006_categories.ts +++ b/api/db/migrations/0006_categories.ts @@ -1,16 +1,16 @@ /** - * Adds categories: the `categories` table and the `dump_categories` join - * table. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with the categories tables) or an existing one that predates them. + * Adds categories, which admins create and posters file their dumps under, as + * the `categories` table and the `dump_categories` join table. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing dumps start in no category, and a + * database made from schema.sql already has both tables. + */ export function up(db: DatabaseSync): void { const tables = new Set( (db.prepare( diff --git a/api/db/migrations/0007_password_reset_tokens.ts b/api/db/migrations/0007_password_reset_tokens.ts index 316ef0a2..0e3dd564 100644 --- a/api/db/migrations/0007_password_reset_tokens.ts +++ b/api/db/migrations/0007_password_reset_tokens.ts @@ -1,15 +1,16 @@ /** - * Adds the `password_reset_tokens` table. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with password_reset_tokens) or an existing one that predates the table. + * Adds `password_reset_tokens`, which holds the single-use tokens that + * password-reset-service emails to users who forgot their password. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. A database made from schema.sql already has the + * table. + */ export function up(db: DatabaseSync): void { const tables = new Set( (db.prepare( diff --git a/api/db/migrations/0008_chat_messages.ts b/api/db/migrations/0008_chat_messages.ts index 03133a4e..8ff17e3d 100644 --- a/api/db/migrations/0008_chat_messages.ts +++ b/api/db/migrations/0008_chat_messages.ts @@ -1,15 +1,15 @@ /** - * Adds the `chat_messages` table. - * - * Idempotent: safe to run against a fresh db (already created from schema.sql - * with this table) or an existing one that predates it. + * Adds `chat_messages`, which holds the messages of the site-wide chat. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. A database made from schema.sql already has the + * table. + */ export function up(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS chat_messages ( id TEXT NOT NULL PRIMARY KEY, diff --git a/api/db/migrations/0009_chat_reply.ts b/api/db/migrations/0009_chat_reply.ts index bc361883..2e163667 100644 --- a/api/db/migrations/0009_chat_reply.ts +++ b/api/db/migrations/0009_chat_reply.ts @@ -1,18 +1,18 @@ /** - * Adds an optional self-reference so a chat message can reply to another. - * Kept as a plain column (no FK): when the replied-to message is later deleted - * the id is retained but the join yields null, letting the UI show a subtle - * "deleted message" reference instead of breaking the insert. - * - * Idempotent: also runs against fresh databases created from schema.sql, where - * the column already exists. + * Adds `chat_messages.reply_to_id`, so a message can reply to another. It has + * no foreign key on purpose: when the replied-to message is deleted, the id + * stays and chat-service's join reads null, so the chat shows "deleted + * message" where the quote was. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing messages reply to nothing, and a + * database made from schema.sql already has the column. + */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as { name: string; diff --git a/api/db/migrations/0010_youtube_embed_start.ts b/api/db/migrations/0010_youtube_embed_start.ts index a1370db9..b831ca5f 100644 --- a/api/db/migrations/0010_youtube_embed_start.ts +++ b/api/db/migrations/0010_youtube_embed_start.ts @@ -1,25 +1,22 @@ /** - * Backfills the `start` parameter on stored YouTube embed URLs. + * Adds the `start` parameter to the YouTube embed URLs stored before the + * YouTube provider carried it over. `rich_content.embedUrl` is computed when a + * dump is posted, and used to drop the source URL's `t` (`?t=1265`, + * `?t=1h2m3s`), so those videos played from 0:00. * - * `rich_content.embedUrl` is computed once, at dump creation, and previously - * dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so - * those videos always opened at 0:00 in the global player. The provider now - * translates `t`/`start` into the `start` param the iframe player honours; this - * migration applies the same translation to rows written before that fix. - * - * Purely local — it re-derives the param from the already-stored source URL and - * makes no network calls. Self-contained (no imports from api/services) so it - * stays reproducible even if the provider's parsing evolves later. - * - * Idempotent: rows whose embed URL already carries a `start` are skipped, so a - * fresh database built from schema.sql is simply a no-op. + * It reads the time from the stored source URL and fetches nothing. It keeps + * its own copy of the parsing, so that it migrates every database alike should + * the provider's change. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */ +/** + * Returns the seconds in `url`'s `t` or `start`, which may be plain seconds, + * `90s`, `20m5s` or `1h2m3s`. + */ function extractStartSeconds(url: string): number | null { let raw: string | null; try { @@ -38,7 +35,10 @@ function extractStartSeconds(url: string): number | null { Number(hms[3] ?? 0); } -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. It leaves alone an embed URL that already has a + * `start`, and a row whose `rich_content` doesn't parse. + */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, url, rich_content FROM dumps @@ -55,7 +55,7 @@ export function up(db: DatabaseSync): void { try { rich = JSON.parse(row.rich_content); } catch { - continue; // malformed payload — leave it untouched + continue; } if (rich.type !== "youtube" || !rich.embedUrl) continue; diff --git a/api/db/migrations/0011_split_favicon_thumbnail.ts b/api/db/migrations/0011_split_favicon_thumbnail.ts index 6bfaa965..07196a89 100644 --- a/api/db/migrations/0011_split_favicon_thumbnail.ts +++ b/api/db/migrations/0011_split_favicon_thumbnail.ts @@ -1,21 +1,14 @@ /** - * Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new - * `faviconUrl` field. + * Moves site icons out of `rich_content.thumbnailUrl` into the new + * `faviconUrl`. The metadata fetch used to fall back on the page's icon, and + * then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a + * 404, that the UI stretched over a 128×72 box. It now stops at real artwork, + * and a missing `thumbnailUrl` means none, which the frontend draws a + * placeholder for. This gives the older rows that meaning. * - * The extraction cascade used to end at the page's icon and then at a guessed - * `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just - * held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72 - * box. The cascade now stops at real artwork, and an absent `thumbnailUrl` - * means "no artwork", which is what lets the frontend draw a placeholder. - * This migration gives rows written before that change the same meaning. - * - * Purely local — it classifies the already-stored URL and makes no network - * calls, so `accentColor` is deliberately not backfilled: the frontend derives - * a stable hue from the hostname whenever one is missing, and - * `refreshDumpMetadata` fetches the real color on demand. - * - * Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh - * database built from schema.sql is a no-op. + * It judges the stored URL alone and fetches nothing, so it doesn't fill + * `accentColor`. The frontend derives a hue from the hostname when it is + * missing, and refreshing a dump's metadata fetches the real one. * * @module */ @@ -23,12 +16,9 @@ import type { DatabaseSync } from "node:sqlite"; /** - * Checks whether a stored thumbnail URL is really a site icon. - * - * Deliberately loose. A false positive (a genuine cover image living under - * `/assets/icons/`) renders contained on a tinted field instead of - * cover-cropped — mildly wrong, never broken — so chasing them isn't worth the - * extra rules. + * Checks whether a stored thumbnail URL is a site icon. It is loose on + * purpose, since artwork taken for an icon still shows whole on a tinted field, + * only not cropped to fill the box. 0012 fixes the rows it got wrong. */ function isIconUrl(raw: string): boolean { let pathname: string; @@ -41,7 +31,11 @@ function isIconUrl(raw: string): boolean { /\.(ico|svg)$/i.test(pathname); } -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. It leaves alone a row that already has a + * `faviconUrl`, and one whose `rich_content` doesn't parse. 0012 undoes its + * mistakes. + */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, rich_content FROM dumps @@ -58,7 +52,7 @@ export function up(db: DatabaseSync): void { try { rich = JSON.parse(row.rich_content); } catch { - continue; // malformed payload — leave it untouched + continue; } if (rich.faviconUrl || !rich.thumbnailUrl) continue; if (!isIconUrl(rich.thumbnailUrl)) continue; diff --git a/api/db/migrations/0012_fix_favicon_reclassification.ts b/api/db/migrations/0012_fix_favicon_reclassification.ts index 0126206f..d80c563b 100644 --- a/api/db/migrations/0012_fix_favicon_reclassification.ts +++ b/api/db/migrations/0012_fix_favicon_reclassification.ts @@ -1,30 +1,19 @@ /** - * Repairs 0011, which was too eager about what counts as a site icon. + * Repairs the rows 0011 got wrong. 0011 took any path containing "favicon", + * or ending in `.svg`, for an icon, so artwork such as + * `/wp-content/uploads/2022/04/mir-logo-favicon.png` lost its `thumbnailUrl`, + * and its dump showed as a pull-quote instead of an image in the journal + * mosaic. * - * 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image` - * like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was - * moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted - * the dump from an image card to a pull-quote in the journal mosaic. A blanket - * `.svg` match had the same effect on real artwork. - * - * `isIconUrl` here looks at the *filename* rather than the whole path: an icon - * is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/` - * directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and - * catches `icon_SEARCH.png`, which 0011 missed. - * - * Applied in both directions, and deliberately narrow when reversing: a value - * is only promoted back to `thumbnailUrl` if it matches 0011's rule but not - * this one, so rows written by a normal fetch aren't disturbed. + * This rule judges the file name and its folder instead. It spares + * `mir-logo-favicon.png` and catches `icon_SEARCH.png`, which 0011 missed. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** - * Applies 0011's rule, kept verbatim so its false positives can be - * identified. - */ +/** 0011's rule, copied as it shipped, to tell which rows it moved. */ function wasIconUrl0011(raw: string): boolean { let pathname: string; try { @@ -37,8 +26,9 @@ function wasIconUrl0011(raw: string): boolean { } /** - * Checks whether a URL names a site icon, judged on its filename and - * directory. + * Checks whether a URL is a site icon. It is one when the file ends in `.ico`, + * sits in a `favicon(s)` or `icon(s)` folder, or is named `favicon`, + * `apple-touch-icon` or `icon`, alone or followed by a digit, `-`, `_` or `.`. */ function isIconUrl(raw: string): boolean { let pathname: string; @@ -55,7 +45,12 @@ function isIconUrl(raw: string): boolean { return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename); } -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. It moves a `faviconUrl` back to `thumbnailUrl` + * only when 0011's rule takes it for an icon and this one doesn't. That also + * catches the ones a later fetch stored that this rule doesn't recognise, such + * as a `logo.svg`. It then moves the icons 0011 missed. + */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, rich_content FROM dumps @@ -73,10 +68,9 @@ export function up(db: DatabaseSync): void { try { rich = JSON.parse(row.rich_content); } catch { - continue; // malformed payload — leave it untouched + continue; } - // Artwork 0011 mistook for an icon: put it back. if ( rich.faviconUrl && !rich.thumbnailUrl && wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl) @@ -90,7 +84,6 @@ export function up(db: DatabaseSync): void { continue; } - // An icon 0011's rule didn't recognize: move it now. if ( rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl) ) { diff --git a/api/db/migrations/0013_dump_url_canonical.ts b/api/db/migrations/0013_dump_url_canonical.ts index 2781252a..75d0675a 100644 --- a/api/db/migrations/0013_dump_url_canonical.ts +++ b/api/db/migrations/0013_dump_url_canonical.ts @@ -1,19 +1,12 @@ /** - * Adds `dumps.url_canonical` — the lookup key behind the "already dumped?" - * check on the create form — and backfills it for every existing URL dump. + * Adds `dumps.url_canonical`, the key the create form's "already dumped" hint + * looks a URL up by, and fills it for every URL dump already posted. * - * Purely local: it re-derives the key from the URL already stored on each row - * and makes no network calls. - * - * The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts` - * as it stood when this migration shipped, not an import of it. A migration - * runs exactly once per database, so importing the live version would mean two - * databases migrating at different times end up with keys computed under - * different rules. Improving the shared canonicalizer therefore calls for a new - * backfill migration rather than an edit here. - * - * Idempotent: the column is only added when missing and only rows whose key is - * still NULL are touched, so a fresh database built from schema.sql is a no-op. + * It fills the key from each stored URL and fetches nothing. Its helpers are a + * copy of lib/canonical-url.ts as it stood when this shipped: importing the + * live one would give two databases migrated at different times keys made by + * different rules. A change to lib/canonical-url.ts therefore needs a new + * migration that fills the keys again. * * @module */ @@ -98,7 +91,10 @@ function canonicalizeUrl(raw: string): string | null { return `https://${host}${path}${query ? `?${query}` : ""}${hash}`; } -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. It fills only the keys still NULL, and leaves + * NULL a URL that isn't http(s). + */ export function up(db: DatabaseSync): void { const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as { name: string; diff --git a/api/db/migrations/0014_chat_read_state.ts b/api/db/migrations/0014_chat_read_state.ts index e7ba3ba0..4a7abafc 100644 --- a/api/db/migrations/0014_chat_read_state.ts +++ b/api/db/migrations/0014_chat_read_state.ts @@ -1,21 +1,22 @@ /** - * Chat unread state lives on the server so it survives reloads and agrees - * across tabs and devices: `users.chat_read_at` is the created_at of the newest - * message the user has seen. Existing users start caught up (now) rather than - * with the whole history unread; new users fall back to their own created_at. + * Moves the chat's unread state to the server, so that it survives a reload + * and agrees across tabs and devices. `users.chat_read_at` holds the time of + * the newest message the user has seen. * - * The history cursor now pages on (created_at, id) so two messages sharing a - * millisecond can't be skipped at a page boundary; the index follows suit. - * - * Idempotent: also runs against fresh databases created from schema.sql, where - * the column and index already exist. + * It also replaces the index on `created_at` with one on `(created_at, id)`, + * which the history now pages on, so that two messages from the same + * millisecond can't fall between two pages. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing users start with every message read, + * instead of the whole history unread. A user created later starts with a + * NULL `chat_read_at`, which chat-service reads as the user's creation time. + */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0015_token_revocation.ts b/api/db/migrations/0015_token_revocation.ts index 62f283a1..0ffd9947 100644 --- a/api/db/migrations/0015_token_revocation.ts +++ b/api/db/migrations/0015_token_revocation.ts @@ -1,18 +1,17 @@ /** - * Changing or resetting a password must end every session opened before it. - * Tokens are stateless JWTs, so each user carries a cut-off instead: - * `users.tokens_valid_after` (ISO), and a token issued before it is refused. - * NULL — the state every existing user starts in — means no cut-off. - * - * Idempotent: also runs against fresh databases created from schema.sql, where - * the column already exists. + * Adds `users.tokens_valid_after`, so that changing a password ends every + * session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a + * token issued before that time instead. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. Existing users start with NULL, which revokes no + * token. + */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0016_performance_indexes.ts b/api/db/migrations/0016_performance_indexes.ts index b48e0742..0baa46e5 100644 --- a/api/db/migrations/0016_performance_indexes.ts +++ b/api/db/migrations/0016_performance_indexes.ts @@ -1,24 +1,21 @@ /** - * Indexes for the queries that ran as full scans. - * - * - feeds page by created_at (newest first), a user's dumps and upvotes by - * (user_id, created_at) — these replace the bare user_id indexes; - * - dump and playlist pages are looked up by slug; - * - the child side of every ON DELETE CASCADE / SET NULL foreign key, which - * SQLite otherwise scans on each parent delete (a comment's replies, a user's - * comments, follows, chat messages, invites, reset tokens), and the - * followers of a user or playlist, read on every new dump; - * - @mentions resolve usernames case-insensitively, by lower(username). - * - * Idempotent: also runs against fresh databases created from schema.sql, where - * these indexes already exist. + * Adds indexes for the queries that scanned a whole table. The feeds page + * dumps by `created_at`, and a user's dumps and upvotes by `(user_id, + * created_at)`, which replace the indexes on `user_id` alone. Dumps and + * playlists are looked up by slug. Each foreign key with ON DELETE gets an + * index on its child column, which SQLite otherwise scans on every delete of + * a parent row, and the followers of a user or playlist are read on every new + * dump. Mentions look usernames up by `lower(username)`. * * @module */ import type { DatabaseSync } from "node:sqlite"; -/** Applies this migration to `db`. */ +/** + * Run once by `runMigrations`. A database made from schema.sql already has + * these indexes. + */ export function up(db: DatabaseSync): void { db.exec(` DROP INDEX IF EXISTS idx_dumps_user; diff --git a/api/db/startup.ts b/api/db/startup.ts index 119e1b83..b1e69011 100644 --- a/api/db/startup.ts +++ b/api/db/startup.ts @@ -1,5 +1,6 @@ /** - * Housekeeping run when the database is opened. + * Deletes what has expired and seeds the first admin, each time connection.ts + * opens the database. * * @module */ @@ -13,22 +14,22 @@ import { } from "../config.ts"; /** - * Runs once when the database is opened, after the migrations: drops what has - * expired (reset tokens, unused invites, orphaned attachments), and seeds the - * default admin (admin/admin) on an empty instance. + * Runs after the migrations, each time the server starts. It deletes used or + * expired reset tokens, invites past their validity and attachments nothing + * links to. On an instance with no users, it creates the user `admin` with the + * password `admin`, which whoever installs it must change. */ export async function runStartupTasks(db: DatabaseSync): Promise { - // Purge expired/used password reset tokens db.prepare( `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, ).run(); - // Purge expired unused invites db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, ).run(); - // Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour + // An image is uploaded as soon as it's pasted, and linked only when the text + // it's pasted into is saved, so a fresh one is left alone. const orphanedAttachments = db.prepare( `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, ).all() as { id: string }[]; @@ -41,11 +42,12 @@ export async function runStartupTasks(db: DatabaseSync): Promise { ).run(); } - // Create default admin user if no users exist + // Registration needs an invite, so the first user can only come from here. const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { count: number; }; if (userCount.count === 0) { + // The format of hashPassword in lib/jwt.ts, which verifies it. const salt = randomBytes(16).toString("hex"); const hash = scryptSync("admin", salt, 64).toString("hex"); const passwordHash = `${hash}.${salt}`; diff --git a/api/lib/api-exception.ts b/api/lib/api-exception.ts index 415b89cd..9faaccab 100644 --- a/api/lib/api-exception.ts +++ b/api/lib/api-exception.ts @@ -1,5 +1,6 @@ /** - * The exception that carries an HTTP error out of any layer below a route. + * Defines the exception that carries an HTTP error from any layer up to the + * error middleware. * * @module */ @@ -7,16 +8,18 @@ import type { APIErrorCode } from "../../shared/api.ts"; /** - * An error meant for the client, thrown anywhere below a route; the error - * middleware turns it into an `APIFailure` response. + * Thrown by a route, a service or a remote fetch to answer the request with an + * error. The error middleware sends it as an `APIFailure`, message included, + * so the message must be fit for the user. */ export class APIException extends Error { - /** The error code the response carries. */ readonly code: APIErrorCode; - /** The HTTP status the response is sent with. */ readonly status: number; - /** Creates an exception answering `status` with `code` and `message`. */ + /** + * Creates the exception. A code goes with several statuses, as + * `UNAUTHORIZED` does with 401 and 403, so both are given. + */ constructor(code: APIErrorCode, status: number, message: string) { super(message); this.code = code; diff --git a/api/lib/auth.ts b/api/lib/auth.ts index 5a2d853d..091813a2 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -1,6 +1,7 @@ /** - * Authenticates the tokens a request carries: session tokens in the - * `Authorization` header and media tokens in a media URL's query string. + * Checks session and media tokens against the live user row, not just their + * signature. The auth middleware, the WebSocket route, the media routes and the + * routes open to anonymous callers all authenticate through it. * * @module */ @@ -14,13 +15,10 @@ import type { Role } from "../../shared/user.ts"; import { getSessionState } from "../services/user-service.ts"; /** - * Checks a signature-verified token against the live user row: the user must - * still exist, and the token must not predate their last password change - * (`users.tokens_valid_after`). Returns the live role, or null to refuse. - * - * `iat` has one-second resolution, so a token issued in the same second as the - * change survives it; one issued after it (the fresh one the change hands back) - * always does. + * Returns the live role of a verified token's user, or null when the user is + * gone or the token predates their last password change + * (`users.tokens_valid_after`). `iat` counts whole seconds, so a token issued + * in the same second as the change survives it. */ function checkTokenOwner( userId: string, @@ -38,9 +36,10 @@ function checkTokenOwner( } /** - * Verifies a session token end to end: signature, expiry, a user that still - * exists and no password change since it was issued. The role comes from the - * DB, not the token, since tokens last 7 days and roles change under them. + * Returns the payload of a valid session token, or null. Besides the signature + * and expiry, it refuses a token whose user is gone or which predates a + * password change. The returned role comes from the database, not the token, + * since a token lasts 7 days and a role can change under it. */ export async function authenticate( token: string, @@ -53,8 +52,9 @@ export async function authenticate( } /** - * Resolves the `?token=` on a media URL to the viewer's user id. Takes a media - * token, or a session token from a client that predates them. + * Returns the user id behind the `?token=` of a file, thumbnail or playlist + * cover URL, or null when it isn't valid. A session token is accepted too, for + * clients from before media tokens. */ export async function authenticateMedia(token: string): Promise { const media = await verifyMediaToken(token); @@ -65,18 +65,13 @@ export async function authenticateMedia(token: string): Promise { } /** - * Returns the user id of an optional Bearer token, or null for an anonymous - * request. + * Returns the caller's user id on a route open to anonymous callers, or null + * when the request has no token. A placeholder such as `Bearer undefined` + * counts as no token. An invalid token throws instead of reading as anonymous, + * so that a stale session gets a 401 the client signs out on, rather than a + * misleading 404 on a private resource. * - * Distinguishes *missing* credentials from *invalid* ones: - * - No `Authorization: Bearer …` header (or a placeholder value such as the - * `Bearer undefined` that the client emits when logged out) → anonymous (null). - * - A real token that fails verification (expired / bad signature / revoked) → - * throws 401, so a stale client session surfaces as an auth error it can react - * to, instead of being silently downgraded to anonymous (which yields a - * misleading 404 on private resources). - * - * @throws {APIException} 401 when a token is present but fails verification. + * @throws {APIException} 401 when a token is present but invalid. */ export async function parseOptionalAuth(ctx: Context): Promise { const authHeader = ctx.request.headers.get("Authorization"); diff --git a/api/lib/canonical-url.ts b/api/lib/canonical-url.ts index dfe13d8b..12770475 100644 --- a/api/lib/canonical-url.ts +++ b/api/lib/canonical-url.ts @@ -1,18 +1,13 @@ /** - * The canonical form of a URL, used *only* to recognise that two links point at the - * same thing — the duplicate check the create form runs while it fetches a - * preview. It is a lookup key, never something we display or fetch: `dumps.url` - * keeps the exact string the poster submitted. + * Reduces a URL to a key shared by every link to the same page or video. + * `dump-service` stores it in `dumps.url_canonical` when a + * dump is posted or edited, and `GET /api/dumps/by-url` matches on it + * to warn the create form of a repost. The key is never fetched or shown, so it + * may be lossy: it forces `https`, drops `www.`, tracking parameters and + * timestamps. * - * Because it is only ever compared against other canonical forms, it may be - * lossy in ways a real URL never could be — it forces `https`, drops `www.`, - * and throws away share/tracking parameters and timestamps, so - * `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse - * to one key. - * - * Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this - * logic to backfill existing rows; changing the rules here therefore needs a - * new backfill migration, or old rows keep keys computed under the old rules. + * Migration 0013 backfilled existing rows with a frozen copy of these rules. + * Changing them needs a new backfill migration, or old rows keep old keys. * * @module */ @@ -42,7 +37,7 @@ function isTrackingParam(key: string): boolean { return k.startsWith("utm_") || TRACKING_PARAMS.has(k); } -/** YouTube's hosts, already stripped of a leading "www.". */ +/** YouTube's hosts, without the "www." {@link canonicalizeUrl} strips first. */ const YOUTUBE_HOSTS = new Set([ "youtube.com", "m.youtube.com", @@ -51,10 +46,10 @@ const YOUTUBE_HOSTS = new Set([ ]); /** - * Returns the video a YouTube URL points at, in any of the shapes people paste - * (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`). - * Timestamps are deliberately ignored: the same video linked at 2:30 is still - * the same video for duplicate purposes. + * Returns the id of the video a YouTube URL points at, in any of the shapes + * people paste (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, + * `/live/ID`). The timestamp is dropped: the same video linked at 2:30 is still + * a repost. */ function youtubeVideoId( host: string, @@ -71,8 +66,8 @@ function youtubeVideoId( } /** - * Returns the canonical form of `raw` (see the module doc), or null when it - * isn't an http(s) URL. + * Returns the canonical key of `raw`, or null when it isn't an http(s) URL. A + * YouTube video or playlist becomes one fixed URL whatever shape it came in. */ export function canonicalizeUrl(raw: string): string | null { let u: URL; @@ -94,7 +89,7 @@ export function canonicalizeUrl(raw: string): string | null { : null; if (listId) return `https://youtube.com/playlist?list=${listId}`; - // A single trailing slash is never meaningful; "/" itself becomes "". + // Trailing slashes are dropped; "/" itself becomes "". const path = u.pathname.replace(/\/+$/, ""); const params = [...u.searchParams.entries()] @@ -102,8 +97,8 @@ export function canonicalizeUrl(raw: string): string | null { .sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv)); const query = new URLSearchParams(params).toString(); - // A bare "#section" anchor points into the same page, so it is dropped — - // but "#/route" and "#!/route" address distinct pages of a hash-routed app. + // A "#section" anchor points into the same page and is dropped, but "#/route" + // and "#!/route" are distinct pages of a hash-routed app. const hash = /^#!?\//.test(u.hash) ? u.hash : ""; return `https://${host}${path}${query ? `?${query}` : ""}${hash}`; diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts index ca72c81b..2c1dd9b8 100644 --- a/api/lib/index-html.ts +++ b/api/lib/index-html.ts @@ -1,6 +1,7 @@ /** - * The built frontend's `index.html`, with its runtime placeholders filled: the - * site name, emoji, icon version and Bandcamp player mode. + * Fills the placeholders of the frontend's `index.html` with settings read at + * runtime, so they change with a restart and no rebuild. The static and OG + * middlewares serve what it returns. * * @module */ @@ -8,18 +9,20 @@ import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { emojiToCodepoint } from "./site-icons.ts"; -/** The cache-busting version of the icon URLs. */ +/** + * Versions the icon URLs, so browsers fetch new icons when the emoji changes. + */ const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); /** - * `index.html` with its placeholders filled, by root. Kept until the file - * changes (a rebuild under a running server): a stat per navigation instead of - * a read and four replaceAll. + * Caches each root's filled file until its mtime changes, as a rebuild under a + * running server does, so a navigation costs a stat instead of a read. */ const indexHtml = new Map(); /** - * Returns `${root}/index.html` with its placeholders filled. + * Returns `${root}/index.html` with the site name, emoji, icon version and + * Bandcamp player mode filled in. * * @throws {Error} When the file can't be read. */ diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 31d7e800..46935067 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,6 +1,8 @@ /** - * Signed tokens and password hashes: session tokens, media tokens, password - * reset tokens, and scrypt hashing. + * Signs and verifies the server's three kinds of token (session, media and + * password reset), all HS256 with the one key, and hashes passwords. Only a + * token's signature and expiry are checked here: `lib/auth.ts` checks a session + * or media token against its user. * * Importing this module throws when `GERBEUR_JWT_SECRET` is unset. * @@ -26,22 +28,26 @@ if (!JWT_SECRET) { } const JWT_KEY = new TextEncoder().encode(JWT_SECRET); -// ── Password reset tokens ───────────────────────────────────────────────────── - -/** Signs a single-use password reset token for `userId`, valid for an hour. */ +/** + * Signs a password reset token for `userId`, valid for an hour. The token + * isn't single-use by itself: `requestPasswordReset` stores it, and + * `resetPassword` marks it used. + */ export async function createPasswordResetToken( userId: string, ): Promise { return await new SignJWT({ purpose: "password-reset", userId }) .setProtectedHeader({ alg: "HS256" }) + // Two requests in the same second would otherwise sign the same token, + // which is the primary key of `password_reset_tokens`. .setJti(crypto.randomUUID()) .setExpirationTime("1h") .sign(JWT_KEY); } /** - * Verifies a password reset token and returns its payload, or null when it is - * invalid, expired or not a reset token. + * Returns the payload of a password reset token, or null when it is invalid, + * expired or another kind of token. Whether it was used is the caller's check. */ export async function verifyPasswordResetToken( token: string, @@ -55,29 +61,29 @@ export async function verifyPasswordResetToken( } } -// ── Auth tokens ─────────────────────────────────────────────────────────────── - -/** Signs a session token carrying `payload`, valid for 7 days. */ +/** + * Signs a session token carrying `payload`, valid for 7 days. The users routes + * issue it on sign-up, sign-in and password change, and the client sends it as + * a bearer token. + */ export async function createJWT( payload: Omit, ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) - // `iat` is what a password change revokes against (users.tokens_valid_after). + // A password change revokes tokens by comparing `iat` with + // `users.tokens_valid_after`. .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } -// ── Media tokens ────────────────────────────────────────────────────────────── - /** - * Signs a media token for `userId`, valid for 7 days. - * - * Media elements can't send an Authorization header, so a private dump's file - * and thumbnail URLs carry a token in the query string, where it lands in - * access logs and browser history. That token is this one: it only reads media, - * so a leaked URL exposes the owner's private files, not their account. + * Signs a media token for `userId`, valid for 7 days. Media elements can't send + * an `Authorization` header, so the URLs of private files, thumbnails and + * playlist covers carry this token as `?token=`, where it lands in logs and + * browser history. It only reads media, so a leaked URL doesn't expose the + * account. */ export async function createMediaToken(userId: string): Promise { return await new SignJWT({ purpose: "media", userId }) @@ -88,8 +94,8 @@ export async function createMediaToken(userId: string): Promise { } /** - * Verifies a media token and returns its user id and issue time, or null when - * it is invalid, expired or not a media token. + * Returns the user id and issue time of a media token, or null when it is + * invalid, expired or another kind of token. */ export async function verifyMediaToken( token: string, @@ -106,8 +112,9 @@ export async function verifyMediaToken( } /** - * Verifies a session token's signature and expiry and returns its payload, or - * null. Checking it against the live user is `authenticate`'s job (lib/auth.ts). + * Returns the payload of a session token whose signature and expiry are valid, + * or null. Call `authenticate` (`lib/auth.ts`) instead, which also checks the + * token against its user. */ export async function verifyJWT(token: string): Promise { try { @@ -124,7 +131,10 @@ export async function verifyJWT(token: string): Promise { } } -/** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */ +/** + * Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for + * `users.password_hash`. + */ export function hashPassword(password: string): Promise { const salt = randomBytes(16).toString("hex"); @@ -142,8 +152,7 @@ export function verifyPassword( storedHash: string, ): Promise { const [hash, salt] = storedHash.split("."); - // A malformed stored hash can't match anything; scrypt would throw on the - // missing salt instead of answering. + // scrypt would throw on a missing salt, and a malformed hash matches nothing. if (!hash || !salt) return Promise.resolve(false); return new Promise((resolve, reject) => { diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts index 700134fb..713ce309 100644 --- a/api/lib/pagination.ts +++ b/api/lib/pagination.ts @@ -1,6 +1,7 @@ /** - * Page-based pagination: reading `page` and `limit` from a query string, and - * cutting fetched rows into a page. + * Reads the page a list route's query string asks for, and cuts the rows a + * service fetched into that page. The routes send the page as + * `PaginatedData`. * * @module */ @@ -8,9 +9,9 @@ import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts"; /** - * Reads the `page` and `limit` query parameters. `page` is at least 1 and - * defaults to 1; `limit` is clamped to [1, {@link PAGINATION_MAX_LIMIT}] and - * defaults to `defaultLimit`. + * Reads the `page` and `limit` query parameters of a list route. It never + * fails: a missing or invalid `page` reads as 1, and `limit` is kept between 1 + * and {@link PAGINATION_MAX_LIMIT}. */ export function parsePagination( params: URLSearchParams, @@ -30,18 +31,19 @@ export function parsePagination( return { page, limit }; } -/** One page of a list, and whether another follows. */ +/** + * Returned to the routes by the services' list functions, and sent as + * `PaginatedData`. + */ export interface Page { - /** The page's items, in order. */ items: T[]; - /** Whether another page follows this one. */ hasMore: boolean; } /** - * Turns rows fetched with `LIMIT limit + 1` into a page: the extra row, if it - * came back, is dropped and says there's a next page. No COUNT(*) over the - * whole list just to know that. + * Turns rows fetched with `LIMIT limit + 1` into a page. The extra row, when it + * comes back, is dropped and tells that another page follows, which spares a + * COUNT(*) over the whole list. */ export function pageOf(rows: T[], limit: number): Page { return rows.length > limit diff --git a/api/lib/permissions.ts b/api/lib/permissions.ts index 36f1b8b9..e72c926b 100644 --- a/api/lib/permissions.ts +++ b/api/lib/permissions.ts @@ -1,5 +1,7 @@ /** - * Permission checks against an authenticated caller. + * Checks an authenticated caller's permissions. Routes call it where the + * answer depends on more than the permission, such as "the owner, or a + * moderator"; `requirePermission` gates a whole route instead. * * @module */ @@ -7,7 +9,10 @@ import { hasPermission, type Permission } from "../../shared/permissions.ts"; import type { AuthPayload } from "../model/auth.ts"; -/** Checks whether the caller's role grants `permission`. */ +/** + * Checks whether the caller's role grants `permission`. The role is the live + * one `authenticate` put in the payload, not the token's. + */ export function can(payload: AuthPayload, permission: Permission): boolean { return hasPermission(payload.role, permission); } diff --git a/api/lib/playlist-members.ts b/api/lib/playlist-members.ts index 14a4329d..929c0c9d 100644 --- a/api/lib/playlist-members.ts +++ b/api/lib/playlist-members.ts @@ -1,26 +1,30 @@ /** - * Which of a playlist's dumps a given viewer may see. + * 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. * * @module */ import type { Playlist } from "../../shared/playlist.ts"; -/** A playlist's dump, with what deciding who may see it takes. */ +/** + * The fields of a playlist's dump that {@link isMemberVisibleTo} needs. A + * `Dump` has them. For its broadcasts, the playlist service reads only these. + */ export interface PlaylistMember { - /** The dump's id. */ id: string; - /** Whether the dump is private. */ isPrivate: boolean; - /** The id of the dump's poster. */ + /** The dump's poster, not the playlist's owner. */ userId: string; } /** - * Checks whether `viewerId` sees this member when the playlist is shown to them: a - * public dump, or a private one of the owner's own, to the owner. Everyone - * else — moderators too — doesn't see private dumps, and neither does the - * owner see someone else's dump once it has gone private. + * Checks whether `viewerId` may see `member` in `playlist`. A public dump is + * visible to all. A private dump is visible only to the playlist's owner, and + * only if they posted it: moderators don't see it, and neither does the owner + * once someone else's dump in their playlist goes private. A null or undefined + * `viewerId` is a signed-out viewer. */ export function isMemberVisibleTo( member: PlaylistMember, diff --git a/api/lib/rate-limit.ts b/api/lib/rate-limit.ts index b35c5c16..42ff49ec 100644 --- a/api/lib/rate-limit.ts +++ b/api/lib/rate-limit.ts @@ -1,7 +1,7 @@ /** - * In-memory rate limiting with token buckets, keyed by the caller's choice - * (e.g. `${kind}:${userId}`) — per user rather than per socket or request, so - * more tabs or parallel requests don't buy more throughput. + * Limits how often something may happen, with token buckets kept in memory, + * which a restart empties. Logins, password reset requests and the chat's + * typing messages use it. * * @module */ @@ -12,15 +12,22 @@ interface Bucket { } const buckets = new Map(); -/** A token bucket's shape. */ +/** + * Passed to {@link takeToken} by each limited action, which declares its own + * as a constant. + */ export interface RateLimit { - /** Burst size. */ + /** How many tokens a full bucket holds, and so the largest burst. */ capacity: number; /** Tokens regained per second. */ refill: number; } -/** Takes one token from `key`'s bucket, or returns false when it is empty. */ +/** + * Takes one token from `key`'s bucket, or returns false when it is empty. The + * caller picks the key, such as `typing:`, and keys by user or account + * so that more tabs or parallel requests don't buy more tokens. + */ export function takeToken(key: string, limit: RateLimit): boolean { const now = Date.now(); const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now }; @@ -35,8 +42,8 @@ export function takeToken(key: string, limit: RateLimit): boolean { return true; } -// Idle buckets have refilled by now for every limit in use; drop them so the -// map tracks only keys that have been active lately. +// Every limit in use refills within the hour, so an older bucket is full and +// dropping it changes nothing. setInterval(() => { const cutoff = Date.now() - 60 * 60_000; for (const [key, bucket] of buckets) { diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index d4eb2f32..ae07dbb9 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -1,13 +1,16 @@ /** - * Which slugs a category may take. Category slugs live at the URL root (e.g. - * /music), so they must never collide with the frontend's own routes. + * Checks which slugs a category may take. A category's feed lives at the URL + * root (`/music`), so its slug must not shadow another top-level route. The + * category service runs these checks when a category is created or renamed. * * @module */ /** - * Top-level path segments that already have meaning in the SPA router (see - * src/App.tsx), plus a few reserved for infrastructure. + * The top-level path segments a category slug may not take. They are the SPA's + * own routes (`src/App.tsx`), `api` and `assets`, which the server answers + * before the SPA, and `manage`, which no route uses any more. Keep it in step + * with the route table. */ export const RESERVED_SLUGS: readonly string[] = [ "~", @@ -25,12 +28,12 @@ export const RESERVED_SLUGS: readonly string[] = [ ]; /** - * A well-formed category slug: lowercase alphanumerics, - * hyphen-separated, no leading, trailing or double hyphen. + * Matches a well-formed category slug, made of lowercase letters and digits + * joined by single hyphens. */ export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** Checks whether `slug` is well-formed (see {@link CATEGORY_SLUG_RE}). */ +/** Checks whether `slug` matches {@link CATEGORY_SLUG_RE}. */ export function isValidSlugFormat(slug: string): boolean { return CATEGORY_SLUG_RE.test(slug); } @@ -40,7 +43,10 @@ export function isReservedSlug(slug: string): boolean { return RESERVED_SLUGS.includes(slug); } -/** Checks whether `slug` is well-formed and not reserved. */ +/** + * Checks whether `slug` is well-formed and not reserved. The category service + * runs the two checks apart, to give each failure its own message. + */ export function isSlugAvailable(slug: string): boolean { return isValidSlugFormat(slug) && !isReservedSlug(slug); } diff --git a/api/lib/self-url.ts b/api/lib/self-url.ts index dba4fd10..3df2ccad 100644 --- a/api/lib/self-url.ts +++ b/api/lib/self-url.ts @@ -1,6 +1,7 @@ /** - * Recognises links to this site's own pages, which previews resolve from the - * database instead of fetching. + * Recognises links to this site's own pages. The self provider previews them + * from the database, and remote/fetch.ts accepts them as dump links even on a + * private host such as localhost. * * @module */ diff --git a/api/lib/serve-file.ts b/api/lib/serve-file.ts index 57226f89..28f7a5c7 100644 --- a/api/lib/serve-file.ts +++ b/api/lib/serve-file.ts @@ -1,6 +1,7 @@ /** - * Serving files from disk with ETags and byte ranges, streamed a chunk at a - * time. + * Sends files from disk with ETags and byte ranges, streamed a chunk at a + * time. The files and thumbnails routes call it, and lib/upload.ts for the + * other uploads. * * @module */ @@ -67,21 +68,24 @@ function matchesEtag(header: string | null, etag: string): boolean { return header.split(",").some((tag) => strip(tag) === strip(etag)); } -/** How {@link serveFile} answers. */ +/** + * Passed to {@link serveFile} by its caller, which knows the file's type and + * how long it may be cached. + */ export interface ServeFileOptions { - /** The `Content-Type` sent. */ mime: string; - /** The `Cache-Control` sent. */ cacheControl: string; - /** Answer a single `Range: bytes=…` with a 206 (media files). */ + /** + * Answers a single `Range: bytes=…` with a 206. The files route sets it, so + * that audio and video can seek. + */ ranges?: boolean; } /** - * Sends a file from disk with an ETag (a 304 when the client already has it) - * and, when `ranges` is set, single byte ranges. The body is streamed. - * - * Returns false, having sent nothing, when the file doesn't exist. + * Sends a file from disk with an ETag, answering 304 when the client already + * has it, and single byte ranges when `ranges` is set. It returns false, having + * sent nothing, when the file doesn't exist, and leaves the 404 to the caller. */ export async function serveFile( ctx: Context, diff --git a/api/lib/site-icons.ts b/api/lib/site-icons.ts index 9eac4d11..e359da41 100644 --- a/api/lib/site-icons.ts +++ b/api/lib/site-icons.ts @@ -1,12 +1,10 @@ /** - * Generates the full favicon set and web manifest from a single emoji. + * Renders the site's favicons and web manifest from its emoji. The `icons` + * task (`api/scripts/generate-icons.ts`) runs it before the server starts, so + * the emoji can change without a rebuild. * - * Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without - * any system emoji font (which matters in the minimal Alpine runtime image). - * `@resvg/resvg-wasm` does the rasterization; the .ico is assembled by hand. - * - * Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in - * prod, public/ in dev) — see that script for how the output dir is chosen. + * The glyph comes from Twemoji as plain vector paths, so rendering it needs no + * emoji font, which the Alpine runtime image lacks. * * @module */ @@ -19,9 +17,10 @@ const APPLE_SIZE = 180; const ICO_SIZES = [16, 32, 48]; /** - * Returns the name Twemoji files `emoji` under: its codepoints joined with - * "-", variation selectors (FE0F) stripped unless the emoji is a ZWJ sequence. - * Also doubles as a stable cache-busting version string for the icon URLs. + * Returns the name Twemoji files `emoji` under: its codepoints in hex, joined + * with "-", without variation selectors (FE0F) unless the emoji is a ZWJ + * sequence. `lib/index-html.ts` also uses it as the icon URLs' cache-busting + * version. */ export function emojiToCodepoint(emoji: string): string { const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, ""); @@ -47,7 +46,7 @@ function renderPng(svg: string, size: number, background?: string): Uint8Array { return resvg.render().asPng(); } -/** Packs PNG payloads into a (PNG-compressed) .ico container. */ +/** Packs PNG images into an .ico file, which may hold PNGs as they are. */ function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array { const HEADER = 6; const ENTRY = 16; @@ -99,24 +98,23 @@ function buildManifest(siteName: string, bgColor: string): string { return JSON.stringify(manifest, null, 2) + "\n"; } -/** What {@link generateSiteIcons} renders, and where. */ +/** + * Passed to {@link generateSiteIcons} by `api/scripts/generate-icons.ts`, from + * the site's config. + */ export interface GenerateSiteIconsOptions { - /** Emoji to render the icons from. */ emoji: string; - /** Directory to write the assets into (created if missing). */ + /** Created if missing. */ outDir: string; - /** Site name written into the manifest. */ siteName: string; - /** - * Background color for the Apple touch icon and manifest (iOS ignores - * transparency). - */ + /** Fills the Apple touch icon, and the manifest's theme and background. */ bgColor: string; } /** - * Generates `favicon.svg`, `favicon.ico`, `favicon-x.png` (16–512), - * `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`. + * Writes `favicon.svg`, `favicon.ico`, `favicon-x.png` (16 to 512), + * `apple-touch-icon.png` and `manifest.webmanifest` into `opts.outDir`, the + * directory the site's static files are served from. * * @throws {Error} When the emoji's glyph can't be fetched. */ @@ -137,7 +135,7 @@ export async function generateSiteIcons( for (const size of PNG_SIZES) { await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!); } - // Apple touch icon gets a solid background — iOS doesn't honor transparency. + // iOS draws a transparent touch icon on black. await Deno.writeFile( out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor), diff --git a/api/lib/slugify.ts b/api/lib/slugify.ts index 1b4cb090..c9dd5a7e 100644 --- a/api/lib/slugify.ts +++ b/api/lib/slugify.ts @@ -1,31 +1,37 @@ /** - * URL slugs for records that have a title. + * Builds the URL slugs of dumps and playlists from their titles. The dump and + * playlist services store the slug when the record is created or retitled. * * @module */ -/** A UUID, which a route parameter may carry instead of a slug. */ +/** + * Matches a UUID. A dump or playlist route takes either an id or a slug, and + * the services test the parameter with this to know which column to look up. + */ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * Turns `title` into a URL-safe slug: lowercase ASCII, dash-separated, at most - * 60 characters, `untitled` when nothing is left. + * Turns `title` into lowercase ASCII words joined by dashes, at most 60 + * characters long. Accents are dropped, and a title with no ASCII letter or + * digit gives `untitled`. */ export function slugify(title: string): string { const slug = title .toLowerCase() .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // strip diacritics - .replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash - .replace(/^-+|-+$/g, "") // trim leading/trailing dashes + // NFD splits an accented letter into the letter and a combining mark. + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") .substring(0, 60); return slug || "untitled"; } /** - * Returns the slug of a record: its title's slug followed by the start of its - * `id`, so it stays unique by construction. + * Returns a record's slug, its title's {@link slugify} followed by the first 8 + * characters of `id`. The suffix keeps two records with the same title apart. */ export function makeSlug(title: string, id: string): string { return `${slugify(title)}-${id.substring(0, 8)}`; diff --git a/api/lib/static.ts b/api/lib/static.ts index 3c3643bb..8e7222e1 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -1,6 +1,7 @@ /** - * Serving the built frontend: static files, and `index.html` with its - * placeholders filled for every client-side route. + * Serves the built frontend, as its static files and, for every client-side + * route, `index.html` with its placeholders filled. main.ts installs it after + * the routers. * * @module */ @@ -8,30 +9,31 @@ import { Context, Next, send } from "@oak/oak"; import { loadIndexHtml } from "./index-html.ts"; -/** Answers with the filled `index.html` of `root`, always revalidated. */ async function serveIndexHtml( context: Context>, root: string, ) { const html = await loadIndexHtml(root); context.response.type = "text/html"; - // Always revalidated: it names the hashed chunks of the current build. + // It names the hashed chunks of the current build, so it is always + // revalidated. context.response.headers.set("Cache-Control", "no-cache"); context.response.body = html; } /** - * Build output whose URL changes whenever its content does: Vite's hashed - * chunks, and the emoji data under its package version (see vite.config.ts). + * Prefixes the build output whose URL changes whenever its content does, which + * are Vite's hashed chunks and the emoji data under its package version (see + * vite.config.ts). */ const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"]; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; /** - * Returns a middleware serving static files from the first of `staticPaths` - * that has them, and `index.html` from the first for any other path so - * client-side routes load on direct navigation. Hashed build output is cached - * for a year, and a missing chunk is a 404. + * Returns the middleware that serves a static file from the first of + * `staticPaths` that has it, and `index.html` of the first for any other path. + * Hashed build output is cached for a year, and a missing chunk is a 404. + * main.ts passes dist/ then public/. */ export function routeStaticFilesFrom( staticPaths: string[], @@ -39,7 +41,6 @@ export function routeStaticFilesFrom( return async (context: Context>, next: Next) => { const pathname = context.request.url.pathname; - // Serve index.html with runtime placeholder replacement if (pathname === "/" || pathname === "/index.html") { await serveIndexHtml(context, staticPaths[0]); return; @@ -53,7 +54,8 @@ export function routeStaticFilesFrom( const compress = context.request.headers.has("accept-encoding"); for (const path of staticPaths) { try { - // send() picks a prebuilt .br or .gz sibling when the browser accepts it. + // send() picks a prebuilt .br or .gz sibling when the browser accepts + // it. await send(context, pathname, { root: path, brotli: compress, @@ -74,7 +76,7 @@ export function routeStaticFilesFrom( return; } - // SPA fallback: serve index.html so client-side routes work on direct navigation + // Any other path is a client-side route, opened directly or reloaded. await serveIndexHtml(context, staticPaths[0]); await next(); }; diff --git a/api/lib/upload.ts b/api/lib/upload.ts index a36cfdfc..b8571b56 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -1,5 +1,6 @@ /** - * Checking uploaded images and serving uploaded files back. + * Checks the images users upload and serves them back. The avatar, playlist + * cover, dump thumbnail and attachment routes call it. * * @module */ @@ -29,7 +30,8 @@ export { /** * Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic - * bytes, ignoring the type the browser declared; null for anything else. + * bytes, or null for anything else. The type the browser declared is never + * trusted, since the file is later served with the type found here. */ export function detectImageMime(data: Uint8Array): string | null { if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) { @@ -52,7 +54,8 @@ export function detectImageMime(data: Uint8Array): string | null { } /** - * Checks an uploaded image's size and content, and returns its MIME type. + * Checks an uploaded image's size and content, and returns its MIME type, + * which the route stores to serve the image with. * * @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or * isn't a JPEG, PNG, GIF or WebP image. @@ -77,9 +80,10 @@ export function validateImageUpload(data: Uint8Array): string { } /** - * Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds - * (privately when `isPrivate`). Answers 404 and resolves false when the file - * is missing. + * Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds, + * and only by the browser when `isPrivate`. It answers 404 and resolves false + * when the file is missing. The caller checks first that the viewer may see + * it. */ export async function serveUploadedFile( ctx: Context, @@ -89,6 +93,8 @@ export async function serveUploadedFile( isPrivate = false, ): Promise { ctx.response.headers.set("Content-Disposition", "inline"); + // The browser must take the checked type, never guess another from the + // bytes, and a frontend on another origin may still show the image. ctx.response.headers.set("X-Content-Type-Options", "nosniff"); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); const served = await serveFile(ctx, filePath, { diff --git a/api/main.ts b/api/main.ts index 46fd854d..e00a6426 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,7 +1,8 @@ /** - * The server's entry point: builds the Oak application — error and OG - * middleware, CORS, every router, then the static frontend — and listens on - * {@link LISTEN_HOST}:{@link PORT} when run directly. + * Builds the Oak application and, when run directly, listens on + * {@link LISTEN_HOST}:{@link PORT}. A request goes through the error + * middleware, the OG tags and CORS, then the routers, and last the built + * frontend, which answers every path no router did. * * @module */ @@ -33,16 +34,19 @@ import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; -/** The application, exported so the tests can drive it without listening. */ +/** + * The whole server, exported so the tests' helpers can send it requests + * without listening. + */ const app: Application = new Application(); const cors = oakCors({ origin: ALLOWED_ORIGINS }); app.use(errorMiddleware); app.use(ogMiddleware); -// Only invoke oakCors when the origin is present and allowed. -// The library calls next() without await when origin is falsy, which -// corrupts the response in Oak's async middleware chain. +// oakCors calls next() without awaiting it when there is no origin, which +// corrupts the response in Oak's async chain. So it only sees requests from an +// allowed origin, and the others get no CORS headers. app.use(async (ctx, next) => { const origin = ctx.request.headers.get("origin"); if (origin && ALLOWED_ORIGINS.includes(origin)) { diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index b70d6d88..61ee8c03 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -1,6 +1,7 @@ /** - * Middleware gating routes on authentication, the admin role, or a - * permission. + * Gates routes on a session token, and on a permission after it. The routers + * put these middleware before a handler; a route open to anonymous callers + * calls `parseOptionalAuth` (`lib/auth.ts`) instead. * * @module */ @@ -18,16 +19,17 @@ import type { AuthPayload } from "../model/auth.ts"; import { can } from "../lib/permissions.ts"; import type { Permission } from "../../shared/permissions.ts"; -/** The request state once {@link authMiddleware} has run. */ +/** + * Set on `ctx.state` by {@link authMiddleware} for the handlers after it. + * `user` carries the caller's live role, not the one in their token. + */ export interface AuthState extends State { - /** The authenticated caller, with their live role. */ user: AuthPayload; } /** - * A router context whose state is guaranteed to carry the authenticated user - * (populated by {@link authMiddleware}). Generic over the route path so - * `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`. + * The context type of a handler behind {@link authMiddleware}. Pass the route + * path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed. */ export type AuthContext< R extends string = string, @@ -35,8 +37,8 @@ export type AuthContext< > = RouterContext; /** - * Requires a valid session token in the `Authorization` header and puts its - * caller in `ctx.state.user`. + * Requires a valid session token in the `Authorization` header, and puts its + * payload in `ctx.state.user` for the rest of the route. * * @throws {APIException} 401 when the token is missing or invalid. */ @@ -55,8 +57,6 @@ export async function authMiddleware( } const token = authHeader.substring(7); - // Also checks the user still exists and hasn't changed their password since - // the token was issued, and takes the live role from the DB. const payload = await authenticate(token); if (!payload) { @@ -70,6 +70,7 @@ export async function authMiddleware( /** * Requires the caller to be an admin. Must run after {@link authMiddleware}. + * No route uses it: they gate on a permission with {@link requirePermission}. * * @throws {APIException} 403 when the caller isn't an admin. */ @@ -89,10 +90,11 @@ export async function adminOnlyMiddleware( } /** - * Returns a middleware requiring the caller to hold `permission`, which throws - * an {@link APIException} (403) when they don't. Must run after - * {@link authMiddleware}. Generic over the route path so it composes with - * path-typed route handlers. + * Returns a middleware that requires the caller to hold `permission`. It must + * run after {@link authMiddleware}, as in + * `router.patch(path, authMiddleware, requirePermission("user:manage"), …)`. + * The returned middleware throws an {@link APIException} (403) when the caller + * lacks the permission. */ export function requirePermission( permission: Permission, diff --git a/api/middleware/error.ts b/api/middleware/error.ts index e9a56437..786b818b 100644 --- a/api/middleware/error.ts +++ b/api/middleware/error.ts @@ -1,5 +1,7 @@ /** - * Turns errors thrown while handling a request into `APIFailure` responses. + * Turns the errors a request's handling throws into `APIFailure` responses. + * main.ts installs it first, so it catches what every later middleware and + * route throws. * * @module */ @@ -10,8 +12,9 @@ import { APIErrorCode, APIFailure } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; /** - * Answers an {@link APIException} with its status and code, and anything else - * with a 500 `SERVER_ERROR`, logging both. + * Answers an {@link APIException} with its status, code and message. Anything + * else is logged with its stack and answered with a 500 `SERVER_ERROR` whose + * message gives nothing away. */ export async function errorMiddleware( ctx: Context, diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 49e4c7ff..9906456f 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -1,7 +1,8 @@ /** - * Open Graph and Twitter card tags for shared links: a dump, user or public - * playlist page is answered with `index.html` carrying that page's title, - * description and image, so link unfurlers that don't run the app see them. + * Answers a link to a dump, a profile or a public playlist with `index.html` + * carrying that page's Open Graph and Twitter card tags, for the link previews + * of chat apps and social sites, which don't run the app. main.ts installs it + * before the routers. * * @module */ @@ -18,8 +19,8 @@ interface OGMeta { description?: string; imageUrl?: string; /** - * True only for real artwork — a square site icon must not claim a wide - * card. + * Set for a dump's artwork only. It asks for the large card, which would + * stretch a square icon or avatar. */ imageIsWide?: boolean; url: string; @@ -68,8 +69,8 @@ function inject(html: string, meta: OGMeta): string { } /** - * Returns the filled `index.html` of the build, or of the working directory - * when there is no build; null when there is neither. + * Returns the filled `index.html` of dist/, or else the source one, which is + * all there is in development. Returns null when neither can be read. */ async function findIndexHtml(): Promise { for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) { @@ -83,13 +84,14 @@ async function findIndexHtml(): Promise { } const DUMP_RE = /^\/dumps\/([^/]+)$/; -/** A profile, with an optional `/~/` suffix so its tabs keep OG meta. */ +/** Matches a profile, and its tabs under `/~/`. */ const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; /** - * Serves `index.html` with OG tags for a dump, user or public playlist page - * that exists, and passes every other request on. + * Serves `index.html` with the tags of the dump, user or public playlist a + * path names, and passes the request on when it names none that exists or + * the visitor may see. The tags' URLs are built on the request's origin. */ export async function ogMiddleware( ctx: Context, @@ -119,9 +121,8 @@ export async function ogMiddleware( } else if (dump.richContent?.thumbnailUrl) { imageUrl = dump.richContent.thumbnailUrl; } - // No artwork: fall back to our own site icon rather than the target's - // favicon, which is typically too small to survive a social card (and may - // be hotlink-protected). + // Without artwork, the site's icon: the linked site's favicon is too + // small for a card, and may refuse hotlinking. meta = { title: dump.title, description: dump.comment, @@ -129,7 +130,7 @@ export async function ogMiddleware( imageIsWide: !!imageUrl, url: pageUrl, }; - } catch { /* not found or private — serve default */ } + } catch { /* Unknown, or private. */ } } else if (userMatch) { try { const user = getUserByUsername(userMatch[1]); @@ -142,10 +143,11 @@ export async function ogMiddleware( imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, url: pageUrl, }; - } catch { /* not found */ } + } catch { /* Unknown. */ } } else if (playlistMatch) { try { const playlist = getPlaylistById(playlistMatch[1]); + // getPlaylistById doesn't check visibility, unlike getDump. if (playlist.isPublic) { const imageUrl = playlist.imageMime ? `${origin}/api/playlists/${playlist.id}/image` @@ -157,7 +159,7 @@ export async function ogMiddleware( url: pageUrl, }; } - } catch { /* not found or private */ } + } catch { /* Unknown. */ } } if (!meta) return await next(); diff --git a/api/model/auth.ts b/api/model/auth.ts index 9c2942d1..9515e708 100644 --- a/api/model/auth.ts +++ b/api/model/auth.ts @@ -1,35 +1,35 @@ /** - * The payloads of the tokens the server signs, and their guards. + * Types the payloads of session and password reset tokens, and guards them. + * `lib/jwt.ts` signs the tokens and runs the guards when it verifies one. * * @module */ import { isRole, type Role } from "../../shared/user.ts"; -/** What a session token carries. */ +/** + * Signed into a session token by `createJWT`, and returned by `authenticate` + * to the auth middleware as `ctx.state.user`. By then `role` is the user's live + * role, while `username` is still the one at issue time. + */ export interface AuthPayload { - /** The user's id. */ userId: string; - /** The user's name when the token was issued. */ username: string; - /** - * The user's role; the live one replaces it once the token is authenticated. - */ role: Role; - /** When the token expires, in seconds since the epoch. */ + /** Seconds since the epoch. */ exp: number; - /** When the token was issued, in seconds since the epoch. */ + /** Seconds since the epoch, compared with `users.tokens_valid_after`. */ iat?: number; } /** - * Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped - * token's payload. + * Checks whether `obj` is an {@link AuthPayload}. A media or reset token's + * payload, which has a `purpose`, never is. */ export function isAuthPayload(obj: unknown): obj is AuthPayload { return !!obj && typeof obj === "object" && - // Purpose-scoped tokens (password reset, media) are never session tokens. + // A media or reset token must not pass for a session token. !("purpose" in obj) && "userId" in obj && typeof obj.userId === "string" && "username" in obj && typeof obj.username === "string" && @@ -37,17 +37,21 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload { "exp" in obj && typeof obj.exp === "number"; } -/** What a password reset token carries. */ +/** + * Signed into a reset link's token by `createPasswordResetToken`, and returned + * by `verifyPasswordResetToken` to `resetPassword`. + */ export interface PasswordResetPayload { - /** The token's purpose, which makes it a reset token. */ purpose: "password-reset"; - /** The id of the user whose password it resets. */ userId: string; - /** When the token expires, in seconds since the epoch. */ + /** Seconds since the epoch. */ exp: number; } -/** Checks whether `obj` is a {@link PasswordResetPayload}. */ +/** + * Checks whether `obj` is a {@link PasswordResetPayload}. It doesn't check + * `exp`, which `jwtVerify` has already enforced. + */ export function isPasswordResetPayload( obj: unknown, ): obj is PasswordResetPayload { diff --git a/api/model/category.ts b/api/model/category.ts index 6cfb24c6..2de6e2b8 100644 --- a/api/model/category.ts +++ b/api/model/category.ts @@ -1,6 +1,6 @@ /** - * The `categories` table: its row type, its guard, and its mapper to the API's - * {@link Category}. + * Types the rows the category service reads from `categories`, and turns them + * into the {@link Category} the category routes send. * * @module */ @@ -8,25 +8,19 @@ import type { SQLOutputValue } from "node:sqlite"; import type { Category } from "../../shared/category.ts"; -/** A row of the `categories` table. */ +/** Returned by the category service's `SELECT`s on `categories`. */ export interface CategoryRow { - /** The category's id. */ id: string; - /** The category's URL slug, at the site root. */ slug: string; - /** The category's display name. */ name: string; - /** The category's place in the list, ascending. */ + /** Sorts the categories, lowest first. */ position: number; - /** When the category was created. */ created_at: string; - /** When the category was last changed, if ever. */ updated_at: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link CategoryRow}. */ +/** Checks whether a row `node:sqlite` returned is a {@link CategoryRow}. */ export function isCategoryRow(obj: unknown): obj is CategoryRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && @@ -38,7 +32,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow { (typeof obj.updated_at === "string" || obj.updated_at === null); } -/** Converts a row to the API's {@link Category}. */ +/** Converts `row` to the {@link Category} the category routes send. */ export function categoryRowToApi(row: CategoryRow): Category { return { id: row.id, diff --git a/api/model/chat.ts b/api/model/chat.ts index ec60decd..75efd53c 100644 --- a/api/model/chat.ts +++ b/api/model/chat.ts @@ -1,6 +1,6 @@ /** - * The `chat_messages` table: its row type, its guard, and its mapper to the - * API's {@link ChatMessage}. + * Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends. + * Only `services/chat-service.ts` uses it, on the rows of its one query. * * @module */ @@ -9,44 +9,30 @@ import type { SQLOutputValue } from "node:sqlite"; import type { ChatMessage } from "../../shared/chat.ts"; /** - * A row of the `chat_messages` table, joined with its author and, when it is - * a reply, the replied-to message and its author. + * Returned by chat-service's query, which joins each message to its author and + * to the message it replies to. `reply_to_id` is kept when the message it + * points to is deleted, while `reply_to_author` and `reply_to_body` then read + * null. */ export interface ChatMessageRow { - /** The message's id. */ id: string; - /** The author's id. */ user_id: string; - /** The message, in Markdown. */ body: string; - /** When the message was sent. */ created_at: string; - /** When the message was last edited, if ever. */ updated_at: string | null; - /** The author's name (joined). */ author_username: string; - /** The author's avatar type, null without one (joined). */ author_avatar_mime: string | null; - /** - * The id of the message this replies to, kept even once that message is - * deleted. - */ reply_to_id: string | null; - /** - * The replied-to message's author (joined); null when there's no reply or it - * was since deleted. - */ reply_to_author: string | null; - /** - * The start of the replied-to message (joined); null when there's no reply or - * it was since deleted. - */ + /** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */ reply_to_body: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link ChatMessageRow}. */ +/** + * Checks whether `obj`, a row read by chat-service, is a + * {@link ChatMessageRow}. + */ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && @@ -67,7 +53,10 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { (typeof obj.reply_to_body === "string" || obj.reply_to_body === null); } -/** Converts a row to the API's {@link ChatMessage}. */ +/** + * Converts a row to the {@link ChatMessage} that chat-service returns, turning + * its timestamps into Dates and its nulls into absent fields. + */ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { return { id: row.id, diff --git a/api/model/comment.ts b/api/model/comment.ts index a6fbd42b..36b032c0 100644 --- a/api/model/comment.ts +++ b/api/model/comment.ts @@ -1,6 +1,6 @@ /** - * The `comments` table: its row type, its guard, and its mapper to the API's - * {@link Comment}. + * Types the rows the comment service reads from `comments`, and turns them into + * the {@link Comment} the comment routes send. * * @module */ @@ -8,38 +8,31 @@ import type { SQLOutputValue } from "node:sqlite"; import type { Comment } from "../../shared/comment.ts"; -/** A row of the `comments` table, joined with its author. */ +/** + * Returned by the comment service's `SELECT`s on `comments`, joined with + * `users` for the `author_` columns. + */ export interface CommentRow { - /** The comment's id. */ id: string; - /** The id of the dump it is posted under. */ dump_id: string; - /** The author's id. */ user_id: string; - /** The id of the comment it replies to; null at the top level. */ + /** It is null on a top-level comment. */ parent_id: string | null; - /** The comment, in Markdown. */ body: string; - /** When the comment was posted. */ created_at: string; - /** When the comment was last edited, if ever. */ updated_at: string | null; /** - * 1 once deleted: the body is emptied but the row stays, so its replies - * keep their place. + * 1 once deleted. The body is emptied but the row stays, so its replies keep + * their place in the thread. */ deleted: number; - /** How many users like the comment. */ like_count: number; - /** The author's name (joined). */ author_username: string; - /** The author's avatar type, null without one (joined). */ author_avatar_mime: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link CommentRow}. */ +/** Checks whether a row `node:sqlite` returned is a {@link CommentRow}. */ export function isCommentRow( obj: unknown, ): obj is CommentRow { @@ -61,7 +54,10 @@ export function isCommentRow( obj.author_avatar_mime === null); } -/** Converts a row to the API's {@link Comment}. */ +/** + * Converts `row` to the {@link Comment} the comment routes send and the + * WebSocket broadcasts. + */ export function commentRowToApi(row: CommentRow): Comment { return { id: row.id, diff --git a/api/model/dump.ts b/api/model/dump.ts index 351b6547..91888bd5 100644 --- a/api/model/dump.ts +++ b/api/model/dump.ts @@ -1,6 +1,7 @@ /** - * The `dumps` table: its row type, its guard, the column lists services select - * it with, and its mappers to and from the API's {@link Dump}. + * Maps the `dumps` table to the API's {@link Dump}. Every service that reads + * dumps selects them with the column lists here, checks each row with + * {@link isDumpRow} and converts it with {@link dumpRowToApi}. * * @module */ @@ -8,67 +9,69 @@ import type { SQLOutputValue } from "node:sqlite"; import type { Dump, RichContent } from "../../shared/dump.ts"; -/** A row of the `dumps` table, with its live comment count. */ +/** + * Read from the `dumps` table with {@link DUMP_SELECT_COLUMNS}, or written back + * from a {@link Dump} by {@link dumpApiToRow}. It lacks `url_canonical`, which + * only the duplicate lookup reads. + */ export interface DumpRow { - /** The dump's id. */ id: string; - /** `url` for a link, `file` for an upload. */ kind: string; - /** The dump's title. */ title: string; - /** The poster's description, in Markdown. */ comment: string | null; - /** The poster's id. */ user_id: string; - /** When the dump was posted. */ created_at: string; - /** When the dump was last edited, if ever. */ updated_at: string | null; - /** The dump's URL slug. */ slug: string | null; - /** The link exactly as submitted, for a URL dump. */ + /** The link as the poster submitted it. */ url: string | null; - /** The link's preview, as {@link RichContent} JSON. */ + /** {@link RichContent} as JSON. */ rich_content: string | null; - /** The uploaded file's name, for a file dump. */ file_name: string | null; - /** The uploaded file's type, for a file dump. */ file_mime: string | null; - /** The uploaded file's size in bytes, for a file dump. */ file_size: number | null; - /** How many users upvoted the dump. */ vote_count: number; - /** How many comments it has, not counting deleted ones (computed). */ + /** Computed by a subquery, which counts the comments that aren't deleted. */ comment_count: number; - /** 1 when only the poster sees the dump. */ is_private: number; - /** The uploaded thumbnail's type, null without one. */ custom_thumbnail_mime: string | null; - /** Any other column the query selects. */ + /** Lets a query select more columns, such as a join's. */ [key: string]: SQLOutputValue; } /** - * The stored columns of {@link DumpRow} — keep in sync with it and - * {@link isDumpRow}. Services select with these instead of repeating the - * column list themselves. + * Lists the stored columns of {@link DumpRow}. A column added to the table must + * be added here, to {@link DUMP_COLUMNS_ALIASED}, to {@link DumpRow} and to + * {@link isDumpRow}. */ export const DUMP_COLUMNS = "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime"; -/** {@link DUMP_COLUMNS}, for a query aliasing `dumps` as `d`. */ +/** + * Lists {@link DUMP_COLUMNS} for a query that aliases `dumps` as `d`. The + * playlist service selects with it and counts the comments itself. + */ export const DUMP_COLUMNS_ALIASED = "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime"; -/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */ +/** + * Selects a whole {@link DumpRow}, the stored columns and the computed + * `comment_count`. + */ export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS}, (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; -/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */ +/** + * Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to + * join it with other tables. + */ export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED}, (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`; -/** Checks whether `obj` is a {@link DumpRow}. */ +/** + * Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks + * each column's type, not that `kind` is `url` or `file`. + */ export function isDumpRow(obj: unknown): obj is DumpRow { return !!obj && typeof obj === "object" && @@ -99,7 +102,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow { obj.custom_thumbnail_mime === null); } -/** Converts a row to the API's {@link Dump}. */ +/** + * Converts a row to the API's {@link Dump}, parsing its preview's JSON. It + * leaves `categoryIds` out: the services add them with `attachCategoryIds`. + */ export function dumpRowToApi(row: DumpRow): Dump { return { id: row.id, @@ -124,7 +130,10 @@ export function dumpRowToApi(row: DumpRow): Dump { }; } -/** Converts a {@link Dump} back to a row. */ +/** + * Converts a {@link Dump} back to a row, for a service that writes an edited + * dump's columns. + */ export function dumpApiToRow(dump: Dump): DumpRow { return { id: dump.id, diff --git a/api/model/follow.ts b/api/model/follow.ts index 90cf0584..c18d37ea 100644 --- a/api/model/follow.ts +++ b/api/model/follow.ts @@ -1,5 +1,6 @@ /** - * The `follows` table: its row type and its guard. + * Types and guards the rows of `follows`, which `follow-service.ts` reads to + * answer what a user follows. * * @module */ @@ -7,25 +8,22 @@ import type { SQLOutputValue } from "node:sqlite"; /** - * A row of the `follows` table: one user following either another user or a - * playlist. + * Read by `getFollowStatus`. Exactly one of `followed_user_id` and + * `followed_playlist_id` is set, as the table's CHECK constraint requires. */ export interface FollowRow { - /** The follow's id. */ id: string; - /** The id of the user following. */ follower_id: string; - /** The id of the user followed, when it is a user. */ followed_user_id: string | null; - /** The id of the playlist followed, when it is a playlist. */ followed_playlist_id: string | null; - /** When the follow began. */ created_at: string; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link FollowRow}. */ +/** + * Checks whether `obj`, a row `getFollowStatus` selected, is a + * {@link FollowRow}. + */ export function isFollowRow( obj: unknown, ): obj is FollowRow { diff --git a/api/model/invite.ts b/api/model/invite.ts index bd5f4a7f..0ab0a01a 100644 --- a/api/model/invite.ts +++ b/api/model/invite.ts @@ -1,26 +1,32 @@ /** - * The `invites` table: its row type and its guard. + * Types and guards the rows of `invites`, which `invite-service.ts` reads to + * check an invite before sign-up. * * @module */ import type { SQLOutputValue } from "node:sqlite"; -/** A row of the `invites` table. */ +/** + * Stored by `createInvite`, and read back by `validateInvite` when the invite + * link is opened or used to sign up. + */ export interface InviteRow { - /** The invite's token, which registering redeems. */ + /** The random token the invite link carries, which is the primary key. */ token: string; - /** The id of the user who made the invite. */ + /** Becomes the new user's `invited_by`. */ inviter_id: string; - /** When the invite was redeemed; null while unused. */ + /** Set when an account is created with the invite. */ used_at: string | null; - /** When the invite was made. */ + /** Starts the 7-day window in which the invite works. */ created_at: string; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link InviteRow}. */ +/** + * Checks whether `obj`, a row `validateInvite` selected, is an + * {@link InviteRow}. + */ export function isInviteRow( obj: unknown, ): obj is InviteRow { diff --git a/api/model/notification.ts b/api/model/notification.ts index a42763dd..ccdb6f58 100644 --- a/api/model/notification.ts +++ b/api/model/notification.ts @@ -1,6 +1,6 @@ /** - * The `notifications` table: its row type, its guard, and its mapper to the - * API's {@link Notification}. + * Types and guards the rows of `notifications`, and converts them to the + * {@link Notification} that `GET /api/notifications` answers. * * @module */ @@ -12,30 +12,33 @@ import type { NotificationType, } from "../../shared/notification.ts"; -/** A row of the `notifications` table. */ +/** + * Stored by the triggers of `notification-service.ts`, and read back by + * `getNotificationsForUser`. + */ export interface NotificationRow { - /** The notification's id. */ id: string; - /** The id of the user notified. */ user_id: string; - /** The notification's {@link NotificationType}. */ + /** A {@link NotificationType}, not checked by the guard. */ type: string; - /** The notification's {@link NotificationData}, as JSON. */ + /** The {@link NotificationData} as JSON, not checked by the guard. */ data: string; - /** 1 once the user has read it. */ + /** 0 or 1. */ read: number; - /** When the notification was made. */ created_at: string; /** - * What caused the notification: a second one with the same - * `(user_id, source_key)` isn't stored. Null when duplicates are allowed. + * Names the event that caused the notification. A unique index keeps a + * second row with the same `(user_id, source_key)` out; null allows + * duplicates. */ source_key: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link NotificationRow}. */ +/** + * Checks whether `obj`, a row `getNotificationsForUser` selected, is a + * {@link NotificationRow}. + */ export function isNotificationRow( obj: unknown, ): obj is NotificationRow { @@ -50,7 +53,11 @@ export function isNotificationRow( (typeof obj.source_key === "string" || obj.source_key === null); } -/** Converts a row to the API's {@link Notification}. */ +/** + * Converts a row to the {@link Notification} the client gets. + * + * @throws {SyntaxError} When `data` isn't valid JSON. + */ export function notificationRowToApi(row: NotificationRow): Notification { return { id: row.id, diff --git a/api/model/password-reset.ts b/api/model/password-reset.ts index 97f80e59..b8685ec6 100644 --- a/api/model/password-reset.ts +++ b/api/model/password-reset.ts @@ -1,26 +1,34 @@ /** - * The `password_reset_tokens` table: its row type and its guard. + * Types and guards the rows of `password_reset_tokens`, which + * `password-reset-service.ts` reads to make each reset link work once. * * @module */ import type { SQLOutputValue } from "node:sqlite"; -/** A row of the `password_reset_tokens` table. */ +/** + * Stored by `requestPasswordReset` when it emails a reset link, and read back + * by `resetPassword` when the link is used. + */ export interface PasswordResetTokenRow { - /** The reset token. */ + /** The signed token itself, which the reset link carries. */ token: string; - /** The id of the user whose password it resets. */ user_id: string; - /** When the token stops working. */ + /** + * Lets `db/startup.ts` purge old rows. The token's own `exp` is what ends + * the link. + */ expires_at: string; - /** When the token was used; null while unused. */ + /** Set when the link is used, and reset to null if the reset then fails. */ used_at: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */ +/** + * Checks whether `obj`, a row `resetPassword` selected, is a + * {@link PasswordResetTokenRow}. + */ export function isPasswordResetTokenRow( obj: unknown, ): obj is PasswordResetTokenRow { diff --git a/api/model/playlist.ts b/api/model/playlist.ts index 129d4ceb..1113dd29 100644 --- a/api/model/playlist.ts +++ b/api/model/playlist.ts @@ -1,6 +1,6 @@ /** - * The `playlists` table: its row type, its guard, and its mapper to the API's - * {@link Playlist}. + * Types the rows the playlist and follow services read from `playlists`, and + * turns them into the {@link Playlist} the API sends. * * @module */ @@ -9,33 +9,24 @@ import type { SQLOutputValue } from "node:sqlite"; import type { Playlist } from "../../shared/playlist.ts"; /** - * A row of the `playlists` table. Queries may add `dump_count` and - * `owner_username`, which {@link playlistRowToApi} carries over. + * Returned by the playlist and follow services' `SELECT`s on `playlists`. A + * query may add `dump_count` and `owner_username` columns, which + * {@link playlistRowToApi} carries over when present. */ export interface PlaylistRow { - /** The playlist's id. */ id: string; - /** The owner's id. */ user_id: string; - /** The playlist's title. */ title: string; - /** The playlist's URL slug. */ slug: string | null; - /** The owner's description, in Markdown. */ description: string | null; - /** 1 when anyone may see the playlist, 0 when only its owner does. */ is_public: number; - /** When the playlist was created. */ created_at: string; - /** When the playlist was last changed, if ever. */ updated_at: string | null; - /** The cover image's type, null without one. */ image_mime: string | null; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link PlaylistRow}. */ +/** Checks whether a row `node:sqlite` returned is a {@link PlaylistRow}. */ export function isPlaylistRow( obj: unknown, ): obj is PlaylistRow { @@ -54,7 +45,7 @@ export function isPlaylistRow( (typeof obj.image_mime === "string" || obj.image_mime === null); } -/** Converts a row to the API's {@link Playlist}. */ +/** Converts `row` to the {@link Playlist} the playlist routes send. */ export function playlistRowToApi(row: PlaylistRow): Playlist { return { id: row.id, diff --git a/api/model/user.ts b/api/model/user.ts index c3989083..7237533a 100644 --- a/api/model/user.ts +++ b/api/model/user.ts @@ -1,6 +1,7 @@ /** - * The `users` table: its row type, its guard, its mappers to and from the - * stored {@link User}, and the views of a user the API sends. + * Types and guards the rows of `users`, and converts them to the {@link User} + * that `user-service.ts` returns. A route turns a `User` into what it may send + * with {@link toPublicUser} or {@link toSelfUser}. * * @module */ @@ -13,56 +14,65 @@ import { type SelfUser, } from "../../shared/user.ts"; -/** A user as stored: what they see of themselves, plus the password hash. */ +/** + * Returned by the lookups of `user-service.ts`. It must never be sent as is: + * it carries the password hash. + */ export interface User extends SelfUser { - /** The scrypt hash of the password, as `hash.salt`. */ + /** From `hashPassword`, as `hash.salt`. */ passwordHash: string; } -/** Returns what anyone may see of `user`: no hash, no email. */ +/** + * Returns what anyone may see of `user`, without the hash and the email. Every + * route that sends another user's profile goes through it. + */ export function toPublicUser( { passwordHash: _, email: _email, ...user }: User, ): PublicUser { return user; } -/** Returns what `user` sees of themselves: everything but the hash. */ +/** + * Returns what `user` sees of themselves, without the hash, for the routes + * that answer the caller's own account. + */ export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { return user; } -/** A row of the `users` table. */ +/** + * Selected by `user-service.ts` and `follow-service.ts`, and converted by + * {@link userRowToApi}. It leaves out `chat_read_at` and `tokens_valid_after`, + * which only the chat's read state and `getSessionState` query. + */ export interface UserRow { - /** The user's id. */ id: string; - /** The user's name, unique. */ username: string; - /** The scrypt hash of the password, as `hash.salt`. */ password_hash: string; - /** The user's role. */ role: Role; - /** When the user registered. */ created_at: string; - /** When the profile was last changed, if ever. */ + /** Bumped by any profile or avatar change. */ updated_at: string | null; - /** The avatar's type, null without one. */ + /** Null without an avatar. */ avatar_mime: string | null; - /** The user's profile text, in Markdown. */ + /** Markdown. */ description: string | null; - /** The id of the user whose invite they registered with. */ + /** Null for the seeded admin, who had no inviter. */ invited_by: string | null; /** - * The inviter's name; present only when the query joins it (`LEFT JOIN users - * i ON i.id = u.invited_by`). + * Joined from the inviter's row rather than stored, and undefined when the + * query doesn't join it. The guard doesn't check it. */ invited_by_username: string | null; - /** The user's email address. */ email: string; - /** Any other column the query selects. */ [key: string]: SQLOutputValue; } -/** Checks whether `obj` is a {@link UserRow}. */ +/** + * Checks whether `obj`, a row selected with the columns of {@link UserRow}, is + * one. + */ export function isUserRow(obj: unknown): obj is UserRow { return !!obj && typeof obj === "object" && @@ -82,7 +92,7 @@ export function isUserRow(obj: unknown): obj is UserRow { "email" in obj && typeof obj.email === "string"; } -/** Converts a row to the stored {@link User}. */ +/** Converts a row to a {@link User}, turning nulls into undefined. */ export function userRowToApi(row: UserRow): User { return { id: row.id, @@ -100,7 +110,10 @@ export function userRowToApi(row: UserRow): User { }; } -/** Converts a {@link User} back to a row, without the join-only columns. */ +/** + * Converts a {@link User} back to a row, for `updateUser`. A `User` has no + * inviter id, so `invited_by` comes out null: don't write it back. + */ export function userApiToRow(user: User): UserRow { return { id: user.id, diff --git a/api/remote/bandcamp-streams.ts b/api/remote/bandcamp-streams.ts index 79bba2f7..b6adf34b 100644 --- a/api/remote/bandcamp-streams.ts +++ b/api/remote/bandcamp-streams.ts @@ -1,10 +1,9 @@ /** - * Resolves a Bandcamp page to its streamable tracks, at play time. - * - * Bandcamp's signed stream URLs expire 24h after the page fetch, so they can - * never be baked into `rich_content` at dump-creation time the way `embedUrl` - * is. This service is the play-time counterpart: the frontend asks for a page, - * gets a whole tracklist back, and plays the mp3s directly from bcbits. + * Resolves a Bandcamp page to its streamable tracks when the native player + * starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and + * expire 24 hours after the page is fetched, so unlike the embed URL they + * can't be stored in the dump's preview. The browser plays them from bcbits + * directly. * * @module */ @@ -16,19 +15,18 @@ import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts"; import type { Tralbum } from "../../shared/player.ts"; /** - * How long a resolved page is cached: comfortably inside the 24h signature - * life, so a cache hit is never a URL that is about to expire mid-listen. + * Half the signatures' life, so a cached URL still has at least 12 hours left + * when it is handed out. */ const TTL_MS = 12 * 60 * 60 * 1000; -/** The most pages cached; the oldest write goes first. */ +/** The oldest write goes first. */ const MAX_ENTRIES = 200; -/** Resolved pages, by {@link cacheKey}. */ const cache = new Map(); /** - * Returns the cache key of `url`: a page is the same page regardless of query, - * fragment or case. + * Returns `url` without its query, fragment and trailing slashes, and with its + * host in lowercase. */ function cacheKey(url: string): string { try { @@ -61,10 +59,10 @@ function writeCache(key: string, value: Tralbum): void { } /** - * Fetches a Bandcamp page and returns its tracklist, cached. - * - * `force` bypasses the cache — used by the client's re-resolve path, so a - * cached entry whose URLs have started 403ing can't keep being handed back. + * Returns the tracklist of the Bandcamp page `pageUrl`, from the cache when it + * was resolved in the last 12 hours. `force` skips the cache: the player sets + * it when it re-resolves a queue whose URLs expired or are about to, which a + * cached entry could hand back. * * @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL, * 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when @@ -112,9 +110,8 @@ export async function resolveBandcamp( "Could not reach Bandcamp", ); } - // Every path out of here that doesn't read the body has to release it, or the - // connection stays open until GC. This endpoint is unauthenticated: a caller - // hammering random /track/ URLs would otherwise leak one body a request. + // A body left unread keeps its connection open until GC, and this endpoint + // is public: every early return cancels it. if (!res.ok) { await res.body?.cancel(); throw new APIException( @@ -134,9 +131,8 @@ export async function resolveBandcamp( const parsed = parseTralbum(await readTextCapped(res), pageUrl); if (!parsed) { - // Covers "no data-tralbum", "hasAudio: false" and preorder-only releases - // with nothing streamable — all cases where the client falls back to the - // iframe embed rather than showing an error. + // No data, no audio or nothing streamable yet (a preorder). The client + // plays the embed instead. throw new APIException( APIErrorCode.NOT_FOUND, 404, diff --git a/api/remote/bandcamp-tralbum.ts b/api/remote/bandcamp-tralbum.ts index 7e5bff7a..2da7c009 100644 --- a/api/remote/bandcamp-tralbum.ts +++ b/api/remote/bandcamp-tralbum.ts @@ -1,18 +1,8 @@ /** - * Parser for Bandcamp's `data-tralbum` attribute. - * - * Every Bandcamp track and album page carries the whole release — titles, - * durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a - * `data-tralbum` attribute. One fetch of an album page therefore yields every - * track's stream URL; there is no per-track request. - * - * The stream URLs are signed and expire 24h after the page was fetched, which - * is why nothing here is ever persisted to `rich_content` — see - * `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint. - * - * The shape is undocumented, so every field is treated as optional and the - * whole parse is best-effort: a `null` return means "not a Bandcamp page", - * which callers turn into a fallback to the iframe embed. + * Reads a release out of a Bandcamp page, for `bandcamp-streams.ts`. Every + * track and album page carries the whole release, stream URLs included, as + * HTML-escaped JSON in a `data-tralbum` attribute, so one fetch covers an + * album. The format is undocumented, so every field is treated as optional. * * @module */ @@ -20,19 +10,16 @@ import type { Tralbum, TralbumTrack } from "../../shared/player.ts"; /** - * The hostnames Bandcamp serves its own sites from, anchored at the end so that `bandcamp.com.example.org` cannot pass. Artists on custom - * domains do not match this and keep using the embed. + * Matches the hostnames `bandcamp-streams.ts` accepts: bandcamp.com and its + * subdomains, and not `bandcamp.com.example.org`. An artist on a custom domain + * doesn't match, and the player falls back to the embed. */ export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/; /** - * Decodes HTML entities in a single pass. - * - * The single pass is the point: `decodeHtmlEntities` in remote/html.ts - * replaces `&` before `"`, so an escaped-ampersand-then-quot sequence - * (`&quot;`, i.e. the literal text `"` inside a title) decodes twice - * and yields a bare `"` — which lands inside the JSON string and breaks the - * parse. Matching every entity form in one regex means each is replaced once. + * Decodes HTML entities in a single pass. `html.ts` decodes `&` before + * `"`, which turns a title's literal `"` (escaped as `&quot;`) + * into a bare `"` that breaks the JSON. */ function decodeEntitiesOnce(input: string): string { const named: Record = { @@ -53,7 +40,7 @@ function decodeEntitiesOnce(input: string): string { ); } -/** Returns the URL of the large square artwork numbered `artId`. */ +/** Returns the URL of the artwork `artId`; undefined when it isn't numeric. */ function artworkUrl(artId: unknown): string | undefined { if (typeof artId !== "number" && typeof artId !== "string") return undefined; const id = String(artId).trim(); @@ -63,8 +50,9 @@ function artworkUrl(artId: unknown): string | undefined { } /** - * Pulls the `data-tralbum` blob out of a Bandcamp page and normalises it. - * Returns null when the page has no blob, no audio, or nothing parseable. + * Returns the release on the Bandcamp page `html`, fetched from `sourceUrl`; + * null when the page has no release data, no audio or no streamable track. + * Stream URLs outside bcbits.com are dropped. */ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/); @@ -84,8 +72,8 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { const t = (entry ?? {}) as Record; const file = (t.file ?? {}) as Record; const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null; - // Only ever hand out bcbits URLs. Without this the endpoint would relay - // whatever host a hostile page put in the blob straight to the browser. + // A hostile page could put any host here, and the endpoint would hand it + // to the browser. const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null; return { index, @@ -117,7 +105,6 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { }; } -/** Checks whether `url` is an https URL on Bandcamp's bcbits.com CDN. */ function isBcbitsUrl(url: string): boolean { try { const { protocol, hostname } = new URL(url); diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts index af747610..29a6eea8 100644 --- a/api/remote/fetch.ts +++ b/api/remote/fetch.ts @@ -1,7 +1,8 @@ /** - * The guarded fetch every request to another site goes through: each hop must - * resolve to a public address, the whole exchange has a deadline, and pages are - * read up to a cap. + * Fetches pages and images from other sites for link previews, the image proxy + * and the Bandcamp resolver, without letting a user-supplied URL reach the + * server's own network. Each hop must resolve to a public address, the whole + * exchange has a deadline, and pages are read up to a cap. * * @module */ @@ -20,15 +21,11 @@ const FETCH_HEADERS = { const MAX_REDIRECTS = 5; /** - * Fetches `url` through a `curl --insecure` subprocess and returns a minimal - * Response. - * Used as a last resort when Deno's rustls rejects the server's TLS certificate - * due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). - * Returns null if curl is unavailable or exits non-zero. - * - * `address` pins the connection to the IP already checked by - * resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes - * back with its Location so fetchWithTimeout can check the next hop too. + * Fetches `url` with `curl --insecure`, for a site whose TLS certificate Deno + * rejects. Returns a Response carrying only the `Location` and `Content-Type` + * headers, or null when curl is missing or fails. The connection is pinned to + * `address`, the one {@link resolvePublicAddress} checked, and redirects come + * back unfollowed so that {@link fetchWithTimeout} checks each hop. */ async function fetchViaCurl( url: URL, @@ -49,6 +46,8 @@ async function fetchViaCurl( `${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`, "--max-time", String(Math.ceil(timeoutMs / 1000)), + // curl fails on a larger file rather than stopping at a cap, so this is + // looser than MAX_PAGE_BYTES, where readTextCapped still cuts. "--max-filesize", String(2 * MAX_PAGE_BYTES), "--user-agent", @@ -81,8 +80,8 @@ async function fetchViaCurl( if (!contentType.startsWith("text/")) { return new Response(bytes, { status: statusCode, headers }); } - // Decode using the charset declared in the Content-Type header so that - // pages served in ISO-8859-1, windows-1252, etc. are read correctly. + // readTextCapped decodes as UTF-8, so a page in another charset is + // re-encoded here. const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; let bodyText: string; try { @@ -100,15 +99,15 @@ async function fetchViaCurl( } /** - * The most bytes of a fetched page that are read. Metadata lives in the , - * and a "page" past this is a download, not something to scrape. + * The default cap of {@link readTextCapped}. The metadata previews read is in + * the ``, and a page past this is a download. */ export const MAX_PAGE_BYTES = 4 * 1024 * 1024; /** - * Hands `body` on, and clears the request's deadline once it has been read to - * the end or cancelled. Until then the deadline still applies: a server that - * answers at once and then drips its body can't hold the request open. + * Wraps `body` so that the request's deadline `timer` is cleared only once the + * body is read to the end or cancelled. A server that answers at once and then + * drips its body is still cut off. */ function withDeadline( body: ReadableStream, @@ -138,8 +137,9 @@ function withDeadline( } /** - * Reads a response's body as text, stopping at `maxBytes` (the rest is - * cancelled, never downloaded). + * Reads the body of `res` as UTF-8 text, up to `maxBytes`; the rest is + * cancelled, never downloaded. The providers and the Bandcamp and YouTube + * resolvers read pages through it. */ export async function readTextCapped( res: Response, @@ -170,7 +170,10 @@ export async function readTextCapped( return new TextDecoder().decode(bytes); } -/** Makes one request, following no redirects. The timeout covers the body too. */ +/** + * Makes one request to `url`, following no redirects, with a deadline that + * covers the body too. + */ async function fetchOnce( url: URL, address: string, @@ -191,10 +194,9 @@ async function fetchOnce( throw err; } - // Deno/rustls rejected the server's TLS certificate (expired, self-signed, - // or an unsupported signature algorithm) and offers no way to override - // verification programmatically. Fall back to a `curl --insecure` - // subprocess, which uses its own TLS stack. + // Deno can't be told to skip certificate checks for one request, so a + // site with an expired, self-signed or unusually signed certificate is + // fetched with curl instead. const curlRes = await fetchViaCurl(url, address, timeoutMs); if (curlRes) return curlRes; @@ -212,10 +214,10 @@ async function fetchOnce( } /** - * Fetches a user-supplied URL, following up to five redirects. Every hop — the - * URL itself and each redirect — must resolve to a public address (see - * {@link resolvePublicAddress}), so a link or a redirect can't point the - * server at its own network. + * Fetches a user-supplied URL, following up to five redirects, each of which + * must resolve to a public address too. `timeoutMs` bounds each hop, body + * included. The caller must read or cancel the body, or its connection stays + * open. * * @throws {TypeError} When a hop isn't http(s), there are too many redirects, * or the request fails. @@ -242,16 +244,17 @@ export async function fetchWithTimeout( } /** - * Checks whether `raw` is an http(s) URL a dump may link to: not on a private - * host, unless it is one of this site's own pages. + * Checks whether `raw` is an http(s) URL on a host that isn't private, or a + * link to one of this site's own pages. Routes and services check a URL with it + * before storing, previewing or proxying it; it does no DNS lookup, which + * {@link fetchWithTimeout} does. */ export function isValidHttpUrl(raw: string): boolean { try { const u = new URL(raw); if (u.protocol !== "http:" && u.protocol !== "https:") return false; - // This site's own pages are fine even on a private host (e.g. localhost in - // development): selfProvider reads them from the DB, no HTTP involved. - // Every actual fetch is checked again, after DNS, in fetchWithTimeout. + // This site may run on a private host (localhost in development), and the + // self provider reads its pages from the database without fetching them. return !isPrivateHostname(u) || isSelfUrl(u); } catch { return false; diff --git a/api/remote/html.ts b/api/remote/html.ts index da91b37e..d82d36da 100644 --- a/api/remote/html.ts +++ b/api/remote/html.ts @@ -1,11 +1,17 @@ /** - * Scraping helpers: the metadata a link preview reads from a page's HTML — - * meta and OG tags, the title, JSON-LD, images, icons and brand colors. + * Reads a link preview's metadata out of a page's HTML with regular + * expressions, without parsing the document. The generic provider uses most of + * it; the Bandcamp, SoundCloud and YouTube providers read only OG tags. Each + * function returns undefined when the page doesn't say. * * @module */ -/** Decodes the named and numeric HTML entities in `str`. */ +/** + * Decodes the five XML entities and numeric ones in `str`. `&` goes first, + * so `&quot;` decodes twice; `bandcamp-tralbum.ts`, which decodes JSON, + * has its own single-pass decoder. + */ function decodeHtmlEntities(str: string): string { return str .replace(/&/gi, "&") @@ -21,9 +27,9 @@ function decodeHtmlEntities(str: string): string { } /** - * Returns the `content` of the first `` tag whose `attr` equals `value` - * (either attribute ordering). Each tag is matched in isolation so - * the `content` capture can never span across tag boundaries. + * Returns the `content` of the first `` tag whose `attr` equals `value`, + * in whichever order the attributes come. Each tag is matched on its own, so + * the capture can't run into the next tag. */ function extractMetaContent( html: string, @@ -44,7 +50,7 @@ function extractMetaContent( return undefined; } -/** Returns the content of ``. */ +/** Returns the content of ``, entities decoded. */ export function extractOgTag( html: string, tag: string, @@ -53,8 +59,8 @@ export function extractOgTag( } /** - * Returns the content of `` (both attribute - * orderings). + * Returns the content of ``, entities decoded. Twitter + * card tags use `name`, not `property`. */ export function extractMetaName( html: string, @@ -63,20 +69,16 @@ export function extractMetaName( return extractMetaContent(html, "name", name); } -/** Returns the text content of the `` element. */ +/** Returns the text of the `<title>` element, trimmed and entities decoded. */ export function extractPageTitle(html: string): string | undefined { const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); return match ? decodeHtmlEntities(match[1].trim()) : undefined; } -// ── Brand color helpers ─────────────────────────────────────────────────────── - /** - * Returns the page's `theme-color`. - * - * Sites routinely ship the tag twice, scoped by `media`, and often list the - * dark-scheme one first — so unlike `extractMetaName` this can't just take the - * first match. Prefer the unscoped tag, then the light-scheme one, then any. + * Returns the page's `theme-color`, as written: pass it to + * {@link normalizeCssColor}. Sites often ship one per color scheme with the + * dark one first, so the unscoped tag wins, then the light one, then any. */ export function extractThemeColor(html: string): string | undefined { const tagRe = /<meta\b[^>]*>/gi; @@ -100,7 +102,10 @@ export function extractThemeColor(html: string): string | undefined { return unscoped ?? light ?? any; } -/** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ +/** + * Returns the `color` of `<link rel="mask-icon">`, Safari's pinned-tab icon, + * as written. The generic provider's last resort for a brand color. + */ export function extractMaskIconColor(html: string): string | undefined { const linkRe = /<link[^>]+>/gi; let m: RegExpExecArray | null; @@ -114,8 +119,8 @@ export function extractMaskIconColor(html: string): string | undefined { } /** - * The handful of named colors that actually show up in `theme-color`. The full - * 148-name table isn't worth carrying for the long tail. + * The named colors sites actually put in `theme-color`; the rest of CSS's names + * aren't worth carrying. */ const NAMED_COLORS: Record<string, string> = { black: "#000000", @@ -145,9 +150,10 @@ function clampByte(n: number): number { } /** - * Normalizes a CSS color to canonical lowercase `#rrggbb`, or `undefined` when - * it isn't one of the forms sites actually use. Alpha is dropped — the color is - * only ever used as a tint over the app's own surface. + * Returns `raw` as a lowercase `#rrggbb`, from a hex, `rgb()`, `rgba()` or + * common named color; undefined for any other form. The generic provider + * stores it as the preview's `accentColor`, a tint over the app's own surface, + * so alpha is dropped. */ export function normalizeCssColor(raw: string | undefined): string | undefined { if (!raw) return undefined; @@ -182,17 +188,17 @@ export function normalizeCssColor(raw: string | undefined): string | undefined { return undefined; } -// ── JSON-LD helpers ─────────────────────────────────────────────────────────── - -/** What a page's JSON-LD says about it. */ +/** + * Returned to the generic provider by {@link extractJsonLd}, which ranks each + * field below the page's OG and Twitter tags. + */ export interface JsonLdResult { - /** The node's `name` or `headline`. */ + /** From `name`, or else `headline`. */ title?: string; - /** The node's `description`. */ description?: string; /** - * The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute - * URL. + * From `image`, or else `thumbnailUrl` or `thumbnail`, and only when it is + * an absolute URL. */ thumbnailUrl?: string; } @@ -237,9 +243,10 @@ function ldExtractNode(data: unknown): JsonLdResult { } /** - * Parses every `<script type="application/ld+json">` block and returns the first - * node that yields a title or image. Handles `@graph`, arrays, and the common - * `image` shapes (string, string[], ImageObject). + * Returns the first JSON-LD node in `html` that has a title or an image, + * looking through `@graph` and arrays; empty when there is none. An image may + * be a URL, a list of them or an `ImageObject`. Blocks that aren't valid JSON + * are skipped. */ export function extractJsonLd(html: string): JsonLdResult { const pattern = @@ -249,14 +256,15 @@ export function extractJsonLd(html: string): JsonLdResult { try { const result = ldExtractNode(JSON.parse(match[1])); if (result.title || result.thumbnailUrl) return result; - } catch { /* invalid JSON — skip */ } + } catch { /* invalid JSON: try the next block */ } } return {}; } /** - * Returns the `src` of the first `<img>` whose declared width or height is at - * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. + * Returns the absolute URL of the first `<img>` whose declared width and height + * are both at least `minSize` pixels; an image without them doesn't count. + * Data URIs are skipped. */ export function extractLargeImage( html: string, @@ -283,13 +291,12 @@ export function extractLargeImage( } /** - * Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks - * them by declared size (largest wins), and returns the best resolved URL. - * Falls back to the first match when no `sizes` attribute is present. + * Returns the absolute URL of the page's largest icon, among its `icon` and + * `apple-touch-icon` links, by declared `sizes`; the first one when none + * declares a size. The generic provider stores it as the preview's + * `faviconUrl`, which the thumbnail placeholder draws. * - * SVG icons are ranked above everything: they carry no `sizes` attribute, so - * they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to - * whatever size the placeholder renders them at. + * An SVG icon wins over any size: it declares none, yet scales to any. */ export function extractBestIcon( html: string, @@ -330,7 +337,7 @@ export function extractBestIcon( return candidates[0].href; } -/** Filename tokens that mark an image as UI chrome rather than content. */ +/** Filename words that mark an image as page furniture rather than content. */ const UI_IMAGE_KEYWORDS = new Set([ "logo", "icon", @@ -350,24 +357,22 @@ const UI_IMAGE_KEYWORDS = new Set([ ]); /** - * Checks whether `src` looks like UI chrome: an SVG, or a filename with one of - * the {@link UI_IMAGE_KEYWORDS}. + * Checks whether `src` looks like page furniture: an SVG, or a filename with + * one of the {@link UI_IMAGE_KEYWORDS} between its `-`, `_` or `.`. */ function isUiImage(src: string): boolean { if (/\.svg(\?|$)/i.test(src)) return true; const filename = src.split("?")[0].split("/").pop() ?? ""; - const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension - // Split on common filename separators (-, _, .) and check each token + const baseName = filename.replace(/\.[^.]+$/, ""); return baseName.toLowerCase().split(/[-_.]/).some((t) => UI_IMAGE_KEYWORDS.has(t) ); } /** - * Returns the `src` of the first `<img>` that looks like content rather than UI - * chrome. Skips SVGs, data URIs, and images whose filename tokens match common - * icon/logo/nav patterns (logo, icon, sprite, etc.). - * Resolves relative and protocol-relative URLs. + * Returns the absolute URL of the first `<img>` that looks like content rather + * than a logo, icon or other page furniture. Data URIs are skipped. The generic + * provider's last resort for a thumbnail. */ export function extractFirstContentImage( html: string, @@ -390,8 +395,8 @@ export function extractFirstContentImage( } /** - * Returns the `href` of the first `<link rel="…">` whose rel contains - * `relFragment`, resolved to an absolute URL using `baseUrl`. + * Returns the absolute URL of the first `<link>` whose `rel` contains + * `relFragment`. No provider calls it; only its tests do. */ export function extractLinkHref( html: string, diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts index dca5447b..442784ea 100644 --- a/api/remote/image-proxy.ts +++ b/api/remote/image-proxy.ts @@ -1,17 +1,21 @@ /** - * The image proxy's fetch and cache: remote thumbnails are served through - * this site, so viewers' browsers never contact the hosts. + * Fetches and caches the remote images `GET /api/proxy-image` serves. The + * frontend routes a thumbnail through it when the image is on plain http, + * which an https page would block, or when loading it directly failed, often + * to hotlink protection. * * @module */ import { fetchWithTimeout } from "./fetch.ts"; -/** An image fetched for the proxy. */ +/** + * Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends + * it back under `contentType`. + */ export interface ProxiedImage { - /** The image's bytes. */ bytes: Uint8Array; - /** The image's MIME type, as its host sent it. */ + /** One of the raster types, without parameters. */ contentType: string; } @@ -26,14 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([ "image/vnd.microsoft.icon", ]); -/** How long a proxied image is served from the cache. */ const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; -/** The cache's size bound, in image bytes. */ const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; /** - * Proxied images, by URL: every viewer of a feed asks for the same thumbnails, - * which were fetched from their hosts again for each one. Bounded by total - * size; the least recently used go first. Map order is recency order. + * Proxied images, by URL, since every viewer of a feed asks for the same + * thumbnails. The least recently used go first, and the Map's order is that + * of use. */ const imageCache = new Map< string, @@ -51,7 +53,7 @@ function readImageCache( imageCacheBytes -= hit.bytes.byteLength; return undefined; } - imageCache.set(url, hit); // most recently used + imageCache.set(url, hit); // Moves it to the end: most recently used. return hit; } @@ -77,7 +79,7 @@ function writeImageCache( imageCacheBytes += bytes.byteLength; } -/** Forgets every proxied image (for the tests). */ +/** Empties the cache, for the tests' reset between cases. */ export function clearImageCache(): void { imageCache.clear(); imageCacheBytes = 0; @@ -85,9 +87,11 @@ export function clearImageCache(): void { /** * Returns the image at `url`, from the cache or else from its host; null when - * the host answers with something that isn't a raster image of 5 MB at most. + * the host answers with anything but a raster image of 5 MB at most. The route + * checks `url` with `isValidHttpUrl` first. * - * @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}). + * @throws {Error} When the host can't be reached, as + * {@link fetchWithTimeout} reports it. */ export async function fetchProxiedImage( url: string, @@ -96,9 +100,8 @@ export async function fetchProxiedImage( if (cached) return cached; const res = await fetchWithTimeout(url, 8000); - // Raster formats only. The bytes go out under this site's origin, so an - // SVG (which can carry script) or anything else a browser might execute - // would run with the app's origin if opened directly. + // Raster formats only: the bytes go out under this site's origin, where an + // SVG, which can carry script, would run if opened directly. const contentType = (res.headers.get("content-type") ?? "") .split(";")[0].trim().toLowerCase(); if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { @@ -106,7 +109,7 @@ export async function fetchProxiedImage( return null; } - const MAX_SIZE = 5 * 1024 * 1024; // 5 MB + const MAX_SIZE = 5 * 1024 * 1024; const contentLength = Number(res.headers.get("content-length") ?? "0"); if (contentLength > MAX_SIZE) { await res.body?.cancel(); diff --git a/api/remote/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts index a0785172..7f3c2dfb 100644 --- a/api/remote/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -1,5 +1,6 @@ /** - * The Bandcamp link provider: previews from a release page's OG tags. + * Previews Bandcamp links from their page's OG tags, for + * `rich-content-service.ts`. * * @module */ @@ -11,7 +12,12 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; -/** The provider for bandcamp.com pages, with the embed player from `og:video`. */ +/** + * Claims bandcamp.com and its subdomains, and `bandcamp.com.example.org` too, + * since the pattern isn't anchored at the end. Takes the embed player's URL + * from the page's `og:video`. The native player fetches the page's streams + * itself at play time, through `GET /api/bandcamp/tracks`. + */ export const bandcampProvider: RichContentProvider = { name: "bandcamp", diff --git a/api/remote/providers/generic.ts b/api/remote/providers/generic.ts index 9b84cb63..e1a7a309 100644 --- a/api/remote/providers/generic.ts +++ b/api/remote/providers/generic.ts @@ -1,5 +1,6 @@ /** - * The fallback link provider, for any page no other provider claims. + * Previews any page no other provider claims, for `rich-content-service.ts`, + * from whatever metadata its HTML carries. * * @module */ @@ -21,15 +22,16 @@ import { import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; /** - * The provider for any HTML page, previewed from its OG and Twitter tags, JSON-LD, and markup: - * title, description, artwork, icon and brand color. Matches every URL, so it - * must come last. + * Matches every URL, so it comes last in the service's list. Each field falls + * back from OG tags to Twitter tags, JSON-LD and the markup itself. The icon + * and brand color feed the placeholder the frontend draws when there is no + * thumbnail. */ export const genericProvider: RichContentProvider = { name: "generic", matches(_url: string): boolean { - return true; // fallback — always matches + return true; }, async fetch(url: string): Promise<RichContent> { @@ -44,9 +46,9 @@ export const genericProvider: RichContentProvider = { const html = await readTextCapped(res); const ld = extractJsonLd(html); - // If og:url is present but points to a different page (e.g. the homepage), - // the og: block is a site-level fallback, not page-specific metadata. - // In that case skip og:title and og:image so page-level signals win. + // An og:url naming another page (often the home page) means the OG tags + // are the site's defaults, not this page's: its title and image are + // skipped. const ogUrl = extractOgTag(html, "url"); const useOg = !ogUrl || (() => { try { @@ -58,36 +60,29 @@ export const genericProvider: RichContentProvider = { } })(); - // Title: og:title (page-matched) → twitter:title → JSON-LD → <title> const title = (useOg ? extractOgTag(html, "title") : undefined) ?? extractMetaName(html, "twitter:title") ?? ld.title ?? extractPageTitle(html); - // Site name: og:site_name → hostname const siteName = extractOgTag(html, "site_name") ?? new URL(url).hostname.replace(/^www\./, ""); - // Description: og:description → twitter:description → JSON-LD → <meta name="description"> const description = extractOgTag(html, "description") ?? extractMetaName(html, "twitter:description") ?? ld.description ?? extractMetaName(html, "description"); - // Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → - // first content <img>. The chain deliberately stops there: a favicon is not - // artwork, and pretending otherwise means every art-less page gets a 16×16 - // icon cover-cropped into a 128×72 box. No match here means "no artwork", - // and the frontend draws a generated placeholder instead. + // Never the favicon: a 16×16 icon cropped into a thumbnail looks broken, + // and the placeholder drawn instead shows the icon properly. const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ?? extractMetaName(html, "twitter:image") ?? ld.thumbnailUrl ?? extractLargeImage(html, url) ?? extractFirstContentImage(html, url); - // Icon and brand color are independent facts about the page, so they're - // collected whether or not there's artwork. `/favicon.ico` is a guess; when - // it 404s the placeholder falls back to the site's initial. + // `/favicon.ico` is a guess. When it doesn't load, the placeholder shows + // the site's initial. const faviconUrl = extractBestIcon(html, url) ?? `${new URL(url).origin}/favicon.ico`; diff --git a/api/remote/providers/provider.ts b/api/remote/providers/provider.ts index 67ac846a..a027ea80 100644 --- a/api/remote/providers/provider.ts +++ b/api/remote/providers/provider.ts @@ -1,20 +1,25 @@ /** - * The interface every link provider implements. + * Declares what a link provider implements. `rich-content-service.ts` asks its + * providers in turn and fetches a link's preview from the first that matches. * * @module */ import type { RichContent } from "../../../shared/dump.ts"; -/** Metadata for the links one kind of site serves. */ +/** + * Implemented by each file of `remote/providers/`, and listed in priority order + * by `rich-content-service.ts`. + */ export interface RichContentProvider { - /** The provider's name, for logs. */ name: string; - /** Checks whether this provider handles `url`. */ + /** Called on a URL that `isValidHttpUrl` accepted; never throws. */ matches(url: string): boolean; /** - * Fetches `url`'s preview. Rejects when the page can't be fetched; a page - * that isn't HTML yields a preview with no metadata. + * Returns the preview of `url`, which ends up in the dump's `rich_content` or + * the create form's preview. It rejects when the page can't be fetched, and + * the service then falls back to a stub. A page that isn't HTML yields a + * preview without metadata. */ fetch(url: string): Promise<RichContent>; } diff --git a/api/remote/providers/self.ts b/api/remote/providers/self.ts index 04e45e37..ee76d4a7 100644 --- a/api/remote/providers/self.ts +++ b/api/remote/providers/self.ts @@ -1,6 +1,7 @@ /** - * The link provider for this site's own pages, read from the database - * instead of fetched. + * Previews links to this site's own dump, user and playlist pages from the + * database, for `rich-content-service.ts`. No request goes out, so this works + * even when the site runs on a private host that `fetchWithTimeout` refuses. * * @module */ @@ -18,8 +19,8 @@ const USER_RE = /^\/users\/([^/]+)$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; /** - * Internal links carry gerbeur's own branding, so an art-less one still gets a - * recognizable placeholder instead of a generic tint. + * Gives a link to this site without a thumbnail a placeholder in the site's + * icon and color. */ const SELF_BRANDING = { siteName: "gerbeur", @@ -28,9 +29,9 @@ const SELF_BRANDING = { } as const; /** - * The provider for links to this site's dump, user and playlist pages, read - * from the database. Its `fetch` rejects with the services' `APIException` (404) for a - * page that doesn't exist or is private. + * Comes before the generic provider, and claims what `isSelfUrl` accepts. Its + * `fetch` rejects with the services' 404 `APIException` for a missing page or + * a private dump. A private playlist is previewed all the same. */ export const selfProvider: RichContentProvider = { name: "self", @@ -97,7 +98,7 @@ export const selfProvider: RichContentProvider = { }); } - // Should not reach here if matches() is correct + // Unreachable: isSelfUrl accepts only the three paths above. return Promise.resolve({ type: "generic", url }); }, }; diff --git a/api/remote/providers/soundcloud.ts b/api/remote/providers/soundcloud.ts index 4e56e744..f152838e 100644 --- a/api/remote/providers/soundcloud.ts +++ b/api/remote/providers/soundcloud.ts @@ -1,5 +1,6 @@ /** - * The SoundCloud link provider: previews from a track page's OG tags. + * Previews SoundCloud links from their page's OG tags, for + * `rich-content-service.ts`. * * @module */ @@ -9,7 +10,10 @@ import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; -/** The provider for soundcloud.com pages, with SoundCloud's embed player. */ +/** + * Claims soundcloud.com only, not www. or m.soundcloud.com, and builds the + * embed player's URL from the link itself. + */ export const soundcloudProvider: RichContentProvider = { name: "soundcloud", diff --git a/api/remote/providers/youtube.ts b/api/remote/providers/youtube.ts index aa8a53ec..5e4548a5 100644 --- a/api/remote/providers/youtube.ts +++ b/api/remote/providers/youtube.ts @@ -1,5 +1,7 @@ /** - * The YouTube link provider: videos, playlists and channels. + * Previews YouTube videos, playlists and channels, for + * `rich-content-service.ts`. Videos and playlists are read from YouTube's + * oEmbed endpoint, channels from their page's OG tags. * * @module */ @@ -9,7 +11,10 @@ import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; -/** Returns the id of the video a youtu.be or youtube.com URL points at. */ +/** + * Returns the id of the video a URL points at, on youtu.be, youtube.com or + * www.youtube.com only; `m.` and `music.` links go to the generic provider. + */ function extractVideoId(url: string): string | null { try { const u = new URL(url); @@ -32,7 +37,10 @@ function extractVideoId(url: string): string | null { return null; } -/** Returns the `list` parameter of a URL: the playlist it plays. */ +/** + * Returns the `list` parameter of a URL, whatever its host: a link to another + * site with a `list` parameter is taken for a YouTube playlist. + */ function extractPlaylistId(url: string): string | null { try { return new URL(url).searchParams.get("list"); @@ -42,9 +50,8 @@ function extractPlaylistId(url: string): string | null { } /** - * Returns the playback offset in seconds of a YouTube URL. - * Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start` - * (plain seconds). Returns null when absent or unparseable. + * Returns the start time of a YouTube URL in seconds, from `t` (`90`, `90s`, + * `1h2m3s`) or `start`; null when there is none that parses. */ function extractStartSeconds(url: string): number | null { let raw: string | null; @@ -66,8 +73,8 @@ function extractStartSeconds(url: string): number | null { } /** - * Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or - * /user/name. + * Returns the path of a youtube.com channel URL (`/channel/UC…`, `/@handle`, + * `/c/name` or `/user/name`); null for any other URL. */ function extractChannelPath(url: string): string | null { try { @@ -90,7 +97,7 @@ function extractChannelPath(url: string): string | null { /** * Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint; - * empty when that fails. + * empty when that fails, since the preview can do without them. */ async function fetchOEmbed( url: string, @@ -109,14 +116,16 @@ async function fetchOEmbed( } await res.body?.cancel(); } catch { - // oembed failed — carry on + // YouTube unreachable, or its answer not JSON. } return {}; } /** - * The provider for YouTube videos (with their playlist and start time in the embed), - * playlists (as a `videoseries` embed) and channels (not embeddable). + * Comes first in the service's list. A video's embed keeps the link's playlist + * and start time, a playlist embeds as a `videoseries`, and a channel gets no + * embed. Its `fetch` never rejects: whatever YouTube doesn't answer is left + * out. */ export const youtubeProvider: RichContentProvider = { name: "youtube", @@ -134,9 +143,8 @@ export const youtubeProvider: RichContentProvider = { const listId = extractPlaylistId(url); const channelPath = extractChannelPath(url); - // ── Channel ─────────────────────────────────────────────────────────────── if (channelPath && !videoId) { - // oEmbed doesn't support channel URLs — scrape og:image from the page instead + // oEmbed doesn't take channel URLs. let title: string | undefined; let thumbnailUrl: string | undefined; try { @@ -149,7 +157,7 @@ export const youtubeProvider: RichContentProvider = { await res.body?.cancel(); } } catch { - // scrape failed — carry on with undefined values + // YouTube unreachable: the preview goes without. } return { type: "youtube", @@ -157,11 +165,9 @@ export const youtubeProvider: RichContentProvider = { url, title, thumbnailUrl, - // channels are not embeddable as a player }; } - // ── Playlist (no specific video) ────────────────────────────────────────── if (listId && !videoId) { const { title, thumbnailUrl } = await fetchOEmbed(url); return { @@ -175,7 +181,7 @@ export const youtubeProvider: RichContentProvider = { }; } - // ── Video (with optional playlist context) ──────────────────────────────── + // Built, not taken from oEmbed, so it is there even when oEmbed fails. const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; const { title } = await fetchOEmbed(url); diff --git a/api/remote/public-address.ts b/api/remote/public-address.ts index bb9adad8..4c571f22 100644 --- a/api/remote/public-address.ts +++ b/api/remote/public-address.ts @@ -1,8 +1,8 @@ /** - * Guards for server-side fetches of user-supplied URLs (link previews, the - * image proxy, Bandcamp resolution): the server must not become a way to reach - * addresses only it can see — loopback, the LAN, container networks, or the - * cloud metadata endpoint at 169.254.169.254. + * Tells private addresses from public ones, so that a user-supplied URL can't + * make the server fetch what only it can reach: loopback, the LAN, container + * networks or the cloud metadata endpoint at 169.254.169.254. `fetch.ts` checks + * every hop with it. * * @module */ @@ -83,8 +83,9 @@ function isPrivateIPv6(b: number[]): boolean { } /** - * Checks whether `ip` is an IP literal outside the public internet. Non-IPs - * are false. + * Checks whether `ip` is an IPv4 or IPv6 address outside the public internet. + * An IPv4 address carried in IPv6 is judged as IPv4. Anything that isn't an IP + * address is false, so a hostname must be resolved first. */ export function isPrivateAddress(ip: string): boolean { const v4 = parseIPv4(ip); @@ -106,9 +107,9 @@ function bareHostname(url: URL): string { } /** - * Checks whether `url`'s host is private without DNS: a private IP literal, - * or a name that is local by definition. Names still need resolving (see - * resolvePublicAddress) — this only rejects what is obvious without DNS. + * Checks whether the host of `url` is private without a DNS lookup: a private + * IP address, or `localhost` and its subdomains. `isValidHttpUrl` uses it to + * reject a link early; only {@link resolvePublicAddress} is a real guard. */ export function isPrivateHostname(url: URL): boolean { const host = bareHostname(url).toLowerCase(); @@ -117,13 +118,13 @@ export function isPrivateHostname(url: URL): boolean { } /** - * Resolves `url`'s host and returns one of its addresses, or throws if the - * host is private or any address it resolves to is (a name with both a public - * and a private record is refused outright, since the fetch may pick either). + * Resolves the host of `url` and returns one of its addresses, all of which + * must be public: the fetch may pick any of them. `fetchWithTimeout` calls it + * before each hop. * - * The fetch itself resolves again, so a DNS server answering differently the - * second time (rebinding) isn't fully excluded; the curl fallback pins the - * address this returns. + * Only the curl fallback connects to the address returned. Deno's fetch + * resolves the name again, so a DNS server that answers differently the second + * time (rebinding) can still get through. * * @throws {Error} When the host is private, can't be resolved, or resolves to * a private address. diff --git a/api/remote/twemoji.ts b/api/remote/twemoji.ts index 3863a7ee..23fa1a0f 100644 --- a/api/remote/twemoji.ts +++ b/api/remote/twemoji.ts @@ -1,18 +1,23 @@ /** - * Twemoji glyphs, fetched from the jsDelivr CDN. + * Fetches Twemoji glyphs from the jsDelivr CDN, for `lib/site-icons.ts` to + * render the favicons from. * * @module */ -/** The pinned Twemoji release. */ +/** Pinned, so a new Twemoji release can't change the icons unasked. */ const TWEMOJI_VERSION = "16.0.1"; -/** Fetched glyphs by codepoint, so repeated runs don't re-hit the network. */ +/** + * Glyphs by codepoint. It only lives as long as the process, and the `icons` + * task renders one emoji per process. + */ const glyphCache = new Map<string, string>(); /** - * Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named - * `cp` (see `emojiToCodepoint` in lib/site-icons.ts). + * Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from + * `emojiToCodepoint`. `emoji` only serves the error message. It fetches with + * plain `fetch`, since the host is fixed. * * @throws {Error} When the glyph can't be fetched. */ diff --git a/api/remote/youtube-playlist.ts b/api/remote/youtube-playlist.ts index 90e19740..14766bb1 100644 --- a/api/remote/youtube-playlist.ts +++ b/api/remote/youtube-playlist.ts @@ -1,17 +1,13 @@ /** - * Resolves a YouTube playlist to its tracklist, at play time. + * Resolves a YouTube playlist to its entries' titles and lengths, for + * `GET /api/youtube/playlist`. The embed plays the list but doesn't expose + * them, and the global player needs them to draw a tracklist. * - * Playback itself stays in the iframe embed — YouTube plays the list and - * advances through it on its own. This only supplies what the embed doesn't - * expose: every entry's title and length, so the global player can draw a - * tracklist the way it does for a Bandcamp album. The client syncs the - * highlighted row and row clicks through the IFrame Player API. - * - * The data comes from the `ytInitialData` blob on the public playlist page. Its - * shape is undocumented and changes, so the parse is best-effort and accepts - * both the current `lockupViewModel` rows and the older `playlistVideoRenderer` - * ones. Only the first page (100 entries) is read; the client appends any - * further IDs the player reports, untitled. + * They are read from the `ytInitialData` JSON of the public playlist page, + * whose undocumented shape changes, so both the current `lockupViewModel` rows + * and the older `playlistVideoRenderer` ones are accepted. Only the first 100 + * entries are there; the client adds the rest, untitled, as the player reports + * them. * * @module */ @@ -24,28 +20,25 @@ import type { YouTubePlaylistTrack, } from "../../shared/player.ts"; -/** - * How long a resolved playlist is cached. Titles and lengths rarely change; a - * stale entry only mislabels a row. - */ +/** Titles and lengths rarely change, and a stale one only mislabels a row. */ const TTL_MS = 6 * 60 * 60 * 1000; -/** The most playlists cached; the oldest write goes first. */ +/** The oldest write goes first. */ const MAX_ENTRIES = 200; -/** Resolved playlists, by list id. */ const cache = new Map<string, { value: YouTubePlaylist; at: number }>(); -/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */ +/** + * Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else. + */ const LIST_ID_RE = /^[\w-]{2,64}$/; -/** The headers the playlist page is requested with. */ const HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", "Accept": "text/html", "Accept-Language": "en-US,en;q=0.8", - // Pre-answered consent: without it EU requests are redirected to - // consent.youtube.com and never see the playlist. + // Declines the consent prompt, which EU requests are otherwise redirected + // to instead of the playlist. "Cookie": "SOCS=CAI", }; @@ -55,7 +48,7 @@ function parseClock(text: string): number | undefined { return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0); } -/** Collects, depth-first, every value stored under `key` into `out`. */ +/** Collects, depth first, every value stored under `key` into `out`. */ function collect(node: unknown, key: string, out: unknown[]): void { if (Array.isArray(node)) { for (const child of node) collect(child, key, out); @@ -74,10 +67,7 @@ function firstString(node: unknown, key: string): string | undefined { return found.find((v): v is string => typeof v === "string"); } -/** - * Returns the value at `path` in parsed JSON; undefined wherever a step is - * missing. - */ +/** Returns the value at `path` in `node`; undefined when a step is missing. */ function at(node: unknown, ...path: (string | number)[]): unknown { let cur = node; for (const key of path) { @@ -124,8 +114,8 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null { } /** - * Reads the tracklist from a playlist page's `ytInitialData`; null when the - * page has none or no track in it. + * Returns the playlist `listId` as read from its page `html`; null when the + * page has no `ytInitialData` or no track in it. */ export function parsePlaylistPage( html: string, @@ -148,8 +138,7 @@ export function parsePlaylistPage( const legacy: unknown[] = []; collect(at(data, "contents"), "playlistVideoRenderer", legacy); - // A video can appear in a playlist twice; both rows are kept, so the list - // stays positional and matches what the embed plays. + // A video listed twice keeps both rows, so that row n is the embed's entry n. const tracks = [ ...lockups.map(fromLockup), ...legacy.map(fromLegacyRenderer), @@ -164,7 +153,8 @@ export function parsePlaylistPage( } /** - * Fetches the playlist `listId` and returns its tracklist, cached. + * Returns the tracklist of the playlist `listId`, from the cache when it was + * resolved in the last 6 hours. * * @throws {APIException} 400 when `listId` isn't a playlist id, 502 when * YouTube can't be reached or answers with an error, 404 when the playlist has @@ -189,6 +179,8 @@ export async function resolveYouTubePlaylist( const timer = setTimeout(() => controller.abort(), 8000); let html: string; try { + // youtube.com needs no address check, and fetchWithTimeout can't send the + // consent cookie. const res = await fetch( `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`, { headers: HEADERS, signal: controller.signal }, @@ -215,8 +207,7 @@ export async function resolveYouTubePlaylist( const parsed = parsePlaylistPage(html, listId); if (!parsed) { - // Private, deleted, or auto-generated mixes (RD…) that have no playlist - // page — the client just shows the embed without a tracklist. + // The client then shows the embed without a tracklist. throw new APIException( APIErrorCode.NOT_FOUND, 404, diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index a247eeb3..743703e6 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -1,9 +1,9 @@ /** - * The attachment routes, for images pasted into Markdown bodies. + * Stores and serves the images pasted or dropped into the text editor, which + * puts each one in the body as a link to its `GET` URL. * * - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at - * most 5 MB) and answers its id; it is bound to a resource once a saved body - * links it. + * most 5 MB) and answers its id. * - `GET /api/attachments/:attachmentId` — public. Serves the image. * * @module @@ -24,7 +24,10 @@ import { validateImageUpload, } from "../lib/upload.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router(); router.post("/api/attachments", authMiddleware, async (ctx) => { diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index b8e436df..c9a35bd4 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -1,8 +1,11 @@ /** - * The avatar routes. + * Serves the avatar endpoints. The profile page uploads the avatar, and the + * client's `Avatar` component loads it, with the user's `updatedAt` as a + * cache-busting version. * * - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart - * `file`, at most 5 MB), announces it, and answers the caller's user. + * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the + * caller's user. * - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without * one. * @@ -26,7 +29,10 @@ import { validateImageUpload, } from "../lib/upload.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router(); router.post("/api/avatars/me", authMiddleware, async (ctx) => { @@ -56,16 +62,15 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { try { updateUserAvatar(authPayload.userId, mime); } catch (err) { - // DB write failed — clean up the orphaned file + // The row doesn't describe the new file, so don't keep it. await Deno.remove(filePath).catch(() => {}); throw err; } updateClientAvatar(authPayload.userId, mime); const user = getUserById(authPayload.userId); - // Let every client refresh this user's avatar wherever it's shown (feeds, - // comments, OP, profiles) — not just the presence row. updated_at, bumped on - // the avatar write, doubles as the cache-busting version. + // Presence alone would update only the online list; `user_updated` reaches + // every avatar on screen, and its bumped `updatedAt` busts their cache. broadcastUserUpdated(toPublicUser(user)); const responseBody: APIResponse<SelfUser> = { success: true, diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts index fe4e495a..8e5540c8 100644 --- a/api/routes/bandcamp.ts +++ b/api/routes/bandcamp.ts @@ -1,8 +1,9 @@ /** - * The Bandcamp route, for the native player. + * Serves the Bandcamp release the native player plays, resolved from its page + * when playback starts. On any failure the player falls back to the embed. * - * - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page - * to its streamable tracks, at play time; `force=1` bypasses the cache. + * - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's + * tracks and their stream URLs; `force=1` skips the server's cache. * * @module */ @@ -12,13 +13,14 @@ import type { APIResponse } from "../../shared/api.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; import type { Tralbum } from "../../shared/player.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const bandcampRouter: Router = new Router(); -// Public: it only reads a public page and returns URLs the same page already -// hands any visitor. Errors are thrown as APIException and rendered by -// errorMiddleware; the client treats any failure as "fall back to the iframe -// embed". +// Public: it reads a public page and answers the URLs that page gives any +// visitor. bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; const force = ctx.request.url.searchParams.get("force") === "1"; @@ -28,8 +30,8 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { data: await resolveBandcamp(url, { force }), }; - // Private: the signed URLs in the body are per-fetch, so they must not be - // held in a shared cache. Well inside the 24h signature life either way. + // Not in a shared cache: the signed URLs belong to one page fetch. An hour + // is well inside their 24 hours. ctx.response.headers.set("Cache-Control", "private, max-age=3600"); ctx.response.body = responseBody; }); diff --git a/api/routes/categories.ts b/api/routes/categories.ts index bce626f6..528ae332 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -41,7 +41,10 @@ import { updateCategory, } from "../services/category-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/categories" }); router.get("/", (ctx) => { diff --git a/api/routes/chat.ts b/api/routes/chat.ts index c05c646c..26174038 100644 --- a/api/routes/chat.ts +++ b/api/routes/chat.ts @@ -1,13 +1,14 @@ /** - * The chat history route; messages are posted and edited over the socket (see - * routes/ws.ts). + * Serves the chat's history to the chatbox (`ChatModal`). Messages are sent, + * edited and deleted over the socket instead (`routes/ws.ts`). * - * - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in. - * Answers messages in ascending order: the most recent by default; with - * `before`, the page of older messages preceding that cursor ("load older"); - * with `after`, the messages following it (resync after a reconnect). A - * cursor is an ISO `created_at` plus the message id to break ties; without - * `afterId` the `after` timestamp itself is included. + * - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=`, signed in. + * Returns messages oldest first, at most `limit` (50 by default, 100 at + * most). With no cursor, it returns the latest. With `before`, it returns + * the page just older than that cursor, for "load older". With `after`, it + * returns what follows it, for the resync after a reconnect. A cursor is a + * message's ISO `created_at`, and its id breaks ties. Without `afterId`, + * messages at the `after` timestamp itself are included. * * @module */ @@ -21,7 +22,10 @@ import { getRecentChatMessages, } from "../services/chat-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/chat" }); router.get("/", authMiddleware, (ctx) => { diff --git a/api/routes/comments.ts b/api/routes/comments.ts index c39b2ee9..2b58b1db 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -1,6 +1,7 @@ /** - * The comment routes. A private dump's comments exist only for its poster, and - * changes to them aren't broadcast. + * Serves the comments on dumps. The comments of a private dump 404 for anyone + * but its poster, and changes to them aren't broadcast. Comments are otherwise + * broadcast over the WebSocket as they are posted, edited and deleted. * * - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment * on a dump, oldest first. @@ -49,7 +50,10 @@ import { broadcastCommentUpdated, } from "../services/ws-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api" }); router.get("/dumps/:dumpId/comments", async (ctx) => { @@ -111,7 +115,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => { router.get("/comments/:commentId/likers", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dumpId = getCommentDumpId(ctx.params.commentId); - getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible + getDump(dumpId, requestingUserId); // 404s unless the caller may see the dump. const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, hasMore } = getCommentLikers( ctx.params.commentId, diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index e3490727..f6255c1b 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -1,6 +1,7 @@ /** - * The dump routes. `:dumpId` takes an id or a slug; a private dump exists only - * for its poster. + * Posts, reads, edits and deletes dumps, for the index feed, the dump page, its + * edit page and the create form. `:dumpId` takes an id or a slug. A private + * dump answers 404 to anyone but its poster, moderators included. * * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file * (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). @@ -14,9 +15,10 @@ * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the * playlists it appears in. * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the - * dumps, newest first. + * dumps the caller may see, newest first. * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with - * `dump:moderate`. Replaces a file dump's upload (multipart). + * `dump:moderate`. Replaces a file dump's upload and comment, and any of its + * title, categories and visibility (multipart). * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with * `dump:moderate`. Updates its title, comment, visibility, categories, and a * link's URL. @@ -73,16 +75,18 @@ import { getDumpVoters } from "../services/vote-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/dumps" }); -// Every route here takes a slug or an id; the services below them key on the -// id, so mutations always pass `dump.id` from the resolved dump, never the raw -// parameter. +// Each handler resolves `:dumpId` with getDump, which checks privacy and takes +// a slug, and passes `dump.id` on: the services' writes match on the id alone. /** - * Reads the category ids that arrive on the multipart create path as a - * JSON-encoded string field; empty when it is missing or malformed. + * Parses a multipart form's `categoryIds`, a JSON array sent as a string. A + * missing or malformed value counts as no category. */ function parseCategoryIds(value: FormDataEntryValue | null): string[] { if (typeof value !== "string" || !value) return []; @@ -191,7 +195,6 @@ router.get("/:dumpId/related", async (ctx) => { router.get("/:dumpId/playlists", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; - // Resolve through getDump so private dumps 404 for anyone but their owner. const dump = getDump(ctx.params.dumpId, requestingUserId); const playlists = getPlaylistsForDump(dump.id, requestingUserId); const responseBody: APIResponse<Playlist[]> = { @@ -309,7 +312,8 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => { const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); - // DB update first (resolves slug → id), then file write. + // The record is written first. If the file write then fails, the thumbnail + // route finds no custom file and falls back to the generated thumbnail. const updatedDump = setDumpThumbnail(dump.id, mime); const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`; await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); diff --git a/api/routes/files.ts b/api/routes/files.ts index e513a821..2d2b1cfd 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,9 +1,10 @@ /** - * The route serving file dumps' uploads. + * Serves file dumps' uploads to the players, viewers and download links of the + * frontend, which builds the URL with `dumpFileUrl`. * - * - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its - * poster's media `token`. Serves the file, inline when that is safe and as a - * download otherwise, with byte ranges. + * - `GET /api/files/:dumpId?v=&token=` — public; a private dump's file needs + * its poster's media `token`. Serves the file, inline when that is safe and + * as a download otherwise, with byte ranges. * * @module */ @@ -16,16 +17,16 @@ import { authenticateMedia } from "../lib/auth.ts"; import { serveFile } from "../lib/serve-file.ts"; import { dumpFileVersion, getDump } from "../services/dump-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/files" }); /** - * Builds an RFC 6266 `Content-Disposition` value that is always a valid - * ByteString. `headers.set` throws a TypeError if the value contains any - * character > U+00FF, so a filename with an emoji / accent / CJK character - * (e.g. an uploaded file) would otherwise crash the response. We provide an - * ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the - * full UTF-8 name for modern browsers. + * Builds a `Content-Disposition` value for a file called `name`. `Headers.set` + * throws on any character above U+00FF, so `filename` gets an ASCII copy of the + * name and `filename*` (RFC 5987) the full one, percent-encoded. */ function contentDisposition( type: "inline" | "attachment", @@ -40,17 +41,16 @@ function contentDisposition( } /** - * The types served inline. The MIME type of an upload is whatever the - * uploader's browser claimed, and these responses come from the app's own - * origin — where the session token lives. Only types a browser renders without - * running the file's own code are served inline; anything else (text/html, - * image/svg+xml, XML, …) downloads. <img>, <audio> and fetch() ignore - * Content-Disposition, so previews that embed or read the file still work. + * Matches the types served inline. An upload's type is whatever the uploader's + * browser claimed, and the file comes from the app's own origin, where the + * session token lives. So only types a browser shows without running code in + * them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`, + * `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and + * previews still work. */ const INLINE_MIME_RE = /^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/; -/** Checks whether a file of type `mime` may be served inline. */ function isInlineSafe(mime: string): boolean { return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase()); } @@ -58,14 +58,13 @@ function isInlineSafe(mime: string): boolean { router.get("/:dumpId", async (ctx) => { const { dumpId } = ctx.params; - // Guard against path traversal (UUIDs are safe, but be explicit) + // The id names a file on disk: nothing but an id's characters gets through. if (!/^[0-9a-f-]{36}$/.test(dumpId)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } - // Media elements can't send an Authorization header, so the viewer's token - // arrives as a query param (same as the WebSocket route). Needed to authorize - // access to private dumps the requester owns. + // Media elements can't send an Authorization header, so the viewer's media + // token comes in the query string. const token = ctx.request.url.searchParams.get("token"); const viewerId = token ? await authenticateMedia(token) : null; const dump = getDump(dumpId, viewerId ?? undefined); @@ -98,10 +97,9 @@ router.get("/:dumpId", async (ctx) => { // links. ctx.response.headers.set("Referrer-Policy", "no-referrer"); - // The app's URLs carry the file's version (see dumpFileVersion), so one - // naming the current version never changes and is cached for good; any - // other URL is revalidated against the ETag. A private dump's bytes must not - // sit in a shared cache. + // A URL naming the file's current version (dumpFileVersion) always serves the + // same bytes, so it is cached for good; any other is revalidated against the + // ETag. A private dump's bytes must not sit in a shared cache. const scope = dump.isPrivate ? "private" : "public"; const current = ctx.request.url.searchParams.get("v") === dumpFileVersion(dump); diff --git a/api/routes/follows.ts b/api/routes/follows.ts index c47cf9c0..fd304511 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -1,5 +1,6 @@ /** - * The follow routes, all for what the caller follows. + * Serves the endpoints for what the caller follows, and the index's "followed" + * tab built from it. What another user follows is served by `routes/users.ts`. * * - `GET /api/follows/status` — signed in. Answers the ids of the users and * playlists they follow. @@ -37,10 +38,9 @@ import { import { getCategoryIdBySlug } from "../services/category-service.ts"; /** - * Resolves the optional ?category=<slug> filter. Returns the matching category - * id, or "missing" when a slug was given but matches nothing (so the feed comes - * back empty rather than unfiltered), or undefined when no filter was - * requested. + * Returns the id of the category the `?category=` slug names, undefined without + * one, or "missing" when it names none, so that the feed comes back empty + * rather than unfiltered. */ function resolveCategoryFilter( params: URLSearchParams, @@ -50,14 +50,14 @@ function resolveCategoryFilter( return getCategoryIdBySlug(slug) ?? "missing"; } -/** The feed a filter naming no category yields. */ const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false }; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/follows" }); -// Static routes first to prevent Oak matching "status"/"feed" as a :param - router.get("/status", authMiddleware, (ctx) => { const status = getFollowStatus(ctx.state.user.userId); const body: APIResponse<FollowStatus> = { success: true, data: status }; diff --git a/api/routes/invites.ts b/api/routes/invites.ts index 749f7baa..bbbcce3e 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -1,9 +1,10 @@ /** - * The invite routes. + * Serves the invite endpoints. Any signed-in user can invite someone, from + * their profile page, and the invite link opens the `/register` page. * * - `POST /api/invites` — signed in. Makes an invite and answers its token. - * - `GET /api/invites/:token` — public. Checks an invite is valid, before the - * register page shows its form; 404 when it isn't. + * - `GET /api/invites/:token` — public. Checks that an invite still works, + * before the register page shows its form; 404 when it doesn't. * * @module */ @@ -15,7 +16,10 @@ import type { InviteResponse } from "../../shared/user.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/invites" }); router.post("/", authMiddleware, (ctx: AuthContext) => { @@ -31,6 +35,7 @@ router.post("/", authMiddleware, (ctx: AuthContext) => { }); router.get("/:token", (ctx) => { + // A used invite answers 404 too: the page only needs to know it won't work. try { validateInvite(ctx.params.token); const responseBody: APIResponse<null> = { success: true, data: null }; diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 91e7f3fc..7573fc0d 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -1,10 +1,13 @@ /** - * The notification routes, all for the caller's own notifications. + * Serves the caller's own notifications to the client's `/notifications` + * page, which marks them all read when it opens. New ones arrive over the + * WebSocket instead, as `notification_created`. * * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, * newest first. * - `POST /api/notifications/read-all` — signed in. Marks them all read. - * - `PATCH /api/notifications/:id/read` — signed in. Marks one read. + * - `PATCH /api/notifications/:id/read` — signed in. Marks one of them read. + * The client doesn't use it. * * @module */ @@ -25,7 +28,10 @@ import { markOneRead, } from "../services/notification-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/notifications" }); router.get("/", authMiddleware, (ctx: AuthContext) => { diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index cdb2a582..a5998511 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -1,11 +1,13 @@ /** - * The playlist routes. `:playlistId` takes an id or a slug. + * Serves playlists, their dumps and their covers. `:playlistId` takes an id or + * a slug. * * - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each * of the caller's playlists and whether it holds the dump. * - `POST /api/playlists` — signed in. Creates a playlist. * - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist - * with the dumps the caller may see in it; a private one only to its owner. + * with the dumps the caller may see in it. A private one 404s but for its + * owner. * - `PUT /api/playlists/:playlistId` — signed in, as its owner or with * `playlist:moderate`. Updates its title, description and visibility. * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with @@ -16,8 +18,8 @@ * or with `playlist:moderate`. Removes a dump. * - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). - * - `GET /api/playlists/:playlistId/image?token=` — public; a private - * playlist's cover needs its owner's media `token`. Serves its cover; 404 + * - `GET /api/playlists/:playlistId/image?token=` — public, but a private + * playlist's cover needs its owner's media `token`. Serves its cover, or 404s * without one. * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * `playlist:moderate`. Reorders the dumps the caller can see in it. @@ -60,7 +62,10 @@ import { validateImageUpload, } from "../lib/upload.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router<AuthState> = new Router<AuthState>({ prefix: "/api/playlists", }); @@ -170,11 +175,10 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); - // The file goes in before the row changes. Setting image_mime and bumping - // updated_at announces the cover (the `?v=` on its URL, the socket event), - // so the bytes must already be there: an earlier fetch would cache the old - // cover under the new URL, and a failed write would leave a cover that 404s. - // Written beside and renamed over, so the old cover is never half-replaced. + // The file is written before setPlaylistImage announces it, or clients would + // fetch the new `?v=` URL too early and cache the old cover under it. It is + // written beside the old one and renamed over it, so a failed write leaves + // the old cover whole. const { id } = getEditablePlaylist( ctx.params.playlistId, ctx.state.user.userId, @@ -210,7 +214,7 @@ router.get("/:playlistId/image", async (ctx) => { ctx.response.status = 404; return; } - // The URL may carry a media token; keep it out of anyone else's logs. + // The URL may carry a media token, which a Referer would leak. ctx.response.headers.set("Referrer-Policy", "no-referrer"); await serveUploadedFile( ctx, diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 4f377fc9..3f6192ba 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -1,5 +1,6 @@ /** - * The link preview routes. + * Fetches link previews for the create form, and proxies the remote images + * that the frontend's thumbnails can't load directly. * * - `GET /api/preview?url=` — signed in. Answers the preview of a link, and * whether its page could be reached. @@ -21,11 +22,13 @@ import { import type { LinkPreview } from "../../shared/dump.ts"; import { authMiddleware } from "../middleware/auth.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const previewRouter: Router = new Router(); -// Signed-in only: it's the create form's preview, and an open one lets anyone -// use the server to fetch pages for them. +// Signed-in only: an open preview would let anyone make the server fetch pages. previewRouter.get("/api/preview", authMiddleware, async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { @@ -37,10 +40,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { ctx.response.body = responseBody; return; } - // `reached` is reported separately because a failed fetch still yields a - // usable stub (hostname only). Without the flag the create form cannot tell - // "this page has no preview" from "this link is dead", and shows the same - // bare card for both. const { ok, content } = await tryFetchRichContent(url); const responseBody: APIResponse<LinkPreview> = { success: true, @@ -49,7 +48,10 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -/** Answers with a proxied image, sandboxed so it can't run as a document. */ +/** + * Answers with a proxied image. It is sent from this site's origin, so it is + * sandboxed in case a browser opens it as a document. + */ function sendImage( ctx: { response: { headers: Headers; body?: unknown } }, bytes: Uint8Array, @@ -65,8 +67,8 @@ function sendImage( ctx.response.body = bytes; } -// Proxies an external image through the server so HTTP thumbnail URLs don't -// trigger mixed-content blocks when the frontend is served over HTTPS. +// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS +// page won't load, or when its host's hotlink protection refused the browser. previewRouter.get("/api/proxy-image", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { diff --git a/api/routes/search.ts b/api/routes/search.ts index a30092fb..fe125c37 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -1,9 +1,11 @@ /** - * The search route. + * Searches dumps, users and playlists at once, for the client's `Search` page. + * Only the dumps are paged. Private dumps and playlists are found only by + * their owner. * * - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the - * matching dumps with their total, and up to 20 matching users and playlists; - * private dumps and playlists only the caller's own. + * matching dumps with their total, and up to 20 matching users and + * playlists. A blank `q` finds nothing. * * @module */ @@ -17,7 +19,10 @@ import { searchPlaylists } from "../services/playlist-service.ts"; import type { APIResponse } from "../../shared/api.ts"; import type { SearchResults } from "../../shared/search.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/search" }); router.get("/", async (ctx) => { diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 1498def7..c1f1e69b 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -1,7 +1,9 @@ /** - * The route serving dumps' thumbnails, generated with ffmpeg where needed. + * Serves dumps' thumbnails to the cards and pages that show a file dump, which + * build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are + * generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`. * - * - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's + * - `GET /api/thumbnails/:dumpId?v=&token=` — public; a private dump's * thumbnail needs its poster's media `token`. Serves the dump's custom * thumbnail, else one made from its upload: a still of a video, or a * scaled-down copy of an image (the image itself when none can be made); 404 @@ -23,16 +25,18 @@ import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/thumbnails" }); /** - * Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on - * every uncached thumbnail request cost a process per card. + * Holds whether ffmpeg runs, probed once per process: probing on every uncached + * thumbnail request cost a process per card. */ let ffmpegProbe: Promise<boolean> | undefined; -/** Checks whether ffmpeg is available. */ function ffmpegAvailable(): Promise<boolean> { ffmpegProbe ??= (async () => { try { @@ -50,21 +54,23 @@ function ffmpegAvailable(): Promise<boolean> { } /** - * The container formats ffmpeg may read a video as. The upload's type is only - * what the uploader claimed, and ffmpeg probes the real format from the bytes — - * a "video" could be an HLS or concat playlist pointing ffmpeg at local files - * or internal URLs. Read nothing but the file itself, and only as the kind of - * container it's meant to be. + * Lists the container formats ffmpeg may read a video as. The upload's type is + * only what the uploader claimed, and ffmpeg guesses the format from the bytes, + * so a "video" could be an HLS or concat playlist pointing it at local files or + * internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads + * nothing but the file, and only as a video. */ const VIDEO_FORMATS = "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc"; -/** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */ +/** + * Lists the formats ffmpeg may read an image as, for the reason given at + * {@link VIDEO_FORMATS}. + */ const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; /** - * The image types the feed shows through a scaled-down preview, and the - * preview's format. GIFs keep their animation (served as they are); anything - * else has no preview to make. + * Maps the image types that get a scaled-down preview to the preview's format. + * A GIF is served as it is, to keep its animation. */ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = { "image/jpeg": "jpg", @@ -75,13 +81,13 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = { }; /** - * The width of an image preview: comfortably covers the widest feed card on a - * high-density screen, a fraction of a phone photo's bytes. + * Caps an image preview's width. It covers the widest feed card on a + * high-density screen, at a fraction of a phone photo's bytes. */ const PREVIEW_WIDTH = 960; /** - * Runs ffmpeg with `args` into `outPath`. + * Runs ffmpeg with `args`, writing to `outPath`. * * @throws {Error} When ffmpeg fails. */ @@ -121,7 +127,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> { ], outPath); } -/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */ +/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */ function imagePreview(srcPath: string, outPath: string): Promise<void> { return runFfmpeg([ "-format_whitelist", @@ -141,15 +147,14 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> { } /** - * The generations under way, by output path: one ffmpeg run per output, so a - * feed full of cards asking for the same new still at once waits on a single - * generation. + * Holds the generations under way, by output path. A feed full of cards asking + * for the same new still at once then waits on a single ffmpeg run. */ const inFlight = new Map<string, Promise<void>>(); /** - * Makes `outPath` with `generate` unless it exists; false when it can't be - * made (no ffmpeg, or ffmpeg failed). + * Makes `outPath` with `generate` unless it exists. Resolves false when it + * can't be made, because ffmpeg is missing or failed. */ async function ensureGenerated( outPath: string, @@ -168,20 +173,22 @@ async function ensureGenerated( router.get("/:dumpId", async (ctx) => { const { dumpId } = ctx.params; + // The id names files on disk: nothing but an id's characters gets through. if (!/^[0-9a-f-]{36}$/.test(dumpId)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } - // Token via query param (media elements can't send headers) — authorizes the - // owner's private dumps and gates the custom-thumbnail branch below too. + // Media elements can't send an Authorization header, so the viewer's media + // token comes in the query string. This privacy check covers the custom + // thumbnail too, since getDumpThumbnailInfo makes none. const token = ctx.request.url.searchParams.get("token"); const viewerId = token ? await authenticateMedia(token) : null; const dump = getDump(dumpId, viewerId ?? undefined); - // The URL may carry a media token; keep it out of anyone else's logs. + // The URL may carry a media token, which must not leak through a Referer. ctx.response.headers.set("Referrer-Policy", "no-referrer"); - // A private dump's thumbnail must not sit in a shared cache. The app's URLs - // carry the file's version (see dumpFileVersion): what's generated from the - // file is cached for good under the current one, and revalidated otherwise. + // What is generated from the file is cached for good under a URL naming its + // current version (dumpFileVersion), and revalidated under any other. A + // private dump's thumbnail must not sit in a shared cache. const scope = dump.isPrivate ? "private" : "public"; const generatedCache = ctx.request.url.searchParams.get("v") === dumpFileVersion(dump) @@ -190,8 +197,8 @@ router.get("/:dumpId", async (ctx) => { const customThumb = getDumpThumbnailInfo(dumpId); if (customThumb) { - // Replaceable without the URL changing: always revalidated (a 304 when - // unchanged). + // Replacing it doesn't change the file's version, so it is always + // revalidated. const served = await serveUploadedFile( ctx, `${THUMBNAILS_DIR}/${customThumb.id}-custom`, diff --git a/api/routes/users.ts b/api/routes/users.ts index 755206ed..c36d1d9d 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -1,6 +1,7 @@ /** - * The user and account routes. Public profiles never carry the password hash or - * the email. + * Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles, + * with what each user posted, upvoted, follows and invited. A profile sent to + * anyone but its user goes through `toPublicUser`, which drops the email. * * - `POST /api/users/register` — public, with a valid invite. Creates an * account, sends the welcome email, and answers the user and their tokens. @@ -106,15 +107,18 @@ import { getFollowedUsersByUser, } from "../services/follow-service.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router({ prefix: "/api/users" }); -/** Login attempts per account: a burst of 10, then one every 30s. */ +/** Allows a burst of 10 login attempts per account, then one every 30s. */ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; /** - * Issues `user` a session token, plus the media-only token that goes in file - * and thumbnail URLs (see {@link createMediaToken}). + * Returns a new session token and media token for `user`, as sign-up, sign-in + * and a password change answer them. */ async function issueTokens(user: User): Promise<AuthTokens> { return { @@ -138,8 +142,8 @@ router.post("/register", async (ctx) => { ); } - // Claim the invite up front (throws 404/409 if bad) and hand it back if the - // account can't be created, so one invite can't open two accounts. + // Claimed before the async account creation, so that one invite can't open + // two accounts, and handed back if the creation fails. const inviterId = claimInvite(body.inviteToken); let user; @@ -150,7 +154,7 @@ router.post("/register", async (ctx) => { throw err; } - // Send welcome email (fire-and-forget) + // Not awaited: a slow or failing SMTP server mustn't hold up the sign-up. if (isEmailEnabled()) { const emailMarkdown = WELCOME_EMAIL_BODY .replaceAll("{{username}}", user.username) @@ -255,6 +259,8 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); +// Answers the same whatever happens, so it can't tell which addresses have an +// account. router.post("/request-password-reset", async (ctx) => { const body: unknown = await ctx.request.body.json(); const email = fieldOf(body, "email"); @@ -339,10 +345,9 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { "Invalid request", ); } - // Only the profile fields. The role is assigned by an admin, the password - // changes through /me/change-password (which asks for the current one), and - // the username is fixed — a token alone must not be able to change any of - // them. + // Only the profile fields: a stolen token alone must not change the role, + // the password (which needs the current one) or the username (which is + // fixed). const updated = await updateUser(ctx.state.user.userId, { description: body.description, email: body.email, @@ -370,8 +375,7 @@ router.patch( "Invalid role", ); } - // Disallow changing your own role: prevents an admin from accidentally - // locking themselves out, and self-promotion is meaningless anyway. + // So that an admin can't demote themselves by mistake and leave no admin. if (ctx.params.userId === ctx.state.user.userId) { throw new APIException( APIErrorCode.VALIDATION_ERROR, diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 0dd85c92..358a3f40 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -1,11 +1,12 @@ /** - * The WebSocket route, and the handling of what clients send over it. + * Opens the WebSocket that `WSProvider` keeps for each tab, and handles what + * the client sends over it. Voting, comment likes and every chat write go + * through here, not through HTTP. The messages are typed in `shared/ws.ts`, + * and `services/ws-service.ts` sends the broadcasts. * - * - `GET /ws?token=` — public; a session `token` signs the socket in, and an - * invalid one leaves it anonymous. Opens the socket that carries presence, - * live updates, votes, comment likes and the chat (see - * `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403 - * from an origin not in {@link ALLOWED_ORIGINS}. + * - `GET /ws?token=`, public. A valid session `token` signs the socket in, and + * an invalid one leaves it anonymous. Answers 403 to an origin not in + * {@link ALLOWED_ORIGINS}, and 426 to a request that isn't an upgrade. * * @module */ @@ -62,10 +63,17 @@ import type { } from "../../shared/ws.ts"; import type { User } from "../model/user.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const router: Router = new Router(); -/** Checks whether a socket may be opened from `origin`. */ +/** + * Checks whether a socket may be opened from `origin`. Browsers don't apply + * CORS to WebSockets, so this is what stops another site from opening one with + * its visitors' tokens. + */ function isAllowedOrigin(origin: string): boolean { return ALLOWED_ORIGINS.includes(origin); } @@ -82,9 +90,8 @@ router.get("/ws", async (ctx) => { return; } - // authenticate() refuses a token whose user is gone or which predates a - // password change, and reads the live role from the DB. Such a socket is - // simply anonymous; the client's next API call gets the 401. + // A revoked token (deleted user, changed password) opens an anonymous + // socket. The client signs out on its next API call, which gets a 401. const token = ctx.request.url.searchParams.get("token"); const authPayload = token ? await authenticate(token) : null; let user: User | undefined; @@ -104,9 +111,11 @@ router.get("/ws", async (ctx) => { avatarMime: user?.avatarMime, }; - // Use addEventListener — more reliable than onopen= with Deno.serve + // addEventListener, since `onopen =` proved unreliable under Deno.serve. socket.addEventListener("open", () => { register(client); + // The new socket is registered, so it gets this presence_update too, + // before its welcome. broadcastPresence(); try { @@ -141,12 +150,16 @@ router.get("/ws", async (ctx) => { socket.addEventListener("close", () => { unregister(client); broadcastPresence(); - // If they were mid-compose, drop them from the typing set right away. + // The sweep only sees registered clients, so it would never clear this one. if (client.typingAt) broadcastTyping(); }); }); -/** Parses one message from a client and dispatches it by `type`. */ +/** + * Parses one message from a client and dispatches it by `type`. A message that + * isn't JSON, or whose `type` is unknown, is ignored. Its fields are still + * untrusted, so each handler checks their types. + */ function handleMessage(client: WsClient, data: unknown): void { if (typeof data !== "string") return; let parsed: unknown; @@ -155,8 +168,6 @@ function handleMessage(client: WsClient, data: unknown): void { } catch { return; } - // Anything but an object with a string `type` (`null`, a number, an array) - // is ignored rather than read from. if ( typeof parsed !== "object" || parsed === null || typeof (parsed as { type?: unknown }).type !== "string" @@ -187,7 +198,7 @@ function handleMessage(client: WsClient, data: unknown): void { handleChatSend(client, msg.body, msg.replyToId, msg.nonce); break; case "chat_typing": - // Typing is cosmetic: past the limit it's just dropped. + // Typing is cosmetic: past the limit it's dropped, with no error. if ( client.userId && takeToken(`typing:${client.userId}`, TYPING_LIMIT) @@ -210,14 +221,15 @@ function handleMessage(client: WsClient, data: unknown): void { } } -/** Sends a message only while the socket is open: `send` on a closing socket throws. */ +/** Sends `data` unless the socket is closing, where `send` would throw. */ function send(socket: WebSocket, data: ServerToClientMessage): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data)); } /** - * Casts or removes the client's upvote, acknowledges it, and broadcasts the new - * count. + * Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and + * broadcasts `votes_update`. A failure goes back as an `error` naming the dump + * and the action, so the client can undo its optimistic vote at once. */ function handleVote( client: WsClient, @@ -244,8 +256,7 @@ function handleVote( voteCount, }); - // Only a private dump's owner can vote on it, so they're its whole - // audience. + // Only a private dump's owner can see it, and so vote on it. broadcastVoteUpdate( dumpId, voteCount, @@ -260,8 +271,8 @@ function handleVote( } /** - * Casts or removes the client's like on a comment, acknowledges it, and - * broadcasts the new count. + * Handles `comment_like_cast` and `comment_like_remove`, as + * {@link handleVote} does votes. */ function handleCommentLike( client: WsClient, @@ -302,20 +313,23 @@ function handleCommentLike( } /** - * Chat writes per user. Sending, editing and deleting share one bucket: a - * burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a - * flood. + * Limits each user's chat writes, sends, edits and deletes together, to a burst + * of 8, then one every 1.5 s. The buckets are per user, so more tabs don't buy + * more. */ const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 }; -/** Typing signals per user. */ const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 }; /** - * Read markers per user. They are cheap for the client to send often; past - * this they're dropped, and the next one catches up. + * Limits each user's `chat_read`. One over the limit is dropped, and the next + * one catches up, since a marker only moves forward. */ const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 }; -/** Reports a failed chat write to the client, with the nonce of its pending copy. */ +/** + * Sends a chat-scoped `error`. With the `nonce` of a `chat_send`, the chatbox + * marks that pending message failed. Without one, it shows `message` as a + * notice. + */ function sendChatError( client: WsClient, message: string, @@ -325,8 +339,8 @@ function sendChatError( } /** - * Validates the common preconditions of a chat write: signed in and under the - * rate limit. Returns the user id, or undefined after reporting why not. + * Returns the id of the user behind a chat write, or undefined after sending + * the error when they are signed out or over {@link CHAT_WRITE_LIMIT}. */ function chatWriter(client: WsClient, nonce?: string): string | undefined { if (!client.userId) { @@ -344,7 +358,9 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined { return client.userId; } -/** Returns `value` when it is a string of at most `maxLength`, else undefined. */ +/** + * Returns `value` when it is a string of at most `maxLength`, else undefined. + */ function optionalString(value: unknown, maxLength: number): string | undefined { return typeof value === "string" && value.length <= maxLength ? value @@ -352,8 +368,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined { } /** - * Posts a chat message, broadcasts it, and notifies the @mentioned users not - * already reading the chat. + * Handles `chat_send`. It stores the message, broadcasts it, and notifies the + * @mentioned users who aren't reading the chat. */ function handleChatSend( client: WsClient, @@ -375,14 +391,10 @@ function handleChatSend( body, optionalString(replyToId, 64), ); - // Sending a message ends composition — clear the typing flag before the - // message broadcast so the indicator doesn't linger next to it. + // Before the broadcast, so the typing indicator doesn't linger beside the + // message. setClientTyping(client, false); - // Broadcast to everyone including the sender, whose pending copy the nonce - // retires. broadcastChatMessage(message, nonce); - // Skip mention notifications for users who already have the chat open — - // they've seen the message live. notifyMentions( userId, message.body, @@ -402,10 +414,9 @@ function handleChatSend( } /** - * Checks whether the client's user may moderate the chat. Reads the role from - * the DB on every check rather than trusting the one captured at connect: a - * demoted moderator loses the power right away, not whenever their socket next - * reconnects. + * Checks whether the client's user may edit and delete others' chat messages. + * The role is read from the database, not from the socket's connect, so that a + * demoted moderator loses the right at once. */ function canModerateChat(client: WsClient): boolean { if (!client.userId) return false; @@ -417,7 +428,10 @@ function canModerateChat(client: WsClient): boolean { return !!client.role && hasPermission(client.role, "chat:moderate"); } -/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */ +/** + * Handles `chat_edit`. It stores the new body, broadcasts it, and notifies the + * users the edit newly @mentions. + */ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { const userId = chatWriter(client); if (!userId) return; @@ -434,8 +448,9 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { canModerateChat(client), ); broadcastChatMessageUpdated(message); - // A newly added @mention in an edit should still notify (deduped per - // message+user), skipping anyone already reading the chat. + // Mentions already notified for this message are deduplicated, so only + // the new ones notify. After a moderator's edit, they come from the + // moderator. notifyMentions( userId, message.body, @@ -453,7 +468,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { } } -/** Deletes a chat message and broadcasts it. */ +/** Handles `chat_delete`. It deletes the message and broadcasts its id. */ function handleChatDelete(client: WsClient, id: unknown): void { const userId = chatWriter(client); if (!userId) return; @@ -474,8 +489,9 @@ function handleChatDelete(client: WsClient, id: unknown): void { } /** - * Moves the user's read marker and tells all their sockets, so every tab and - * device drops its badge together. + * Handles `chat_read`. It moves the user's read marker and sends + * `chat_read_update` to all their sockets, so every tab and device updates its + * badge together. */ function handleChatRead(client: WsClient, at: unknown): void { if (!client.userId || typeof at !== "string") return; @@ -488,7 +504,8 @@ function handleChatRead(client: WsClient, at: unknown): void { unread: state.unread, }); } catch { - // A malformed marker changes nothing; there's nothing to report. + // A malformed marker changes nothing, and the client doesn't wait for an + // answer. } } diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts index 33c37842..b9c234a8 100644 --- a/api/routes/youtube.ts +++ b/api/routes/youtube.ts @@ -1,8 +1,10 @@ /** - * The YouTube route, for the player's tracklist. + * Serves the titles and lengths of a YouTube playlist's entries, which the + * global player lists beside the embed. On any failure it shows the embed + * without a tracklist. * - * - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id - * to its tracklist (titles and lengths). + * - `GET /api/youtube/playlist?list=` — public. Answers the playlist's + * tracklist. * * @module */ @@ -12,11 +14,13 @@ import type { APIResponse } from "../../shared/api.ts"; import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import type { YouTubePlaylist } from "../../shared/player.ts"; -/** The router serving the endpoints the module doc lists. */ +/** + * Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware. + * An `APIException` thrown by a handler becomes the JSON error response there. + */ const youtubeRouter: Router = new Router(); -// Public, like /api/bandcamp/tracks: it only reads a public page. The client -// treats any failure as "show the embed without a tracklist". +// Public, like /api/bandcamp/tracks: it only reads a public page. youtubeRouter.get("/api/youtube/playlist", async (ctx) => { const listId = ctx.request.url.searchParams.get("list") ?? ""; const responseBody: APIResponse<YouTubePlaylist> = { diff --git a/api/scripts/generate-icons.ts b/api/scripts/generate-icons.ts index 5704781f..bfc03bcb 100644 --- a/api/scripts/generate-icons.ts +++ b/api/scripts/generate-icons.ts @@ -1,12 +1,10 @@ /** - * Generates the favicon set and manifest from the runtime `GERBEUR_SITE_EMOJI`. - * Runs before the server starts, so changing the emoji only requires a restart - * — no rebuild. - * - * The output directory is passed as the first argument (relative to cwd), and - * defaults to `dist`: - * - prod (`deno task start` / `serve`): `dist` — what the Oak server serves - * - dev (`deno task dev`): `public` — what the Vite server serves + * Draws the favicons and writes the manifest from {@link SITE_EMOJI} and + * {@link OG_SITE_NAME}, so changing them needs a restart and no rebuild. + * `deno task icons <dir>` runs it before the server starts, into the directory + * the frontend is served from, relative to the working directory. That is + * dist/ for `start` and `serve`, and public/, which Vite serves, for `dev`. + * It does nothing when the directory doesn't exist. * * @module */ diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index f8df9265..463ddd7b 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -1,7 +1,7 @@ /** - * Images pasted into Markdown bodies (chat, comments, descriptions): their - * records, and binding them to the resource whose body links them, so the - * startup purge keeps them. + * Records the images pasted into Markdown bodies, and binds each one to the + * resource whose body links it. `routes/attachments.ts` stores and serves the + * files; the services that save a body call {@link linkAttachments}. * * @module */ @@ -10,7 +10,11 @@ import { db, transaction } from "../db/connection.ts"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; -/** Records the upload of attachment `id`, not yet bound to any resource. */ +/** + * Records attachment `id` once `POST /api/attachments` has written its file. + * Until a saved body links it, the startup purge deletes it after + * `ORPHANED_ATTACHMENTS_RETENTION_HOURS`. + */ export function createAttachment(id: string, mime: string): void { db.prepare( "INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)", @@ -18,7 +22,8 @@ export function createAttachment(id: string, mime: string): void { } /** - * Returns attachment `attachmentId`'s MIME type. + * Returns attachment `attachmentId`'s MIME type, for + * `GET /api/attachments/:attachmentId` to serve its file with. * * @throws {APIException} 404 when there is no such attachment. */ @@ -38,14 +43,15 @@ export function getAttachment(attachmentId: string): { mime: string } { return { mime: row.mime }; } -/** An attachment URL in a Markdown body, capturing its id. */ const ATTACHMENT_RE = /\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; /** - * Binds the unowned attachments whose URLs appear in `text` to - * `resourceId`. Only updates rows where resource_id IS NULL so a user cannot - * claim attachments that already belong to another resource. + * Binds the attachments whose URLs appear in `text` to `resourceId`, so the + * startup purge keeps them. It is called whenever a dump, comment, chat + * message, playlist or profile description is saved. An attachment already + * bound stays with its resource, so pasting another's URL claims nothing, and + * removing the link from a body never unbinds it. */ export function linkAttachments(text: string, resourceId: string): void { const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index 1d517295..4a7909d2 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -1,6 +1,7 @@ /** - * Backlinks between dumps: which dumps a dump's description and comments link - * to, and the dumps related to one through them. + * 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. * * @module */ @@ -15,21 +16,19 @@ import { import { UUID_RE } from "../lib/slugify.ts"; /** - * An http(s) URL in free text — stops at whitespace or markdown-link - * delimiters (so `[text](url)` and `<url>` don't swallow the closing char). + * Matches an http(s) URL in Markdown. It stops at the delimiters of + * `[text](url)` and `<url>`, which would otherwise end up in the URL. */ const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; /** - * A link to another dump's page on this same site (e.g. pasted from - * the address bar) — host-agnostic, since the configured public URL can differ - * between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, - * ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links - * still resolve to the same target dump). + * Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what + * follows it, so an `/edit` or `#comment-x` link counts too. The host isn't + * checked, since the site's public URL differs between setups. */ const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; -/** Returns `url` as the URL parser normalises it, or null when it isn't one. */ +/** Returns `url` as the URL parser normalizes it, or null when it isn't one. */ function canonicalize(url: string): string | null { try { return new URL(url).toString(); @@ -38,7 +37,7 @@ function canonicalize(url: string): string | null { } } -/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */ +/** Returns the id of the dump with id or slug `idOrSlug`, or null. */ function resolveDumpPathId(idOrSlug: string): string | null { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) @@ -47,11 +46,11 @@ function resolveDumpPathId(idOrSlug: string): string | null { } /** - * Re-derives the backlinks contributed by one text source (a dump's own - * `comment` field, or a single comment's `body`) and persist them. Always - * deletes the source's previous contribution first, so editing/clearing the - * text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`, - * which are purely additive. + * 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 + * counts when it is a dump's page on this site or the exact URL a dump was + * posted with. The dump and comment services call it on every save, and with + * an empty `text` when a comment is deleted. */ export function relinkBacklinks( sourceType: "dump_comment" | "comment", @@ -88,7 +87,7 @@ export function relinkBacklinks( for (const row of rows) targetIds.add(row.id); } - targetIds.delete(fromDumpId); // no self-links + targetIds.delete(fromDumpId); transaction(() => { db.prepare( @@ -107,7 +106,8 @@ export function relinkBacklinks( /** * Returns the dumps linked to or from `dumpId`, newest first and at most 20, - * without the private ones the requesting user can't see. + * without the private ones the requesting user can't see. It doesn't check + * `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first. */ export function getRelatedDumps( dumpId: string, diff --git a/api/services/category-service.ts b/api/services/category-service.ts index f5bbab43..ffc5f560 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -1,5 +1,7 @@ /** - * Categories: managing them, and which dumps belong to which. + * Stores the categories and which dumps belong to them. The category routes + * manage and list them, the dump service files each dump it writes under its + * categories, and every service that lists dumps fills in their `categoryIds`. * * @module */ @@ -23,7 +25,10 @@ import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { VALIDATION } from "../../shared/validation.ts"; -/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */ +/** + * Throws a 400 unless `slug` is well-formed, short enough and not reserved, + * with a message for each case. + */ function validateSlug(slug: string): void { if (!isValidSlugFormat(slug)) { throw new APIException( @@ -48,7 +53,6 @@ function validateSlug(slug: string): void { } } -/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */ function requireName(name: string): string { const trimmed = name.trim(); if (!trimmed) { @@ -65,8 +69,9 @@ function requireName(name: string): string { } /** - * Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending - * field, and anything else as is. + * Rethrows a UNIQUE violation from SQLite as a 409 naming the slug or name + * that is taken, and any other error as is. SQLite's message names the + * column, so this matches on it. */ function uniqueConflict(err: unknown, slug: string, name: string): never { const msg = err instanceof Error ? err.message : String(err); @@ -88,7 +93,7 @@ function uniqueConflict(err: unknown, slug: string, name: string): never { } /** - * Returns category `id`. + * Returns category `id`, for {@link updateCategory}. * * @throws {APIException} 404 when there is no such category. */ @@ -101,7 +106,7 @@ export function getCategoryById(id: string): Category { } /** - * Returns the category at `slug`. + * Returns the category at `slug`, for `GET /api/categories/:slug`. * * @throws {APIException} 404 when there is no such category. */ @@ -113,7 +118,10 @@ export function getCategoryBySlug(slug: string): Category { return categoryRowToApi(row); } -/** Returns the id of the category at `slug`, or undefined when there is none. */ +/** + * Returns the id of the category at `slug`, or undefined when there is none. + * The follows routes use it for their `?category=` feed filter. + */ export function getCategoryIdBySlug(slug: string): string | undefined { const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); return row && typeof (row as { id?: unknown }).id === "string" @@ -121,7 +129,9 @@ export function getCategoryIdBySlug(slug: string): string | undefined { : undefined; } -/** Returns every category, by position then slug. */ +/** + * Returns every category, by position then slug, for `GET /api/categories`. + */ export function listCategories(): Category[] { const rows = db.prepare( `SELECT * FROM categories ORDER BY position ASC, slug ASC;`, @@ -130,7 +140,8 @@ export function listCategories(): Category[] { } /** - * Creates a category. + * Creates a category, for `POST /api/categories`. The name is stored trimmed, + * and the position defaults to 0. * * @throws {APIException} 400 when the slug or name is invalid, 409 when * either is taken. @@ -166,7 +177,8 @@ export function createCategory(req: CreateCategoryRequest): Category { } /** - * Applies `req` to category `id`. + * Applies `req` to category `id`, for `PUT /api/categories/:id`, and returns + * the category as it now is. Fields `req` leaves out keep their value. * * @throws {APIException} 404 when there is no such category, 400 when the new * slug or name is invalid, 409 when either is taken. @@ -211,7 +223,8 @@ export function updateCategory( } /** - * Deletes category `id`; its dumps stay, outside it. + * Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and + * lose it from their `categoryIds`. * * @throws {APIException} 404 when there is no such category. */ @@ -223,9 +236,9 @@ export function deleteCategory(id: string): void { } /** - * Checks that every id names an existing category. Callers that write - * anything else first (a dump row, an uploaded file) run this before those - * writes, so a bad id can't leave them half-done. + * Checks that every id in `categoryIds` names a category. The dump service + * runs it before it writes a dump row or stores an uploaded file, so a bad id + * fails the request before anything is written. * * @throws {APIException} 400 when an id names no category. */ @@ -248,8 +261,9 @@ export function assertCategoriesExist(categoryIds: string[]): void { } /** - * Replaces the set of categories a dump belongs to. An empty list clears all - * category assignments. + * Replaces the categories of dump `dumpId` with `categoryIds`, duplicates + * dropped. An empty list takes the dump out of every category. Called inside + * the dump service's transaction, it joins that one. * * @throws {APIException} 400 when an id names no category. */ @@ -271,7 +285,10 @@ export function setDumpCategories( }); } -/** Fills `categoryIds` on each dump with a single batched lookup. */ +/** + * Sets `categoryIds` on each of `dumps` in place, in one query. The dump row + * doesn't carry them, so every service that returns dumps calls this first. + */ export function attachCategoryIds(dumps: Dump[]): void { if (dumps.length === 0) return; const ids = dumps.map((d) => d.id); @@ -292,8 +309,9 @@ export function attachCategoryIds(dumps: Dump[]): void { } /** - * Returns a page of the dumps in the category at `slug`, newest first, with - * the requesting user's private ones. + * Returns a page of the dumps in the category at `slug`, newest first, for + * `GET /api/categories/:slug/dumps`. Private dumps are left out, except + * `requestingUserId`'s own. * * @throws {APIException} 404 when there is no such category. */ diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index 02105d51..2589e9fd 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -1,5 +1,9 @@ /** - * The site-wide chat: messages, their history, and each user's read marker. + * Stores the site-wide chat's messages and each user's read marker. + * + * The WebSocket route (`routes/ws.ts`) writes messages and moves read markers, + * then broadcasts the result. `routes/chat.ts` reads the history, a page at a + * time. * * @module */ @@ -12,36 +16,32 @@ import { db } from "../db/connection.ts"; import { linkAttachments } from "./attachment-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; -/** How many messages a page holds when the caller names no limit. */ const DEFAULT_LIMIT = 50; -/** The most messages a page may hold. */ const MAX_LIMIT = 100; /** - * The length cap of the replied-to preview snippet, so a reply to a long - * message doesn't ship the whole 2000-char body over the wire for every reply. - * This is raw markdown: the client strips it to text before showing a line of - * it, so it's cut generously enough that an image URL or link doesn't eat it - * all. + * How much of the replied-to message a reply carries. The client shows one line + * of it, once stripped of its Markdown, so the cut leaves room for a long link + * or image URL before the text. */ const REPLY_SNIPPET_LEN = 300; -/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */ +/** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */ const SELECT_COLS = `m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id, u.username as author_username, u.avatar_mime as author_avatar_mime, ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`; /** - * The common joins: author (required) plus the optional replied-to message and - * its author (LEFT — null when there's no reply or the target was deleted). + * Joins a message to its author, and to the message it replies to and that + * message's author. Those two are left joins: a reply outlives its target. */ const FROM_JOINS = `FROM chat_messages m JOIN users u ON m.user_id = u.id LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id LEFT JOIN users ru ON rm.user_id = ru.id`; -/** Returns message `id`, or throws a 404. */ +/** Returns message `id` as the broadcast sends it, or throws a 404. */ function fetchMessage(id: string): ChatMessage { const row = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, @@ -53,8 +53,9 @@ function fetchMessage(id: string): ChatMessage { } /** - * Posts a message from `userId`, replying to `replyToId` when that message - * exists, and binds its attachments. + * Stores a message from `userId` on a `chat_send`, and claims the pasted images + * it links to. A `replyToId` naming no message is dropped, and the message is + * posted as no reply. The caller broadcasts the result. * * @throws {APIException} 400 when the body is blank or longer than * `VALIDATION.CHAT_MESSAGE_MAX`. @@ -79,8 +80,6 @@ export function createChatMessage( `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, ); } - // Only store the reply target if it actually exists — drop dangling ids so a - // message never references a phantom parent. let replyTo: string | null = null; if (replyToId) { const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get( @@ -93,13 +92,12 @@ export function createChatMessage( db.prepare( `INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`, ).run(id, userId, trimmed, createdAt, replyTo); - // Claim any pasted/dropped image attachments so the startup cleanup doesn't - // purge them as orphans (mirrors dumps/comments/playlists). + // Unclaimed attachments are deleted as orphans at startup. linkAttachments(trimmed, id); return fetchMessage(id); } -/** Returns the author of message `id`, or throws a 404. */ +/** Returns the id of message `id`'s author, or throws a 404. */ function getMessageOwner(id: string): string { const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get( id, @@ -111,8 +109,9 @@ function getMessageOwner(id: string): string { } /** - * Replaces a message's body, as its author or a moderator (`canModerate`), and - * binds any new attachment. + * Replaces a message's body on a `chat_edit`, and claims any image the new body + * links to. `canModerate` lets a moderator edit anyone's message. The caller + * broadcasts the result. * * @throws {APIException} 404 when there is no such message, 401 when the user * is neither its author nor a moderator, 400 when the body is blank or too @@ -149,13 +148,13 @@ export function updateChatMessage( } db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) .run(trimmed, new Date().toISOString(), id); - // An edit may introduce a freshly pasted image; claim its attachment too. linkAttachments(trimmed, id); return fetchMessage(id); } /** - * Deletes a message, as its author or a moderator (`canModerate`). + * Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete + * anyone's message. Replies to it keep their `replyToId`, without its preview. * * @throws {APIException} 404 when there is no such message, 401 when the user * is neither its author nor a moderator. @@ -165,7 +164,7 @@ export function deleteChatMessage( requestingUserId: string, canModerate: boolean, ): void { - const ownerId = getMessageOwner(id); // 404s if the message doesn't exist + const ownerId = getMessageOwner(id); if (ownerId !== requestingUserId && !canModerate) { throw new APIException( APIErrorCode.UNAUTHORIZED, @@ -177,21 +176,23 @@ export function deleteChatMessage( } /** - * A position in the chat stream. Messages are ordered by (createdAt, id), so - * two sharing a millisecond still have a strict order and a page boundary - * between them can't skip either. + * Passed by `routes/chat.ts` to page through the history, from the message the + * client already has at one end. Messages are ordered by `createdAt`, then by + * id, so a page boundary between two messages sent in the same millisecond + * skips neither. */ export interface ChatCursor { - /** The `created_at` of the message the cursor sits at. */ + /** An ISO timestamp, compared as a string with `created_at`. */ createdAt: string; /** - * The id of that message. Omitted: the cursor sits before (for `after`) or - * after (for `before`) every message sharing that timestamp. + * Without it, the cursor takes in every message at `createdAt`: they are + * all older for {@link getRecentChatMessages} and all newer for + * {@link getChatMessagesAfter}. */ id?: string; } -/** Converts query rows to messages. */ +/** Converts query rows to messages, or throws a 500 if one doesn't parse. */ function toMessages(rows: unknown[]): ChatMessage[] { if (!rows.every(isChatMessageRow)) { throw new APIException( @@ -203,15 +204,15 @@ function toMessages(rows: unknown[]): ChatMessage[] { return rows.map(chatMessageRowToApi); } -/** Clamps a page size to [1, {@link MAX_LIMIT}]. */ function capLimit(limit: number): number { return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); } /** - * Returns up to `limit` most recent messages in ascending (display) order. - * When `before` is given, returns the page of older messages immediately - * preceding it — used for "load older" history. + * Returns the latest `limit` messages, or with `before` the `limit` messages + * just older than it, oldest first either way. `limit` is clamped to 1–100. + * + * @throws {APIException} 500 when a row doesn't parse as a message. */ export function getRecentChatMessages( limit = DEFAULT_LIMIT, @@ -226,7 +227,7 @@ export function getRecentChatMessages( ).all( before.createdAt, before.createdAt, - // No id: nothing at that timestamp sorts below the empty string. + // Nothing sorts below "", so no message at that timestamp is taken. before.id ?? "", capped, ) @@ -234,14 +235,15 @@ export function getRecentChatMessages( `SELECT ${SELECT_COLS} ${FROM_JOINS} ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ).all(capped); - // Newest-first from SQL; reverse to ascending for display. return toMessages(rows).reverse(); } /** - * Returns up to `limit` messages following `after`, in ascending order — used - * to resync the chat after a reconnect. Without an id the cursor is inclusive - * of its timestamp. + * Returns the first `limit` messages after `after`, oldest first. The chatbox + * uses it to fetch what it missed while the socket was down. `limit` is + * clamped to 1–100. + * + * @throws {APIException} 500 when a row doesn't parse as a message. */ export function getChatMessagesAfter( after: ChatCursor, @@ -255,17 +257,21 @@ export function getChatMessagesAfter( return toMessages(rows); } -/** How far a user has read the chat. */ +/** + * Returned to the WebSocket route by {@link getChatReadState} and + * {@link markChatRead}. The route sends it in `welcome` and in + * `chat_read_update`, and the client shows `unread` on the chat's badge. + */ export interface ChatReadState { - /** The `created_at` of the newest message the user has seen (ISO). */ + /** The `created_at` of the newest message the user has read. */ readAt: string; - /** Messages from other people since then. */ + /** Counts only the messages of other users after `readAt`. */ unread: number; } /** - * Returns how far `userId` has read the chat; a user who never opened it has - * read up to their registration. + * Returns how far `userId` has read the chat. A user who never opened it has + * read up to their registration, so the history from before it isn't unread. * * @throws {APIException} 404 when there is no such user. */ @@ -284,9 +290,10 @@ export function getChatReadState(userId: string): ChatReadState { } /** - * Moves the user's read marker forward to `at` (a message's created_at) — - * never backward, so a lagging tab can't undo a newer tab's read, and never - * past the newest message, so a bogus timestamp can't pre-read the future. + * Moves `userId`'s read marker to `at`, a message's `created_at`, on a + * `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's + * read. It never passes the newest message, so a timestamp from the future + * can't mark messages read before they are sent. * * @throws {APIException} 400 when `at` isn't a date, 404 when there is no * such user. @@ -303,8 +310,7 @@ export function markChatRead(userId: string, at: string): ChatReadState { const newest = db.prepare( `SELECT MAX(created_at) AS newest FROM chat_messages;`, ).get() as { newest: string | null }; - // No messages yet: there's nothing to have read, and no newest message to - // cap a bogus far-future marker at — which could never be moved back. + // With no message to cap it at, a marker from the future would stay stuck. if (!newest.newest) return getChatReadState(userId); const iso = parsed.toISOString(); const target = iso > newest.newest ? newest.newest : iso; diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index 15998a8a..6dd34fea 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -1,5 +1,9 @@ /** - * Likes on comments. + * Stores likes on comments and keeps each comment's `like_count` in step with + * them. Likes have no HTTP endpoint to cast or remove. The WebSocket route + * (`routes/ws.ts`) calls this module on `comment_like_cast` and + * `comment_like_remove` messages, then broadcasts the new count. The comment + * routes use it only to list a comment's likers. * * @module */ @@ -11,16 +15,19 @@ import { db } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; -/** A comment's likes after a like was cast or removed. */ +/** + * Returned to the WebSocket route by {@link castCommentLike} and + * {@link removeCommentLike}. The route acks the liker with `likeCount`, then + * broadcasts it to every client, or to the liker's alone when the comment's + * dump `isPrivate`. + */ export interface LikeResult { - /** The comment's like count now. */ likeCount: number; - /** Whether the comment's dump is private: its likes go to the dump's owner only. */ isPrivate: boolean; } /** - * Returns whether the comment's dump is private, or throws a 404 when the user + * Returns whether the comment's dump is private, or throws a 404 when `userId` * can't see it. */ function commentVisibility(commentId: string, userId: string): boolean { @@ -35,7 +42,8 @@ function commentVisibility(commentId: string, userId: string): boolean { } /** - * Records `userId`'s like on a comment and notifies its author. + * Records `userId`'s like on a comment and notifies the comment's author. The + * notification is best-effort: if it fails, the like still counts. * * @throws {APIException} 404 when the comment doesn't exist or sits on someone * else's private dump, 409 when the user already likes it. @@ -72,7 +80,7 @@ export function castCommentLike( } throw err; } - // Notification is best-effort — must not prevent like_ack from being sent + // The like is committed: a failed notification must not report it as failed. try { notifyCommentOwnerLike(userId, commentId); } catch { /* ignore */ } @@ -80,8 +88,9 @@ export function castCommentLike( } /** - * Takes back `userId`'s like on a comment. No visibility check: taking back a - * like is harmless, and it's the user's own row. + * Takes back `userId`'s like on a comment. It doesn't check that the user can + * still see the comment, since a like on a dump that has gone private is still + * theirs to take back. * * @throws {APIException} 404 when the user doesn't like the comment. */ @@ -118,7 +127,11 @@ export function removeCommentLike( } } -/** Returns the ids of every comment `userId` likes. */ +/** + * Returns the ids of the comments `userId` likes. The WebSocket route sends + * them in its `welcome` message, and the client shows those like buttons + * pressed. + */ export function getUserCommentLikes(userId: string): string[] { const rows = db.prepare( `SELECT comment_id FROM comment_likes WHERE user_id = ?;`, @@ -126,7 +139,13 @@ export function getUserCommentLikes(userId: string): string[] { return rows.map((r) => r.comment_id); } -/** Returns a page of the users who like a comment, latest first. */ +/** + * Returns a page of the users who like a comment, latest like first, for + * `GET /api/comments/:commentId/likers`. It doesn't check that the caller may + * see the comment: the route does that first, with `getDump`. + * + * @throws {APIException} 500 when a row doesn't parse as a user. + */ export function getCommentLikers( commentId: string, page: number, diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 7b98bdfe..d1d76f14 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -1,5 +1,7 @@ /** - * Comments on dumps, threaded by `parent_id`. + * Stores the comments on dumps, threaded by `parent_id`. The comment routes + * call it and broadcast what it returns. It leaves checking that the caller + * may see the dump to them. * * @module */ @@ -17,12 +19,11 @@ import { import { linkAttachments } from "./attachment-service.ts"; import { relinkBacklinks } from "./backlink-service.ts"; -/** The columns of a full `CommentRow`, from `comments c` and `users u`. */ +/** The select list of a `CommentRow`, from `comments c` joined to `users u`. */ const SELECT_COLS = `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count, u.username as author_username, u.avatar_mime as author_avatar_mime`; -/** Returns comment `commentId`, or throws a 404. */ function fetchComment(commentId: string): Comment { const row = db.prepare( `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, @@ -41,7 +42,8 @@ function fetchComment(commentId: string): Comment { } /** - * Returns the id of the dump comment `commentId` is posted under. + * Returns the id of the dump comment `commentId` is posted under. The likers + * route uses it to check that the caller may see that dump. * * @throws {APIException} 404 when there is no such comment. */ @@ -56,8 +58,11 @@ export function getCommentDumpId(commentId: string): string { } /** - * Returns every comment on dump `dumpId`, deleted ones included, oldest - * first. + * Returns every comment on dump `dumpId`, oldest first, for + * `GET /api/dumps/:dumpId/comments`. Deleted comments are included, emptied, + * so the client can keep their replies in place. + * + * @throws {APIException} 500 when a row doesn't parse as a comment. */ export function getComments(dumpId: string): Comment[] { const rows = db.prepare( @@ -75,9 +80,10 @@ export function getComments(dumpId: string): Comment[] { } /** - * Posts a comment on dump `dumpId`, replying to `parentId` if given; notifies - * the dump's poster and anyone @mentioned, and binds its attachments and - * backlinks. + * Posts a comment on dump `dumpId`, replying to `parentId` if given, for + * `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the + * users it mentions, and links the comment's attachments and backlinks. + * `parentId` isn't checked to be a comment on the same dump. */ export function createComment( dumpId: string, @@ -109,8 +115,10 @@ export function createComment( } /** - * Replaces a comment's body, as its author or a moderator (`canModerate`); - * notifies anyone newly @mentioned, and rebinds its attachments and backlinks. + * Replaces a comment's body, for `PATCH /api/comments/:commentId`. + * `canModerate` lets someone other than the author do it. It notifies the users + * it mentions for the first time and relinks the attachments and backlinks. The + * route broadcasts the comment unless `isPrivate`. * * @throws {APIException} 404 when there is no such comment, 400 when it is * deleted, 401 when the user is neither its author nor a moderator. @@ -169,9 +177,10 @@ export function updateComment( } /** - * Deletes a comment, as its author or a moderator (`canModerate`): its body is - * emptied and its backlinks dropped, but the row stays so its replies keep - * their place. + * Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets + * someone other than the author do it. The body is emptied and the backlinks + * dropped, but the row stays so its replies keep their place. The route + * broadcasts the deletion unless `isPrivate`. * * @throws {APIException} 404 when there is no such comment, 401 when the user * is neither its author nor a moderator. diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 7ebb84fc..a651a451 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -1,9 +1,10 @@ /** - * Dumps: posting links and files, editing and deleting them, and reading them - * as each viewer may see them. A private dump exists only for its poster. + * Posts, edits and deletes dumps, and reads them as each viewer may see them. + * `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail + * routes to read dumps. It broadcasts the changes to public dumps over the + * WebSocket. * - * The functions that change a dump don't check who asks: the routes do, before - * calling them. + * The functions that change a dump don't check who asks: the routes do first. * * @module */ @@ -59,16 +60,14 @@ import { setDumpCategories, } from "./category-service.ts"; -/** Checks whether a file dump may have type `mime`. */ function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || DUMP_ALLOWED_MIME_TYPES.has(mime); } /** - * Throws a 400 when a file dump's title or comment is too long. The JSON routes - * validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart - * ones (file create and replace) arrive here unchecked. + * Throws a 400 when a file dump's title or comment is too long. The multipart + * routes that post and replace a file don't check them, unlike the JSON ones. */ function assertFileDumpText(title?: string, comment?: string): void { if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) { @@ -87,7 +86,7 @@ function assertFileDumpText(title?: string, comment?: string): void { } } -/** Returns the title a link gets without a better one: its host. */ +/** Returns a link's host name, the title it gets when its page has none. */ function titleFromUrl(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); @@ -97,8 +96,10 @@ function titleFromUrl(url: string): string { } /** - * Posts a link as `userId`, with the preview fetched from it; a public one is - * announced and notified to the poster's followers. + * Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched + * from the page. A public dump is broadcast as `dump_created` and notified to + * the poster's followers. Anyone @mentioned in the note is notified even when + * the dump is private. * * @throws {APIException} 400 when the URL isn't a public http(s) URL or a * category doesn't exist. @@ -171,8 +172,9 @@ export async function createUrlDump( } /** - * Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public - * one is announced and notified to the poster's followers. + * Posts an uploaded file as `userId`, for `POST /api/dumps`, and stores it in + * `DUMPS_DIR` under the dump's id. It is announced and notified as + * {@link createUrlDump} does. * * @throws {APIException} 400 when the title or comment is too long, the file's * type isn't allowed, it exceeds 50 MB, or a category doesn't exist. @@ -234,7 +236,7 @@ export async function createFileDump( setDumpCategories(dumpId, dedupedCategoryIds); }); } catch (err) { - // Roll back the file if DB insert fails + // Leave no file behind when the write or the insert fails. await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); throw err; } @@ -268,9 +270,10 @@ export async function createFileDump( } /** - * Returns a file dump's version as the app puts it in file and thumbnail URLs - * (`?v=`): it changes whenever the file can have. Mirrored by - * `dumpFileVersion` in src/utils/shared/urls.ts. + * Returns a file dump's version, which the app's file and thumbnail URLs carry + * as `?v=`. It changes whenever the file may have, so the file and thumbnail + * routes cache a URL naming the current one for good. The frontend's + * `dumpFileVersion` must compute the same. */ export function dumpFileVersion( dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">, @@ -281,9 +284,9 @@ export function dumpFileVersion( } /** - * Removes the files generated from a dump's upload (see - * api/routes/thumbnails.ts) when it's replaced or gone, so they're made again - * from the new one. + * Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's + * upload, once the upload is replaced or deleted. The route makes them again + * from a new one. */ async function removeGeneratedThumbnails(dumpId: string): Promise<void> { await Promise.all( @@ -294,8 +297,8 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> { } /** - * Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy - * check: use only when ownership is already enforced. + * Returns the dump with id or slug `idOrSlug`, without its categories, or + * throws a 404. It doesn't check privacy: the routes do, with {@link getDump}. */ function fetchDump(idOrSlug: string): Dump { const row = UUID_RE.test(idOrSlug) @@ -310,9 +313,11 @@ function fetchDump(idOrSlug: string): Dump { } /** - * Returns a dump, by id or slug, as `requestingUserId` may see it. + * Returns a dump, by id or slug, as `requestingUserId` may see it. The routes + * resolve every `:dumpId` with it before acting on the dump. * - * @throws {APIException} 404 when there is no such dump, or it is private and not theirs. + * @throws {APIException} 404 when there is no such dump, or it is private and + * not theirs. */ export function getDump(dumpId: string, requestingUserId?: string): Dump { const dump = fetchDump(dumpId); @@ -324,9 +329,9 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { } /** - * Returns a page of the dumps whose title, comment, or previewed title or - * description contains `query`, newest first, and how many match in all: the - * public ones, and the requesting user's own. + * Returns a page of the dumps `requestingUserId` may see whose title, note, or + * previewed title or description contains `query`, newest first, with how many + * match in all. `GET /api/search` calls it. */ export function searchDumps( query: string, @@ -366,8 +371,10 @@ export function searchDumps( } /** - * Returns a page of the dumps, newest first: the public ones, and the - * requesting user's own. + * Returns a page of the dumps `requestingUserId` may see, newest first, for the + * index feed (`GET /api/dumps`). + * + * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function listDumps( page: number, @@ -375,7 +382,6 @@ export function listDumps( requestingUserId?: string, ): Page<Dump> { const offset = (page - 1) * limit; - // Show public dumps + the requesting user's own private dumps const rows = requestingUserId ? db.prepare( `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`, @@ -398,16 +404,11 @@ export function listDumps( } /** - * Returns the dumps that already point at the same thing as `url`, newest - * first. - * - * Matched on the canonical key rather than the raw URL, so a link reposted with - * different tracking parameters, scheme, `www.`, or (for YouTube) in a - * different share shape still counts as the same dump. Private dumps are - * visible only to their owner, exactly as everywhere else. - * - * Returns a small display-only projection: this feeds a hint on the create - * form, not a feed. + * Returns the dumps `requestingUserId` may see that were posted with the same + * link as `url`, newest first, for the create form's duplicate notice + * (`GET /api/dumps/by-url`). Links are compared in their canonical form, so + * tracking parameters, the scheme, `www.` or a YouTube share format don't hide + * a repost. */ export function findDumpsByUrl( url: string, @@ -447,12 +448,14 @@ export function findDumpsByUrl( } /** - * Applies `request` to a dump: its title, comment, visibility and categories, - * and a URL dump's link (fetching the new preview). Announces the change as the - * visibility requires, and notifies anyone newly @mentioned. + * Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its + * preview fetched and gives the dump its page's title, unless `request` sets + * one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a + * `dump_deleted` when it turns private and a `dump_created` when it turns + * public. Anyone @mentioned in the note is notified. * - * @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL - * or a category doesn't exist. + * @throws {APIException} 404 when there is no such dump, 400 when a new URL + * isn't a public http(s) URL or a category doesn't exist. */ export async function updateDump( dumpId: string, @@ -465,7 +468,6 @@ export async function updateDump( const now = new Date(); - // File dumps: title, comment and isPrivate are editable if (dump.kind === "file") { const title = request.title?.trim() || dump.title; const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; @@ -516,7 +518,6 @@ export async function updateDump( return updatedDump; } - // URL dumps const newUrl = request.url ?? dump.url!; if (!isValidHttpUrl(newUrl)) { @@ -596,15 +597,15 @@ export async function updateDump( } /** - * Replaces a file dump's upload, and with it its comment and optionally its - * title, categories and visibility. `isPrivate` left out, visibility stays as - * it is; it is taken here rather than by a second request so a new file and a - * change of visibility land together. The old file is restored if the update - * fails. + * Replaces a file dump's upload and its comment, for + * `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left + * out stays as it is. Visibility is taken here so that a new file and a change + * of visibility land together. The old file is put back if the database update + * fails, and clients are told as {@link updateDump} tells them. * - * @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or - * comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a - * category doesn't exist. + * @throws {APIException} 404 when there is no such dump, 400 when it isn't a + * file dump, the title or comment is too long, the file's type isn't allowed, + * it exceeds 50 MB, or a category doesn't exist. */ export async function replaceFileDump( dumpId: string, @@ -681,7 +682,6 @@ export async function replaceFileDump( } }); } catch (err) { - // Put the previous file back on DB failure if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {}); else await Deno.remove(filePath).catch(() => {}); throw err; @@ -718,8 +718,11 @@ export async function replaceFileDump( } /** - * Returns a page of `userId`'s dumps, newest first, the private ones only with - * `includePrivate`. + * Returns a page of `userId`'s dumps, newest first, for + * `GET /api/users/:username/dumps`. The route sets `includePrivate` when users + * ask for their own. + * + * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getDumpsByUser( userId: string, @@ -745,8 +748,11 @@ export function getDumpsByUser( } /** - * Returns a page of the dumps `userId` upvoted, latest vote first — their own - * private ones only when they are the one asking. + * Returns a page of the dumps `userId` upvoted, latest vote first, for + * `GET /api/users/:username/votes`. Their own private dumps are included only + * when they are the one asking. + * + * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getVotedDumpsByUser( userId: string, @@ -759,7 +765,6 @@ export function getVotedDumpsByUser( let rows: unknown[]; if (requestingUserId === userId) { - // Own profile: include private dumps the user themselves voted on and owns. rows = db.prepare( `SELECT ${dumpCols} FROM dumps d @@ -790,10 +795,11 @@ export function getVotedDumpsByUser( } /** - * Fetches a URL dump's preview again and replaces its title and preview. + * Fetches a URL dump's preview again, bypassing the cache, and replaces its + * title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`. * - * @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the - * page can't be reached. + * @throws {APIException} 404 when there is no such dump, 400 when it isn't a + * URL dump, 502 when the page can't be reached. */ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { const dump = fetchDump(dumpId); @@ -806,10 +812,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { ); } - // A failed fetch yields a stub with no title and no thumbnail. Writing that - // over a dump that already has good metadata would silently destroy it and - // rename the dump to its bare hostname, so bail out instead — the caller - // surfaces the error and nothing is lost. + // A failed fetch yields a stub with no title or thumbnail. Written over the + // dump's metadata, it would rename the dump to its host name. const { ok, content: richContent } = await tryFetchRichContent(dump.url, { fresh: true, }); @@ -835,7 +839,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { } /** - * Returns a dump's id and custom thumbnail type, or undefined without one. + * Returns a dump's id and custom thumbnail type, or undefined without one, for + * the thumbnail route. It doesn't check privacy: the route calls + * {@link getDump} first. * * @throws {APIException} 404 when there is no such dump. */ @@ -849,7 +855,9 @@ export function getDumpThumbnailInfo( } /** - * Records a dump's new custom thumbnail, once its file is in place. + * Records that a dump has a custom thumbnail of type `mime`, for + * `PUT /api/dumps/:dumpId/thumbnail`. The route writes the file after this + * call returns. * * @throws {APIException} 404 when there is no such dump. */ @@ -865,7 +873,8 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump { } /** - * Removes a dump's custom thumbnail, and its file. + * Removes a dump's custom thumbnail and its file, for + * `DELETE /api/dumps/:dumpId/thumbnail`. * * @throws {APIException} 404 when there is no such dump. */ @@ -880,7 +889,8 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> { } /** - * Deletes a dump, its files and thumbnails, and announces it. + * Deletes a dump with its files and thumbnails, for + * `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`. * * @throws {APIException} 404 when there is no such dump. */ diff --git a/api/services/email-service.ts b/api/services/email-service.ts index e0e38e6a..49ac893f 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -1,5 +1,7 @@ /** - * Sending mail through the SMTP server in {@link SMTPS_URL}. + * Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset. + * Its two callers, the welcome email at sign-up and the password reset link, + * check {@link isEmailEnabled} first and don't wait for delivery. * * @module */ @@ -8,42 +10,43 @@ import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; import { SMTPS_URL } from "../config.ts"; -/** One address, a comma-separated list of them, or an array. */ +/** + * One address, a comma-separated list of them, or an array, as nodemailer + * takes them. + */ export type EmailRecipient = string | string[]; -/** A message to send. It needs a recipient, a subject, and a text or HTML body. */ +/** + * Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or + * HTML body; the callers send the same Markdown as both, rendered for `html`. + */ export interface EmailMessage { - /** The sender's address. */ from: string; - /** The recipients. */ to: EmailRecipient; - /** The subject line. */ subject: string; - /** The plain-text body. */ text?: string; - /** The HTML body. */ html?: string; - /** The carbon-copy recipients. */ cc?: EmailRecipient; - /** The blind-carbon-copy recipients. */ bcc?: EmailRecipient; - /** The address replies go to. */ replyTo?: string; } -/** What the SMTP server said about a sent message. */ +/** + * Returned by {@link sendEmail} once the SMTP server has taken the message. + * No caller reads it yet. + */ export interface EmailSendResult { - /** The message's `Message-ID`. */ messageId: string; - /** The recipients the server accepted. */ accepted: string[]; - /** The recipients the server rejected. */ rejected: string[]; } let transporter: Transporter | null = null; -/** Checks whether an SMTP server is configured. */ +/** + * Checks whether an SMTP server is configured. Without one, sign-up sends no + * welcome email and a password reset request does nothing. + */ export function isEmailEnabled(): boolean { return SMTPS_URL.length > 0; } @@ -76,8 +79,8 @@ function getTransporter(): Transporter { } /** - * Checks the connection to the SMTP server. Resolves false when email is - * disabled. + * Checks the connection to the SMTP server, or resolves false when email is + * disabled. Nothing calls it yet. * * @throws {Error} When the server can't be reached or refuses the login. */ @@ -88,7 +91,8 @@ export async function verifyEmailTransport(): Promise<boolean> { } /** - * Sends `message`. + * Sends `message` and resolves once the SMTP server has accepted it. The + * transport is created on the first send. * * @throws {Error} When email is disabled, the message lacks a recipient, * subject or body, or the server refuses it. diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index c85a5d7c..5803a76c 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -1,5 +1,7 @@ /** - * Following users and playlists, and the feeds made of what one follows. + * Stores who follows which users and playlists, and builds the feeds and + * lists made from it. `routes/follows.ts` serves the caller's own, and + * `routes/users.ts` what any user follows. * * @module */ @@ -25,11 +27,9 @@ import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { attachCategoryIds } from "./category-service.ts"; -// ── Follow / unfollow a user ────────────────────────────────────────────────── - /** - * Makes `followerId` follow `followedUserId`, and notifies them the first - * time. Following again changes nothing. + * Makes `followerId` follow `followedUserId`, and notifies the followed user. + * Following again changes nothing. * * @throws {APIException} 400 when a user tries to follow themselves. */ @@ -41,6 +41,7 @@ export function followUser(followerId: string, followedUserId: string): void { "Cannot follow yourself", ); } + // The unique index answers "already following" without a race. let isNew = true; try { db.prepare( @@ -70,11 +71,10 @@ export function unfollowUser(followerId: string, followedUserId: string): void { ).run(followerId, followedUserId); } -// ── Follow / unfollow a playlist ───────────────────────────────────────────── - /** - * Makes `followerId` follow a public playlist, and notifies its owner the - * first time. Following again changes nothing. + * Makes `followerId` follow a public playlist, and notifies its owner. + * Following again changes nothing. A follow outlives the playlist going + * private, but the feeds and lists skip it while it is. * * @throws {APIException} 404 when there is no such playlist, 403 when it is * private. @@ -124,9 +124,12 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void { ).run(followerId, playlistId); } -// ── Follow status ───────────────────────────────────────────────────────────── - -/** Returns the ids of every user and playlist `followerId` follows. */ +/** + * Returns the ids of every user and playlist `followerId` follows, private + * playlists included, for `GET /api/follows/status`. + * + * @throws {APIException} 500 when a row doesn't parse. + */ export function getFollowStatus(followerId: string): FollowStatus { const rawUserRows = db.prepare( `SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at @@ -152,11 +155,12 @@ export function getFollowStatus(followerId: string): FollowStatus { }; } -// ── Followed-users feed ─────────────────────────────────────────────────────── - /** * Returns a page of the public dumps posted by the users `followerId` follows, - * newest first, optionally only those in `categoryId`. + * newest first, optionally only those in `categoryId`. It feeds the "users" + * half of the index's "followed" tab. + * + * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getFollowedUsersDumpFeed( followerId: string, @@ -166,7 +170,6 @@ export function getFollowedUsersDumpFeed( ): Page<Dump> { const offset = (page - 1) * limit; - // Optional category filter: join the membership table and constrain to it. const catJoin = categoryId ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" : ""; @@ -196,11 +199,13 @@ export function getFollowedUsersDumpFeed( return pageOf(items, limit); } -// ── Followed-playlists dump feed ────────────────────────────────────────────── - /** * Returns a page of the public dumps in the public playlists `followerId` - * follows, most recently added first, optionally only those in `categoryId`. + * follows, optionally only those in `categoryId`. It feeds the "playlists" + * half of the index's "followed" tab. A dump in several of them shows once, + * dated by its latest addition. + * + * @throws {APIException} 500 when a row doesn't parse as a dump. */ export function getFollowedPlaylistsDumpFeed( followerId: string, @@ -242,11 +247,11 @@ export function getFollowedPlaylistsDumpFeed( return pageOf(items, limit); } -// ── Followed playlists (as playlist objects) ────────────────────────────────── - /** * Returns a page of the public playlists `userId` follows, most recently - * followed first. + * followed first, for their profile and playlists pages. + * + * @throws {APIException} 500 when a row doesn't parse as a playlist. */ export function getFollowedPlaylistsByUser( userId: string, @@ -277,9 +282,13 @@ export function getFollowedPlaylistsByUser( return pageOf(rawRows.map(playlistRowToApi), limit); } -// ── Followed users (as user objects) ───────────────────────────────────────── - -/** Returns a page of the users `userId` follows, most recently followed first. */ +/** + * Returns a page of the users `userId` follows, most recently followed first, + * for their profile page. The users carry their password hash and email: pass + * them through `toPublicUser` before sending them. + * + * @throws {APIException} 500 when a row doesn't parse as a user. + */ export function getFollowedUsersByUser( userId: string, page: number, diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts index 007fa44c..5a919b95 100644 --- a/api/services/invite-service.ts +++ b/api/services/invite-service.ts @@ -1,5 +1,7 @@ /** - * Invites: making them, and checking and claiming one on registration. + * Makes invites, and checks and claims them at sign-up. An invite works once, + * for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and + * checks them, and `POST /api/users/register` claims one. * * @module */ @@ -11,15 +13,15 @@ import { db } from "../db/connection.ts"; import { isInviteRow } from "../model/invite.ts"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; -/** - * Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22 - * chars). - */ +/** Returns 128 random bits as 22 URL-safe characters. */ function generateInviteToken(): string { return randomBytes(16).toString("base64url"); } -/** Makes an invite from `inviterId` and returns its token. */ +/** + * Makes an invite from `inviterId` and returns its token, which the client + * puts in the invite link. + */ export function createInvite(inviterId: string): string { const token = generateInviteToken(); db.prepare( @@ -29,12 +31,9 @@ export function createInvite(inviterId: string): string { } /** - * Looks the token up, checks it has not been used and has not expired (same - * 7-day window as the startup cleanup), and returns the inviter's id. - * - * Tokens are opaque random strings stored as the row's primary key. Invites - * created before this scheme (full JWTs) are still stored verbatim in the - * token column, so they continue to validate by direct lookup. + * Returns the id of the inviter when invite `token` is unused and still in its + * window, which is the one after which `db/startup.ts` purges it. It doesn't + * claim the invite: sign-up goes through {@link claimInvite}. * * @throws {APIException} 404 when the invite doesn't exist or has expired, * 409 when it has been used. @@ -74,10 +73,9 @@ export function validateInvite(token: string): string { } /** - * Validates the token and marks it used in one step, returning the inviter's - * id. - * Claim before the (async) account creation, not after: checking first and - * marking later lets parallel registrations all pass the check on one invite. + * Marks invite `token` used and returns the inviter's id. Call it before the + * async account creation, and {@link releaseInvite} if that fails: checking + * first and marking after would let parallel sign-ups share one invite. * * @throws {APIException} 404 when the invite doesn't exist or has expired, * 409 when it has been used. @@ -97,7 +95,10 @@ export function claimInvite(token: string): string { return inviterId; } -/** Hands a claimed invite back when the account it was claimed for failed. */ +/** + * Makes a claimed invite usable again, when the sign-up that claimed it + * failed. + */ export function releaseInvite(token: string): void { db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token); } diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index f717d5cf..c4c167c0 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -1,7 +1,9 @@ /** - * Notifications: storing them, pushing each new one to its user's sockets, - * and the triggers that create them. A trigger passes a source key, so the same - * event never notifies a user twice. + * Stores notifications, pushes each new one to its user's sockets as + * `notification_created`, and reads them back for `routes/notifications.ts`. + * The services of follows, votes, likes, comments, dumps, playlists and the + * chat call its `notify…` triggers. Every trigger passes a source key, so the + * same event never notifies a user twice. * * @module */ @@ -23,11 +25,9 @@ import { type Page, pageOf } from "../lib/pagination.ts"; import { sendToUser } from "./ws-service.ts"; import { MENTION_RE } from "../../shared/mention.ts"; -// ── Core CRUD ───────────────────────────────────────────────────────────────── - /** * Stores a notification for `userId` and pushes it live. With a `sourceKey`, - * a second one for the same `(userId, sourceKey)` is a no-op. + * a second one for the same `(userId, sourceKey)` is neither stored nor sent. */ function createNotification( userId: string, @@ -43,7 +43,6 @@ function createNotification( let changes: number; if (sourceKey) { - // INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op const result = db.prepare( `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) VALUES (?, ?, ?, ?, 0, ?, ?);`, @@ -72,7 +71,12 @@ function createNotification( } } -/** Returns a page of `userId`'s notifications, newest first. */ +/** + * Returns a page of `userId`'s notifications, newest first, for + * `GET /api/notifications`. + * + * @throws {APIException} 500 when a row doesn't parse. + */ export function getNotificationsForUser( userId: string, page: number, @@ -93,7 +97,10 @@ export function getNotificationsForUser( return pageOf(rawRows.map(notificationRowToApi), limit); } -/** Returns how many of `userId`'s notifications are unread. */ +/** + * Returns how many of `userId`'s notifications are unread. The WebSocket route + * sends it in its `welcome` message, for the bell's count. + */ export function getUnreadCount(userId: string): number { const row = db.prepare( `SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`, @@ -101,23 +108,30 @@ export function getUnreadCount(userId: string): number { return row?.count ?? 0; } -/** Marks every notification of `userId` read. */ +/** + * Marks every notification of `userId` read, when they open the + * `/notifications` page. + */ export function markAllRead(userId: string): void { db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run( userId, ); } -/** Marks one of `userId`'s notifications read; someone else's is left alone. */ +/** + * Marks one of `userId`'s notifications read. An id that isn't theirs changes + * nothing, and says nothing. + */ export function markOneRead(notificationId: string, userId: string): void { db.prepare( `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, ).run(notificationId, userId); } -// ── Trigger helpers ─────────────────────────────────────────────────────────── - -/** Notifies `followedUserId` that `followerId` follows them. */ +/** + * Notifies `followedUserId` that `followerId` follows them, once however often + * they unfollow and follow again. `followUser` calls it. + */ export function notifyUserNewFollower( followerId: string, followedUserId: string, @@ -138,7 +152,8 @@ export function notifyUserNewFollower( /** * Notifies a playlist's owner that `followerId` follows it, unless they are - * the owner. + * the owner, and once however often they unfollow and follow again. + * `followPlaylist` calls it. */ export function notifyPlaylistOwnerNewFollower( followerId: string, @@ -168,7 +183,10 @@ export function notifyPlaylistOwnerNewFollower( ); } -/** Notifies everyone following `dumperId` that they posted a dump. */ +/** + * Notifies everyone following `dumperId` that they posted a dump. The dump + * service calls it for a public dump only, when it is created. + */ export function notifyUserFollowersNewDump( dumperId: string, dumpId: string, @@ -196,10 +214,8 @@ export function notifyUserFollowersNewDump( const createdAt = now.toISOString(); const sourceKey = `dump:${dumpId}`; - // Insert per follower so we can broadcast only the rows that were actually - // created (INSERT OR IGNORE skips followers who already have this dump's - // notification) and reuse each persisted id in the live event — otherwise a - // later refetch would dedupe against a mismatched id and show a duplicate. + // One insert per follower, so that only the rows actually stored are sent, + // each with its stored id: the client dedupes a refetch against it. const insert = db.prepare( `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) VALUES (?, ?, ?, ?, 0, ?, ?);`, @@ -236,7 +252,11 @@ export function notifyUserFollowersNewDump( }); } -/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */ +/** + * Notifies a dump's poster that `voterId` upvoted it, unless they are the + * poster, and once however often the vote is withdrawn and cast again. + * `castVote` calls it. + */ export function notifyDumpOwnerUpvote( voterId: string, dumpId: string, @@ -250,7 +270,7 @@ export function notifyDumpOwnerUpvote( ).get(dumpId) as { title: string; user_id: string } | undefined; if (!voterRow || !dumpRow) return; - if (voterId === dumpRow.user_id) return; // no self-notification + if (voterId === dumpRow.user_id) return; createNotification( dumpRow.user_id, @@ -267,10 +287,10 @@ export function notifyDumpOwnerUpvote( /** * Notifies every user @mentioned in `body`, except its author, that they were - * mentioned in the given comment, dump, playlist or chat message. Each is - * notified once per mention context, however often the body is edited. - * `skipUserId` spares a mentioned user (e.g. one already reading the chat - * live, for whom the persistent notification would be redundant). + * mentioned in the comment, dump, playlist or chat message `contextId`. Call + * it again after each edit: a user already notified for this context isn't + * notified twice. `skipUserId` spares a mentioned user; the chat uses it for + * those who have it open. */ export function notifyMentions( mentionerUserId: string, @@ -317,7 +337,7 @@ export function notifyMentions( /** * Notifies a dump's poster that `commenterId` commented on it, unless they are - * the poster. + * the poster. `createComment` calls it for replies too, but not on an edit. */ export function notifyDumpOwnerNewComment( commenterId: string, @@ -333,7 +353,7 @@ export function notifyDumpOwnerNewComment( ).get(dumpId) as { title: string; user_id: string } | undefined; if (!commenterRow || !dumpRow) return; - if (commenterId === dumpRow.user_id) return; // no self-notification + if (commenterId === dumpRow.user_id) return; createNotification( dumpRow.user_id, @@ -351,7 +371,7 @@ export function notifyDumpOwnerNewComment( /** * Notifies a comment's author that `likerId` likes it, unless they are the - * author. + * author, and once however often the like is withdrawn and given again. */ export function notifyCommentOwnerLike( likerId: string, @@ -369,7 +389,7 @@ export function notifyCommentOwnerLike( | undefined; if (!likerRow || !commentRow) return; - if (likerId === commentRow.user_id) return; // no self-notification + if (likerId === commentRow.user_id) return; createNotification( commentRow.user_id, @@ -385,7 +405,11 @@ export function notifyCommentOwnerLike( ); } -/** Notifies everyone following a playlist that a dump was added to it. */ +/** + * Notifies everyone following a playlist that a dump was added to it. The + * playlist service calls it only when both are public, since a follower can't + * open a private one. + */ export function notifyPlaylistFollowersNewDump( playlistId: string, playlistTitle: string, diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index d1d1bd6a..774c097a 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -1,6 +1,8 @@ /** - * Password resets by email: sending a reset link, and setting the new - * password with it. + * Emails password reset links and sets the new password when one is used. The + * users routes call it for `POST /api/users/request-password-reset` and + * `POST /api/users/reset-password`; the link opens the client's + * `/reset-password` page. * * @module */ @@ -20,16 +22,20 @@ import { VALIDATION } from "../../shared/validation.ts"; import { marked } from "marked"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; -/** How long a reset link works. */ +/** Must match the `1h` that `createPasswordResetToken` signs into the token. */ const RESET_TOKEN_TTL_HOURS = 1; -/** Per address: enough to retry a lost email, not enough to flood an inbox. */ +/** + * Applies per address. It allows a retry after a lost email, but not enough + * requests to flood an inbox. + */ const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; /** - * Looks up the user by email, creates a signed reset token, persists it, and - * sends the reset link. Always resolves without throwing so callers can return - * 200 unconditionally (no user enumeration). + * Emails a reset link to the account with address `email`, if there is one and + * email is configured. It says nothing of what it did, so that the route + * answers the same whether the address has an account or not. It sends at + * most 3 links in a row per address, then one every 10 minutes. */ export async function requestPasswordReset(email: string): Promise<void> { if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return; @@ -66,8 +72,8 @@ export async function requestPasswordReset(email: string): Promise<void> { } /** - * Verifies the token (JWT signature + expiry + DB record + not used), updates - * the password, and marks the token consumed. + * Sets `newPassword` for the user of a reset token and marks the token used. + * Through `updateUser`, this ends the user's other sessions. * * @throws {APIException} 400 when the password's length is out of bounds or * the token is invalid or expired, 404 when the token isn't on record or its @@ -109,8 +115,8 @@ export async function resetPassword( ); } - // Consume the link before the (async) password hash: checking used_at and - // marking it later would let parallel submissions all pass the check. + // Claim the link before the async hash: checking `used_at` first and + // marking it after would let parallel submissions all pass. const claimed = db.prepare( `UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`, ).run(new Date().toISOString(), token); @@ -125,6 +131,7 @@ export async function resetPassword( try { await updateUser(payload.userId, { password: newPassword }); } catch (err) { + // The password didn't change, so the link must keep working. db.prepare( `UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`, ) diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 0643dc35..38697c6f 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -1,7 +1,8 @@ /** - * Playlists: managing them and their dumps, and reading them as each viewer - * may see them. A playlist is public or private to its owner; its members are - * filtered per viewer (see {@link isMemberVisibleTo}). + * Stores playlists and the ordered dumps in them. The playlist routes call it, + * and so do the search, users and dumps routes to list playlists. Every write + * is broadcast over the WebSocket, and a viewer is only ever sent the dumps + * {@link isMemberVisibleTo} lets them see. * * @module */ @@ -42,16 +43,18 @@ import { } from "../lib/playlist-members.ts"; /** - * The columns of a `PlaylistRow` with its owner's name and dump count, - * and the `FROM playlists p` they come from. + * 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. */ const PLAYLIST_SELECT = `p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; /** - * Returns the playlist with id or slug `idOrSlug`, private or not: callers - * that show it check its visibility. + * Returns the playlist with id or slug `idOrSlug`, private or not. A caller + * that shows it to someone must check `isPublic` first, as the OG middleware + * does. * * @throws {APIException} 404 when there is no such playlist. */ @@ -66,8 +69,9 @@ export function getPlaylistById(idOrSlug: string): Playlist { } /** - * Creates a playlist owned by `userId`, notifies anyone @mentioned in its - * description, and announces it. + * Creates a playlist owned by `userId`, for `POST /api/playlists`. It notifies + * the users its description mentions, links the attachments it embeds, and + * broadcasts `playlist_created`. */ export function createPlaylist( req: CreatePlaylistRequest, @@ -107,9 +111,11 @@ export function createPlaylist( /** * Returns a playlist with the dumps `requestingUserId` may see in it, in - * order. + * order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a + * signed-out viewer. * - * @throws {APIException} 404 when there is no such playlist, or it is private and not theirs. + * @throws {APIException} 404 when there is no such playlist, or it is private + * and not theirs. */ export function getPlaylist( playlistId: string, @@ -141,7 +147,8 @@ export function getPlaylist( /** * Returns up to `limit` playlists whose title or description contains - * `query`, newest first: the public ones, and the requesting user's own. + * `query`, newest first, for `GET /api/search`. Private playlists are left + * out, except `requestingUserId`'s own. A blank `query` finds nothing. */ export function searchPlaylists( query: string, @@ -164,8 +171,9 @@ export function searchPlaylists( } /** - * Returns a page of `userId`'s playlists, newest first — the private ones only - * to `userId` themselves. + * Returns a page of `userId`'s playlists, newest first, for + * `GET /api/users/:username/playlists`. The private ones are listed only when + * `requestingUserId` is `userId`. */ export function listPlaylistsByUser( userId: string, @@ -185,11 +193,13 @@ export function listPlaylistsByUser( } /** - * Applies `req` to a playlist (its slug follows the title), as its owner or a - * moderator (`canModerate`); notifies anyone @mentioned in the description, - * and announces the change. + * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns + * it as it now is. The slug follows the title. `canModerate` lets someone + * other than the owner edit it. It notifies the users the description + * mentions, links its attachments and broadcasts `playlist_updated`. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator. */ export function updatePlaylist( playlistId: string, @@ -247,10 +257,11 @@ export function updatePlaylist( } /** - * Deletes a playlist, as its owner or a moderator (`canModerate`), and - * announces it. + * Deletes a playlist for `DELETE /api/playlists/:playlistId`, and broadcasts + * `playlist_deleted`. `canModerate` lets someone other than the owner do it. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator. */ export function deletePlaylist( playlistId: string, @@ -268,9 +279,12 @@ export function deletePlaylist( } /** - * Returns the playlist, if the requester may edit it. + * Returns the playlist if `requestingUserId` may edit it. The cover upload + * route checks this before it writes the file, then calls + * {@link setPlaylistImage}. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator. */ export function getEditablePlaylist( playlistId: string, @@ -285,10 +299,12 @@ export function getEditablePlaylist( } /** - * Records a playlist's new cover image and announces it. Call once the image - * file is in place: this is what announces it. + * Records the type of a playlist's new cover and broadcasts `playlist_updated`. + * The route must have written the file already, since clients fetch the cover + * as soon as they get the broadcast. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator. */ export function setPlaylistImage( playlistId: string, @@ -301,8 +317,8 @@ export function setPlaylistImage( requestingUserId, canModerate, ); - // A new cover is an edit: bumping updated_at also moves the `?v=` the - // client puts on the image URL, so a cached old cover isn't shown. + // updated_at is the `?v=` the client puts on the cover URL, so bumping it + // keeps browsers from showing the cached old cover. db.prepare( `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, ).run(imageMime, new Date().toISOString(), playlist.id); @@ -312,11 +328,15 @@ export function setPlaylistImage( } /** - * Puts a dump at the top of a playlist, announces the change, and notifies the - * followers of a public playlist when the dump is public. + * Puts a dump at the top of a playlist, for + * `POST /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts + * `playlist_dumps_updated`. The followers of a public playlist are notified + * when the dump is public. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 404 when the dump doesn't exist or is - * someone else's private dump, 409 when it is already in the playlist. + * @throws {APIException} 404 when there is no such playlist, or when the dump + * doesn't exist or is someone else's private dump, 403 when the user is + * neither the playlist's owner nor a moderator, 409 when the dump is already + * in it. */ export function addDumpToPlaylist( playlistId: string, @@ -330,8 +350,8 @@ export function addDumpToPlaylist( throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } - // Only a dump the requester can see — someone else's private dump would - // otherwise land in the playlist and its title in followers' notifications. + // Else someone else's private dump would land in the playlist, and its title + // in the followers' notifications. const dump = db.prepare( `SELECT title, is_private, user_id FROM dumps WHERE id = ?;`, ).get(dumpId) as @@ -367,7 +387,7 @@ export function addDumpToPlaylist( broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); - // Followers can't open a private dump, even the owner's own. + // Followers can't open a private dump, even one of the owner's own. if (playlist.isPublic && !dump.is_private) { notifyPlaylistFollowersNewDump( playlist.id, @@ -379,9 +399,12 @@ export function addDumpToPlaylist( } /** - * Takes a dump out of a playlist, if it is in it, and announces the change. + * Takes a dump out of a playlist, for + * `DELETE /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts + * `playlist_dumps_updated`. A dump that isn't in it is no error. * - * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + * @throws {APIException} 404 when there is no such playlist, 403 when the user + * is neither its owner nor a moderator. */ export function removeDumpFromPlaylist( playlistId: string, @@ -403,8 +426,9 @@ export function removeDumpFromPlaylist( } /** - * Puts the playlist's members the requester can see in the order of - * `dumpIds`, and announces the change. + * Puts the dumps of a playlist that the requester can see in the order of + * `dumpIds`, for `PUT /api/playlists/:playlistId/order`, and broadcasts + * `playlist_dumps_updated`. * * @throws {APIException} 404 when there is no such playlist, 403 when the user * is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the @@ -422,10 +446,9 @@ export function reorderPlaylist( throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } - // The requester orders what they can see — the playlist as GET gives it to - // them. A member hidden from them (someone else's dump that went private, - // or the owner's private dumps for a moderator) can't be named, so it - // keeps its slot and the visible ones are reordered around it. + // The requester can only name the dumps GET showed them. A hidden one + // (someone else's dump that went private, or the owner's private dump for a + // moderator) keeps its slot, and the visible ones are reordered around it. const members = getCurrentMembers(playlist.id); const visibleIds = members .filter((m) => isMemberVisibleTo(m, playlist, requestingUserId)) @@ -462,7 +485,10 @@ export function reorderPlaylist( /** * Returns each of `userId`'s playlists, newest first, and whether it holds - * dump `dumpId`. + * dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The + * client's add-to-playlist modal shows it. The playlists have no dump count. + * + * @throws {APIException} 500 when a row doesn't parse as a playlist. */ export function getPlaylistMembershipsForDump( dumpId: string, @@ -492,10 +518,10 @@ export function getPlaylistMembershipsForDump( } /** - * Returns the playlists a dump appears in — the "in collections" counterpart to - * backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for - * everyone, plus the requesting user's own private ones. Capped at 20 — a - * small, bounded list of supporting context. + * Returns up to 20 playlists that hold dump `dumpId`, newest first, for + * `GET /api/dumps/:dumpId/playlists`. Private playlists are left out, except + * `requestingUserId`'s own. The route checks first that the caller may see the + * dump. */ export function getPlaylistsForDump( dumpId: string, @@ -512,8 +538,9 @@ export function getPlaylistsForDump( } /** - * Returns a playlist's id, cover image type and visibility, or undefined - * without a cover. A private playlist's cover exists only for its owner. + * Returns what `GET /api/playlists/:playlistId/image` needs to serve a + * playlist's cover, or undefined when it has none. The route serves a private + * playlist's cover as private, so no shared cache keeps it. * * @throws {APIException} 404 when there is no such playlist, or it is private * and `requestingUserId` isn't its owner. @@ -535,7 +562,10 @@ export function getPlaylistImageInfo( : undefined; } -/** Returns a playlist's members, in order, with what their visibility takes. */ +/** + * Returns a playlist's dumps in order, all of them, for the callers to filter + * per viewer. + */ function getCurrentMembers(playlistId: string): PlaylistMember[] { const rows = db.prepare( `SELECT pd.dump_id, d.is_private, d.user_id diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 1247dae9..f882e241 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -1,5 +1,8 @@ /** - * Link previews: picks the provider for a URL and caches recent results. + * Fetches a link's preview through the provider that knows its site, and + * reuses it for a few minutes. `dump-service.ts` calls it when a link is + * posted, changed or refreshed, and `GET /api/preview` when the create form + * previews one. * * @module */ @@ -13,9 +16,9 @@ import { selfProvider } from "../remote/providers/self.ts"; import { genericProvider } from "../remote/providers/generic.ts"; /** - * The providers, in priority order. The first match wins. - * `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip). - * `genericProvider` must stay last — it always matches. + * Lists the providers in priority order: the first that matches a URL reads + * it. `selfProvider` reads this site's own pages from the database, and + * `genericProvider` must stay last, since it matches every URL. */ const providers: RichContentProvider[] = [ youtubeProvider, @@ -25,44 +28,51 @@ const providers: RichContentProvider[] = [ genericProvider, ]; -/** The outcome of fetching a link's preview. */ +/** + * Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports + * `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is + * false. + */ export interface FetchRichContentResult { /** * False when the page couldn't be reached at all, as opposed to reached and - * found to carry no metadata. Callers that already hold good metadata must - * not overwrite it with a failure stub. + * found to carry no metadata. A caller that already holds metadata must not + * overwrite it with the failure stub. */ ok: boolean; - /** The preview, or on failure a stub with just the URL and host. */ + /** + * On failure, a stub holding the URL and its host name. Missing only when + * the URL doesn't parse. + */ content?: RichContent; } /** How long a fetched preview is reused. */ const RECENT_TTL_MS = 5 * 60 * 1000; -/** The most previews kept; the oldest write goes first. */ +/** How many previews are kept. The oldest written goes first. */ const RECENT_MAX = 200; /** - * Previews fetched in the last few minutes, by URL. The create form previews a - * link and then posts it; without this the page was fetched twice, and the - * second fetch was the slow part of posting. Only successes are kept, and a - * metadata refresh (`fresh`) always goes to the page. + * Holds the previews fetched in the last few minutes, by URL. The create form + * previews a link and then posts it, and without this the second fetch of the + * page was the slow part of posting. Only successes are kept. */ const recent = new Map< string, { at: number; result: Promise<FetchRichContentResult> } >(); -/** Forgets every cached preview (for the tests). */ +/** + * Forgets every cached preview. The tests' `stubNetwork` calls it, since + * previews fetched from the previous stubbed network no longer apply. + */ export function clearRichContentCache(): void { recent.clear(); } /** - * Fetches the preview of `url`, reporting whether the fetch itself succeeded. - * A preview fetched in the last five minutes is reused unless `fresh` is set. - * - * On failure it still yields a minimal stub so a *new* dump has something - * displayable, but `ok: false` lets callers with existing metadata keep it. + * Fetches the preview of `url`, and says whether the page could be reached. A + * preview fetched in the last five minutes is reused unless `fresh` is set, as + * a metadata refresh does. It never rejects: a failure resolves to a stub. */ export function tryFetchRichContent( url: string, @@ -86,7 +96,10 @@ export function tryFetchRichContent( return result; } -/** Fetches the preview of `url` through the first provider that matches. */ +/** + * Fetches the preview of `url` through the first provider that matches, or + * makes the failure stub when the provider rejects. + */ async function fetchRichContentUncached( url: string, ): Promise<FetchRichContentResult> { @@ -110,7 +123,11 @@ async function fetchRichContentUncached( } } -/** Returns the preview of `url`, or a minimal stub when it can't be fetched. */ +/** + * Returns the preview of `url`, or a stub holding its host name when the page + * can't be reached. `createUrlDump` and `updateDump` use it for a new link, + * which has no earlier preview to keep. + */ export async function fetchRichContent( url: string, ): Promise<RichContent | undefined> { diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 516ab328..4148d700 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -1,5 +1,8 @@ /** - * Users: accounts, profiles, lookups and search, and the invite tree. + * Reads and writes the `users` table. Its `User`s carry the password hash and + * email, so a route strips them with `toPublicUser` or `toSelfUser` before + * answering. `routes/users.ts` is its main caller; `lib/auth.ts` checks every + * token against {@link getSessionState}. * * @module */ @@ -27,7 +30,7 @@ import { closeUserSockets, disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; import { linkAttachments } from "./attachment-service.ts"; -/** The query selecting full `UserRow`s from `users u`, with the inviter's name. */ +/** Selects full `UserRow`s from `users u`, with the inviter's name joined. */ const USER_SELECT = `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username @@ -35,7 +38,9 @@ const USER_SELECT = LEFT JOIN users i ON i.id = u.invited_by`; /** - * Creates a user with the `user` role, invited by `inviterId`. + * Creates a user with the `user` role, invited by `inviterId`, for + * `POST /api/users/register`. The caller has validated `request` and claimed + * the invite. * * @throws {APIException} 400 when the username is taken, 409 when the email * is. @@ -77,8 +82,8 @@ export async function createUser( request.email, ); } catch (err) { - // The check above ran before the hash yielded, so a parallel registration - // can still take the name first; the UNIQUE constraint is the real guard. + // A parallel sign-up can take the name while the hash is computed; the + // UNIQUE constraint is the real guard. Emails have no such constraint. if (err instanceof Error && err.message.includes("UNIQUE")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -100,7 +105,7 @@ export async function createUser( } /** - * Returns user `userId`. + * Returns user `userId`, with their hash and email. * * @throws {APIException} 404 when there is no such user. */ @@ -117,9 +122,9 @@ export function getUserById(userId: string): User { } /** - * Returns what a session token is checked against: the user's live role, and - * the moment older tokens stopped counting (their last password change). - * Undefined when the user is gone. + * Returns the user's live role and their last password change, before which + * their tokens don't count, or undefined when the user is gone. `lib/auth.ts` + * checks every session and media token against it. */ export function getSessionState( userId: string, @@ -136,7 +141,8 @@ export function getSessionState( } /** - * Returns the user named `username`. + * Returns the user named `username`, with their hash and email. The name + * matches case-sensitively. * * @throws {APIException} 404 when there is no such user. */ @@ -153,9 +159,9 @@ export function getUserByUsername(username: string): User { } /** - * Throws a 409 when another account than `exceptUserId` has `email`. Password - * resets are looked up by email, so an address can belong to one account only - * — otherwise which account a reset link resets is up to row order. + * Throws a 409 when an account other than `exceptUserId` has `email`, in any + * case. Password resets are looked up by email, so an address must belong to + * one account only. */ function assertEmailAvailable(email: string, exceptUserId?: string): void { const row = db.prepare( @@ -170,7 +176,10 @@ function assertEmailAvailable(email: string, exceptUserId?: string): void { } } -/** Returns the user whose email is `email`, or null. */ +/** + * Returns the user whose email is exactly `email`, or null, for + * `requestPasswordReset`. Unlike the uniqueness check, it is case-sensitive. + */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( `${USER_SELECT} WHERE u.email = ?`, @@ -181,8 +190,8 @@ export function getUserByEmail(email: string): User | null { } /** - * Returns up to `limit` users whose name starts with `query`, for @mention - * suggestions. + * Returns up to `limit` users whose name starts with `query`, for the editor's + * @mention suggestions (`GET /api/users/search`). An empty query returns none. */ export function searchUsers( query: string, @@ -204,8 +213,8 @@ export function searchUsers( } /** - * Returns up to `limit` users whose name or profile contains `query`, for the - * public search page: profiles only, never hashes or emails. + * Returns up to `limit` users whose name or description contains `query`, for + * the `/search` page (`GET /api/search`). They are already public profiles. */ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { if (!query.trim()) return []; @@ -216,7 +225,11 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); } -/** Returns every user. */ +/** + * Returns every user. Nothing calls it. + * + * @throws {APIException} 404 when a row doesn't parse as a user. + */ export function listUsers(): User[] { const userRows = db.prepare( `${USER_SELECT}`, @@ -230,8 +243,11 @@ export function listUsers(): User[] { } /** - * Applies `request` to user `userId`. A new password also ends every session - * opened before it and closes the user's sockets. + * Applies `request` to user `userId` and returns the updated user. It trusts + * `request`: the routes pick the fields a caller may change. A new password + * also ends every session opened before it and closes the user's sockets, so + * the caller must issue fresh tokens if the user is to stay signed in. A + * `description` of null keeps the current one. * * @throws {APIException} 404 when there is no such user, 409 when the new * email belongs to another account. @@ -275,7 +291,6 @@ export async function updateUser( throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } - // A new password ends every session opened with the old one. if (password) { db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( now.toISOString(), @@ -292,7 +307,9 @@ export async function updateUser( } /** - * Records that user `userId` now has an avatar of type `mime`. + * Records that user `userId` now has an avatar of type `mime`, once + * `POST /api/avatars/me` has written the file. It bumps `updated_at`, which + * the client uses to reload the image. * * @throws {APIException} 404 when there is no such user. */ @@ -307,8 +324,9 @@ export function updateUserAvatar(userId: string, mime: string): void { } /** - * Returns everyone `userId` invited, and everyone they invited in turn, down - * to ten levels, in registration order. + * Returns everyone `userId` invited, and everyone they invited in turn, eleven + * levels deep at most, in sign-up order, for the profile page's invite tree + * (`GET /api/users/:username/invitees`). */ export function getInviteTree(userId: string): Invitee[] { const rows = db.prepare(` @@ -341,7 +359,7 @@ export function getInviteTree(userId: string): Invitee[] { } /** - * Deletes user `userId` and disconnects their sockets. + * Deletes user `userId` and disconnects their sockets. Nothing calls it. * * @throws {APIException} 404 when there is no such user. */ diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 663b170a..b9da0575 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -1,5 +1,9 @@ /** - * Upvotes on dumps. + * Stores upvotes on dumps and keeps each dump's `vote_count` in step with them. + * + * Voting has no HTTP endpoint. The WebSocket route (`routes/ws.ts`) calls this + * module on `vote_cast` and `vote_remove` messages, then broadcasts the new + * count. `routes/dumps.ts` uses it only to list a dump's voters. * * @module */ @@ -11,11 +15,13 @@ import { db } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; -/** A dump's votes after a vote was cast or removed. */ +/** + * Returned to the WebSocket route by {@link castVote} and {@link removeVote}. + * The route acks the voter with `voteCount`, then broadcasts it to every + * client, or to the dump owner's alone when `isPrivate`. + */ export interface VoteResult { - /** The dump's vote count now. */ voteCount: number; - /** Whether the dump is private: its vote count goes to its owner only. */ isPrivate: boolean; } @@ -34,7 +40,8 @@ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } { } /** - * Records `userId`'s upvote on a dump and notifies its poster. + * Records `userId`'s upvote on a dump and notifies the dump's poster. The + * notification is best-effort: if it fails, the vote still counts. * * @throws {APIException} 404 when the dump doesn't exist or is someone else's * private dump, 409 when the user already voted for it. @@ -68,7 +75,7 @@ export function castVote(dumpId: string, userId: string): VoteResult { } throw err; } - // Notification is best-effort — must not prevent vote_ack from being sent + // The vote is committed: a failed notification must not report it as failed. try { notifyDumpOwnerUpvote(userId, dumpId); } catch { /* ignore */ } @@ -111,7 +118,10 @@ export function removeVote(dumpId: string, userId: string): VoteResult { } } -/** Returns the ids of every dump `userId` upvoted. */ +/** + * Returns the ids of the dumps `userId` upvoted. The WebSocket route sends them + * in its `welcome` message, and the client shows those vote buttons pressed. + */ export function getUserVotes(userId: string): string[] { const rows = db.prepare( `SELECT dump_id FROM votes WHERE user_id = ?;`, @@ -119,7 +129,13 @@ export function getUserVotes(userId: string): string[] { return rows.map((r) => r.dump_id); } -/** Returns a page of the users who upvoted a dump, latest first. */ +/** + * Returns a page of the users who upvoted a dump, latest vote first, for + * `GET /api/dumps/:dumpId/voters`. It doesn't check that the caller may see the + * dump: the route does that first, with `getDump`. + * + * @throws {APIException} 500 when a row doesn't parse as a user. + */ export function getDumpVoters( dumpId: string, page: number, diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 2ce766b1..58fef5d3 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,7 +1,12 @@ /** - * The connected WebSocket clients, and everything pushed to them: presence, - * live updates to dumps, comments, playlists and chat, typing indicators, and - * the keepalive that drops dead sockets. + * Keeps the set of open sockets and sends them the server's messages. + * + * The WebSocket route (`routes/ws.ts`) registers each socket and handles what + * it sends. The services and routes that change dumps, comments, playlists and + * users call the `broadcast*` functions here after the change is stored. A + * `broadcast*` function sends to everyone unless its doc names an audience, so + * its caller must not pass it anything private. The module also pings every + * socket and closes the ones that stop answering. * * @module */ @@ -17,45 +22,56 @@ import { type PlaylistMember, } from "../lib/playlist-members.ts"; -/** A connected socket, and what the server knows of its user. */ +/** + * Created by the WebSocket route for each socket it opens, and kept here until + * the socket closes. The user fields are read at connect and left undefined for + * an anonymous socket. + */ export interface WsClient { - /** The socket. */ socket: WebSocket; - /** The user's id; undefined for an anonymous socket. */ userId?: string; - /** The user's name. */ username?: string; - /** The user's role. */ - role?: Role; - /** The user's avatar type, undefined without one. */ - avatarMime?: string; - /** When the avatar last changed, to bust the client's cache. */ - avatarVersion?: number; - /** Whether the client answered the last ping; undefined before the first. */ - pongReceived?: boolean; - /** Whether this client currently has the chatbox open (is reading chat). */ - chatOpen?: boolean; /** - * Timestamp (ms) of the client's last "typing" signal; undefined when not - * composing. Entries older than {@link TYPING_TTL} are treated as stale. + * Read again from the database on each chat edit or delete, so a demoted + * moderator loses the right at once. */ + role?: Role; + avatarMime?: string; + /** Set by {@link updateClientAvatar}, for the client's avatar URLs. */ + avatarVersion?: number; + /** Undefined until the first ping, so a new socket isn't closed at once. */ + pongReceived?: boolean; + /** + * Set by `chat_focus`: the chatbox is open and the tab visible, so the user + * is reading the chat and gets no notification for a mention in it. + */ + chatOpen?: boolean; + /** When the last `chat_typing` true arrived, in ms. */ typingAt?: number; } -/** Every connected client. */ const clients = new Set<WsClient>(); -/** Adds a connected client. */ +/** + * Adds a client once its socket opens. From then on, it receives the + * broadcasts, and is shown online if it is signed in. + */ export function register(client: WsClient): void { clients.add(client); } -/** Removes a disconnected client. */ +/** + * Removes a client when its socket closes. The caller rebroadcasts presence. + */ export function unregister(client: WsClient): void { clients.delete(client); } -/** Records a user's new avatar on their clients and rebroadcasts presence. */ +/** + * Records `userId`'s new avatar on their clients, so presence and the typing + * list show it, and rebroadcasts presence. `routes/avatars.ts` calls it on an + * upload. + */ export function updateClientAvatar(userId: string, avatarMime: string): void { const version = Date.now(); for (const client of clients) { @@ -67,7 +83,10 @@ export function updateClientAvatar(userId: string, avatarMime: string): void { broadcastPresence(); } -/** Returns each signed-in user with at least one socket, once. */ +/** + * Returns each signed-in user with a socket open, once. The route sends it in + * `welcome`, and {@link broadcastPresence} in `presence_update`. + */ export function getOnlineUsers(): OnlineUser[] { const seen = new Map<string, OnlineUser>(); for (const client of clients) { @@ -83,19 +102,18 @@ export function getOnlineUsers(): OnlineUser[] { return Array.from(seen.values()); } -/** Sends an already serialized message, if the socket is open. */ +/** Sends `payload` unless the socket is closing, where `send` would throw. */ function sendRaw(socket: WebSocket, payload: string): void { if (socket.readyState === WebSocket.OPEN) socket.send(payload); } -/** Sends one message to one socket. */ function send(socket: WebSocket, data: ServerToClientMessage): void { sendRaw(socket, JSON.stringify(data)); } /** - * Sends one message to every client `include` accepts (all by default), - * serialized once rather than once per socket. + * Sends `data` to every client `include` accepts, or to all of them. It is + * serialized once, not once per socket. */ function broadcast( data: ServerToClientMessage, @@ -109,12 +127,18 @@ function broadcast( } } -/** Sends a message to every socket of `userId`. */ +/** + * Sends `data` to every socket of `userId`. The notification service sends + * `notification_created` with it, and the route `chat_read_update`. + */ export function sendToUser(userId: string, data: ServerToClientMessage): void { broadcast(data, (client) => client.userId === userId); } -/** Logs a deleted user out of every socket and closes them. */ +/** + * Sends `force_logout` to every socket of `userId`, then closes them. The user + * service calls it before deleting the account, so every tab signs out. + */ export function disconnectUser(userId: string): void { for (const client of clients) { if (client.userId === userId) { @@ -125,10 +149,10 @@ export function disconnectUser(userId: string): void { } /** - * Drops the user's sockets without logging anyone out: each reconnects with - * whatever token it holds, and /ws turns a revoked one into a force_logout. - * Used on a password change, where the tab that made the change has already - * swapped in a fresh token and must stay signed in. + * Closes every socket of `userId` without signing anyone out. The user service + * calls it on a password change. Each tab reconnects with the token it holds: + * the tab that made the change has a fresh one and stays signed in, while the + * others connect anonymously and sign out on their next 401. */ export function closeUserSockets(userId: string, reason: string): void { for (const client of clients) { @@ -136,29 +160,41 @@ export function closeUserSockets(userId: string, reason: string): void { } } -/** Sends everyone the list of online users. */ +/** + * Sends everyone `presence_update`. The route calls it whenever a socket opens + * or closes. + */ export function broadcastPresence(): void { broadcast({ type: "presence_update", users: getOnlineUsers() }); } -/** Tells everyone a dump was posted. */ +/** + * Sends everyone `dump_created`. The dump service also sends it when a private + * dump goes public, since to everyone else that dump is new. + */ export function broadcastNewDump(dump: Dump): void { broadcast({ type: "dump_created", dump }); } -/** Tells everyone a dump changed. */ +/** + * Sends everyone `dump_updated`, for a public dump that changed. The dump + * service calls it. + */ export function broadcastDumpUpdated(dump: Dump): void { broadcast({ type: "dump_updated", dump }); } -/** Tells everyone a dump was deleted. */ +/** + * Sends everyone `dump_deleted`. The dump service also sends it when a public + * dump goes private, so that everyone else drops it. + */ export function broadcastDumpDeleted(dumpId: string): void { broadcast({ type: "dump_deleted", dumpId }); } /** - * Tells everyone a dump's vote count changed. With `onlyUserId`, a private - * dump's count goes to that user's sockets alone. + * Sends `votes_update` after the route stored a vote, to everyone, or with + * `onlyUserId` to that user's sockets alone (for a private dump). */ export function broadcastVoteUpdate( dumpId: string, @@ -174,8 +210,9 @@ export function broadcastVoteUpdate( } /** - * Tells everyone a comment's like count changed. With `onlyUserId`, a like on - * a private dump's comment goes to that user alone. + * Sends `comment_likes_update` after the route stored a like, to everyone, or + * with `onlyUserId` to that user's sockets alone (for a comment on a private + * dump). */ export function broadcastCommentLikeUpdate( commentId: string, @@ -190,7 +227,7 @@ export function broadcastCommentLikeUpdate( ); } -/** Sends a message to everyone for a public playlist, to its owner otherwise. */ +/** Sends `data` to everyone for a public playlist, else to its owner alone. */ function sendToPlaylistAudience( playlist: Pick<Playlist, "isPublic" | "userId">, data: ServerToClientMessage, @@ -201,16 +238,18 @@ function sendToPlaylistAudience( ); } -/** Tells a new playlist's audience it was created. */ +/** + * Sends `playlist_created` to the new playlist's audience. The playlist service + * calls it. + */ export function broadcastPlaylistCreated(playlist: Playlist): void { sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); } /** - * Tells a playlist's audience it changed. `wasPublic` is the visibility before - * this change: a playlist that just went private still goes to everyone, so - * non-owners can drop it from their views; one that was and stays private goes - * to its owner alone. + * Sends `playlist_updated` to the playlist's audience. `wasPublic` is its + * visibility before the change: a playlist that just went private still goes + * to everyone, so that the others can drop it. */ export function broadcastPlaylistUpdated( playlist: Playlist, @@ -222,7 +261,7 @@ export function broadcastPlaylistUpdated( ); } -/** Tells a playlist's audience it was deleted. */ +/** Sends `playlist_deleted` to the audience the playlist had. */ export function broadcastPlaylistDeleted( playlistId: string, userId: string, @@ -236,9 +275,9 @@ export function broadcastPlaylistDeleted( } /** - * Tells a playlist's audience its dumps changed. Each viewer gets the members - * they can see, as GET would list them: the ids of private dumps don't go out - * to the whole audience of a public playlist. + * 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. */ export function broadcastPlaylistDumpsUpdated( playlist: Playlist, @@ -257,17 +296,23 @@ export function broadcastPlaylistDumpsUpdated( } } -/** Tells everyone a user's profile changed. */ +/** + * Sends everyone `user_updated`, after a profile edit, a role change or an + * avatar upload. + */ export function broadcastUserUpdated(user: PublicUser): void { broadcast({ type: "user_updated", user }); } -/** Tells everyone a comment was posted. */ +/** + * Sends everyone `comment_created`. The comments route calls it only for a + * comment on a public dump, as it does the two below. + */ export function broadcastCommentCreated(comment: Comment): void { broadcast({ type: "comment_created", comment }); } -/** Tells everyone a comment was deleted. */ +/** Sends everyone `comment_deleted`. */ export function broadcastCommentDeleted( commentId: string, dumpId: string, @@ -275,22 +320,23 @@ export function broadcastCommentDeleted( broadcast({ type: "comment_deleted", commentId, dumpId }); } -/** Tells everyone a comment was edited. */ +/** Sends everyone `comment_updated`. */ export function broadcastCommentUpdated(comment: Comment): void { broadcast({ type: "comment_updated", comment }); } /** - * Sends a chat message to signed-in sockets only: history is behind auth, so - * the live stream is too — an anonymous socket gets nothing chat-related. + * Sends `data` to signed-in sockets only. The chat's history needs a session, + * so its live messages do too. */ function sendToMembers(data: ServerToClientMessage): void { broadcast(data, (client) => !!client.userId); } /** - * Sends a new chat message to the members. `nonce` is the sender's id for its - * pending copy; harmless to everyone else. + * Sends `chat_message` to every signed-in socket, the sender's included. + * `nonce` comes from the sender's `chat_send`, and lets it swap its pending + * copy for the message. */ export function broadcastChatMessage( message: ChatMessage, @@ -299,27 +345,25 @@ export function broadcastChatMessage( sendToMembers({ type: "chat_message", message, nonce }); } -/** Tells the members a chat message was edited. */ +/** Sends `chat_message_updated` to every signed-in socket. */ export function broadcastChatMessageUpdated(message: ChatMessage): void { sendToMembers({ type: "chat_message_updated", message }); } -/** Tells the members a chat message was deleted. */ +/** Sends `chat_message_deleted` to every signed-in socket. */ export function broadcastChatMessageDeleted(id: string): void { sendToMembers({ type: "chat_message_deleted", id }); } /** - * How long a client's "typing" signal is considered live after its last - * refresh. Must exceed the client's typing-ping cadence so a steady typist - * never flickers out between pings. + * How long a `chat_typing` true lasts. It must exceed how often the chatbox + * resends it (`TYPING_REFRESH`, 3 s), or a steady typist flickers out. */ const TYPING_TTL = 6_000; /** - * Returns each user currently composing a chat message (typing signal within - * {@link TYPING_TTL}) once, shaped like the online users so the client renders - * the same avatars. + * Returns each user who sent `chat_typing` true within the last + * {@link TYPING_TTL}, once, for {@link broadcastTyping}. */ export function getTypingUsers(): OnlineUser[] { const now = Date.now(); @@ -340,27 +384,29 @@ export function getTypingUsers(): OnlineUser[] { return Array.from(seen.values()); } -/** Sends the members the list of users typing. */ +/** + * Sends `chat_typing_update` to every signed-in socket. The route also calls + * it when a typing user's socket closes. + */ export function broadcastTyping(): void { sendToMembers({ type: "chat_typing_update", users: getTypingUsers() }); } /** - * Records (or clears) a client's typing state and tells everyone when it - * changed. The client also resends "true" periodically while composing; a - * sweep expires it if they go quiet without a clean "false" (e.g. they closed - * the tab). + * Records a client's `chat_typing`, and broadcasts the typing list when the + * client starts or stops. The route also clears it when the client sends a + * message. A client that stops without saying so is expired after + * {@link TYPING_TTL}. */ export function setClientTyping(client: WsClient, typing: boolean): void { const wasTyping = !!client.typingAt; client.typingAt = typing ? Date.now() : undefined; - // Only rebroadcast on an actual edge to avoid a flood of identical updates - // from the periodic typing pings. + // The chatbox resends true every 3 s while typing, which changes nothing. if (typing !== wasTyping) broadcastTyping(); } -// Sweep stale typing entries (clients that stopped pinging without a clean -// stop) and rebroadcast when the visible set shrinks. +// Expires the typing users who went quiet without a false, such as by closing +// the tab. setInterval(() => { const now = Date.now(); let changed = false; @@ -374,9 +420,9 @@ setInterval(() => { }, 2_000); /** - * Checks whether the user has the chatbox open on any of their connected - * clients — i.e. they're already reading chat, so a mention notification would - * be redundant. + * Checks whether `userId` is reading the chat on any of their sockets. The + * route passes it to `notifyMentions`, which skips those users: they saw the + * mention live, and their client chimes for it. */ export function isUserChatOpen(userId: string): boolean { for (const client of clients) { @@ -387,17 +433,16 @@ export function isUserChatOpen(userId: string): boolean { export { type RateLimit, takeToken } from "../lib/rate-limit.ts"; -/** Records that a client answered the keepalive ping. */ +/** Records a client's `pong`, which keeps its socket open. */ export function handleClientPong(client: WsClient): void { client.pongReceived = true; } /** * How often every client is pinged. One that hasn't answered the previous ping - * is disconnected. + * is closed. */ const PING_INTERVAL = 30_000; -/** The serialized ping. */ const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage); setInterval(() => { @@ -406,7 +451,7 @@ setInterval(() => { clients.delete(client); continue; } - // Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle) + // Undefined, not false, before the first ping. if (client.pongReceived === false) { client.socket.close(1001, "Ping timeout"); clients.delete(client); diff --git a/api/validators/category.ts b/api/validators/category.ts index df7f8a49..fc40bc10 100644 --- a/api/validators/category.ts +++ b/api/validators/category.ts @@ -1,5 +1,6 @@ /** - * Guards for the category request bodies. + * Checks the shape of the category request bodies, for the category routes to + * answer a 400. The category service checks the slug and name themselves. * * @module */ @@ -9,7 +10,7 @@ import type { UpdateCategoryRequest, } from "../../shared/category.ts"; -/** Checks whether `obj` is a valid {@link CreateCategoryRequest}. */ +/** Checks whether `obj` has the fields of a {@link CreateCategoryRequest}. */ export function isCreateCategoryRequest( obj: unknown, ): obj is CreateCategoryRequest { @@ -21,7 +22,7 @@ export function isCreateCategoryRequest( return true; } -/** Checks whether `obj` is a valid {@link UpdateCategoryRequest}. */ +/** Checks whether `obj` has the fields of an {@link UpdateCategoryRequest}. */ export function isUpdateCategoryRequest( obj: unknown, ): obj is UpdateCategoryRequest { diff --git a/api/validators/comment.ts b/api/validators/comment.ts index 5d037596..39a9374e 100644 --- a/api/validators/comment.ts +++ b/api/validators/comment.ts @@ -1,5 +1,5 @@ /** - * Guards for the comment request bodies. + * Checks the comment request bodies, for the comment routes to answer a 400. * * @module */ @@ -11,8 +11,9 @@ import type { import { VALIDATION } from "../../shared/validation.ts"; /** - * Checks whether `obj` is a valid {@link CreateCommentRequest}: a body that - * isn't blank and fits `VALIDATION.COMMENT_BODY_MAX`. + * Checks whether `obj` is a {@link CreateCommentRequest} whose body isn't + * blank and fits `VALIDATION.COMMENT_BODY_MAX`. A null `parentId` passes, as a + * top-level comment. */ export function isCreateCommentRequest( obj: unknown, @@ -27,8 +28,8 @@ export function isCreateCommentRequest( } /** - * Checks whether `obj` is a valid {@link UpdateCommentRequest}: a body that - * isn't blank and fits `VALIDATION.COMMENT_BODY_MAX`. + * Checks whether `obj` is an {@link UpdateCommentRequest} whose body isn't + * blank and fits `VALIDATION.COMMENT_BODY_MAX`. */ export function isUpdateCommentRequest( obj: unknown, diff --git a/api/validators/dump.ts b/api/validators/dump.ts index 4d3ad18a..a3bc8130 100644 --- a/api/validators/dump.ts +++ b/api/validators/dump.ts @@ -1,6 +1,7 @@ /** - * Guards for the dump request bodies. File dumps arrive as multipart forms - * and are checked in their route. + * Checks the JSON bodies that `routes/dumps.ts` takes to post a link and to + * edit a dump. A file dump arrives as a multipart form instead, and + * `dump-service.ts` checks its title and comment. * * @module */ @@ -16,8 +17,9 @@ function isStringArray(value: unknown): value is string[] { } /** - * Checks whether `obj` is a valid {@link CreateUrlDumpRequest}, its title and - * comment within the `VALIDATION` limits. + * Checks whether `obj` is a {@link CreateUrlDumpRequest} whose title and + * comment fit the `VALIDATION` limits. It leaves the URL to `createUrlDump`, + * which rejects anything but a public http(s) URL. */ export function isCreateUrlDumpRequest( obj: unknown, @@ -47,9 +49,9 @@ export function isCreateUrlDumpRequest( } /** - * Checks whether `obj` is a valid {@link UpdateDumpRequest}: a title, when - * given, that isn't blank, and title and comment within the `VALIDATION` - * limits. + * Checks whether `obj` is an {@link UpdateDumpRequest} whose title and comment + * fit the `VALIDATION` limits. Unlike on creation, a title that is given must + * not be blank. */ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; diff --git a/api/validators/playlist.ts b/api/validators/playlist.ts index 7ff70eaa..05a630db 100644 --- a/api/validators/playlist.ts +++ b/api/validators/playlist.ts @@ -1,5 +1,5 @@ /** - * Guards for the playlist request bodies. + * Checks the playlist request bodies, for the playlist routes to answer a 400. * * @module */ @@ -12,8 +12,9 @@ import type { import { VALIDATION } from "../../shared/validation.ts"; /** - * Checks whether `obj` is a valid {@link CreatePlaylistRequest}: a non-empty - * title, and title and description within the `VALIDATION` limits. + * Checks whether `obj` is a {@link CreatePlaylistRequest} with a title, and a + * title and description within the `VALIDATION` limits. A null description + * passes. A title of spaces passes too. */ export function isCreatePlaylistRequest( obj: unknown, @@ -40,8 +41,8 @@ export function isCreatePlaylistRequest( } /** - * Checks whether `obj` is a valid {@link UpdatePlaylistRequest}: a title, when - * given, that isn't empty, and title and description within the `VALIDATION` + * Checks whether `obj` is an {@link UpdatePlaylistRequest} whose title, if + * given, isn't empty, with a title and description within the `VALIDATION` * limits. */ export function isUpdatePlaylistRequest( @@ -68,7 +69,10 @@ export function isUpdatePlaylistRequest( return true; } -/** Checks whether `obj` is a valid {@link ReorderPlaylistRequest}. */ +/** + * Checks whether `obj` is a {@link ReorderPlaylistRequest}. Whether its ids + * match the playlist is `reorderPlaylist`'s to check. + */ export function isReorderPlaylistRequest( obj: unknown, ): obj is ReorderPlaylistRequest { diff --git a/api/validators/user.ts b/api/validators/user.ts index 821d8448..479d707d 100644 --- a/api/validators/user.ts +++ b/api/validators/user.ts @@ -1,5 +1,5 @@ /** - * Guards for the user and authentication request bodies. + * Checks the JSON bodies that `routes/users.ts` receives before it reads them. * * @module */ @@ -12,14 +12,20 @@ import { } from "../../shared/user.ts"; import { VALIDATION } from "../../shared/validation.ts"; -/** Checks whether `obj` is a valid {@link LoginUserRequest}. */ +/** + * Checks whether `obj` is a {@link LoginUserRequest}. It checks the types + * only: a wrong name or password is the login route's answer to give. + */ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { return !!obj && typeof obj === "object" && "username" in obj && typeof obj.username === "string" && "password" in obj && typeof obj.password === "string"; } -/** Returns `obj[key]` when `obj` is an object that has it, else undefined. */ +/** + * Returns `obj[key]` when `obj` is an object that has it, else undefined. The + * users routes whose body has no guard of its own read their fields with it. + */ export function fieldOf(obj: unknown, key: string): unknown { return obj !== null && typeof obj === "object" && key in obj ? (obj as Record<string, unknown>)[key] @@ -27,8 +33,8 @@ export function fieldOf(obj: unknown, key: string): unknown { } /** - * Checks whether `obj` is a valid {@link RegisterUserRequest} (see - * {@link validateRegisterUserRequest}). + * Checks whether `obj` is a valid {@link RegisterUserRequest}. When it isn't, + * {@link validateRegisterUserRequest} says why. */ export function isRegisterUserRequest( obj: unknown, @@ -37,9 +43,10 @@ export function isRegisterUserRequest( } /** - * Checks a registration body — its shape, the email's form, and the username - * and password against the `VALIDATION` limits — and returns what is wrong - * with it, readable by the user, or null when it is valid. + * Returns what is wrong with a sign-up body, as the message the register page + * shows, or null when it is valid. It checks the shape, the email's form, and + * the username and password against the `VALIDATION` limits. Whether the name, + * the email or the invite is taken is `createUser`'s and `claimInvite`'s check. */ export function validateRegisterUserRequest(obj: unknown): string | null { if ( @@ -71,8 +78,10 @@ export function validateRegisterUserRequest(obj: unknown): string | null { } /** - * Checks whether `obj` is a valid {@link UpdateUserRequest}: each field it - * carries well-formed, and within the `VALIDATION` limits. + * Checks whether `obj` is a valid {@link UpdateUserRequest}, for + * `PATCH /api/users/me`. Every field is optional, but each one present must be + * well-formed and within the `VALIDATION` limits. The username's limits here + * are not those of sign-up, which the route never lets it change anyway. */ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { if (!obj || typeof obj !== "object") return false; diff --git a/doc-lint.ts b/doc-lint.ts index a5452112..69a12831 100644 --- a/doc-lint.ts +++ b/doc-lint.ts @@ -7,6 +7,10 @@ * whose type the module imports from a package is let through, and counted; * every other diagnostic fails the run. * + * A `missing-jsdoc` on a member (an interface field, a class property) is let + * through too: a field gets a doc only when it adds something its type's doc + * doesn't, so most fields have none. A module or an export without one fails. + * * It also checks what `deno doc --lint` doesn't: that every summary stands on * its own. The reference shows a doc's first paragraph alone, in lists and * above a symbol, so a first paragraph ending in a colon reads cut short. @@ -22,6 +26,8 @@ interface Diagnostic { message: string; /** The module it was reported in. */ file: string; + /** The column it was reported at: 1 for a top-level declaration. */ + column: number; /** The diagnostic as printed, location and excerpt included. */ text: string; } @@ -37,9 +43,15 @@ async function lint(modules: string[]): Promise<Diagnostic[]> { const output = new TextDecoder().decode(stderr); return output.split(/\n(?=error\[)/).flatMap((text) => { const head = /^error\[([\w-]+)\]: (.*)$/m.exec(text); - const at = /--> (.+?):\d+:\d+/.exec(text); + const at = /--> (.+?):\d+:(\d+)/.exec(text); if (!head || !at) return []; - return [{ rule: head[1], message: head[2], file: at[1], text }]; + return [{ + rule: head[1], + message: head[2], + file: at[1], + column: Number(at[2]), + text, + }]; }); } @@ -59,13 +71,20 @@ function importedFromPackage(file: string, name: string): boolean { return false; } -/** Checks whether `d` is a private type from a package, which is let through. */ +/** + * Checks whether `d` is a private type from a package, which is let through. + */ function isPackageTypeRef(d: Diagnostic): boolean { const type = /references private type '([\w$]+)/.exec(d.message)?.[1]; return d.rule === "private-type-ref" && !!type && importedFromPackage(d.file, type); } +/** Checks whether `d` is a member without a doc, which is let through. */ +function isUndocumentedMember(d: Diagnostic): boolean { + return d.rule === "missing-jsdoc" && d.column > 1; +} + /** Returns the first paragraph of `doc`, which the reference shows alone. */ function summary(doc: string): string { return doc.trim().split(/\n\s*\n|\n\s*(?:[-*]|\d+\.)\s|\n\s*```/)[0].trim(); @@ -103,6 +122,7 @@ async function colonSummaries(modules: string[]): Promise<Diagnostic[]> { rule: "colon-summary", message, file, + column: 1, text: `error[colon-summary]: ${message}\n --> ${file}`, }); } @@ -124,13 +144,15 @@ if (import.meta.main) { ...await lint(Deno.args), ...await colonSummaries(Deno.args), ]; - const failures = diagnostics.filter((d) => !isPackageTypeRef(d)); + const failures = diagnostics.filter((d) => + !isPackageTypeRef(d) && !isUndocumentedMember(d) + ); for (const d of failures) console.error(d.text.trimEnd() + "\n"); const allowed = diagnostics.length - failures.length; console.error( `${failures.length} documentation lint errors` + (allowed - ? `, ${allowed} references to npm packages' types let through` + ? `, ${allowed} undocumented members and npm type references let through` : ""), ); if (failures.length) Deno.exit(1); diff --git a/mod.ts b/mod.ts index 1d797e19..60ab4ffd 100644 --- a/mod.ts +++ b/mod.ts @@ -1,55 +1,56 @@ /** - * A small invite-only social platform for sharing links and files. The - * repository holds three parts: the backend in `api/`, the frontend in `src/`, - * and in `shared/` whatever crosses the wire between them. This reference - * documents all three; [every module](./all_symbols.html) is listed by path, - * so they come grouped by part and layer. + * Documents gerbeur, a small invite-only social platform for sharing links and + * files. The repository holds three parts: the backend in `api/`, the + * frontend in `src/`, and in `shared/` whatever crosses the wire between them. + * [Every module](./all_symbols.html) is listed by path, so the list groups + * them by part and layer. * - * ## Backend — `api/` + * ## Backend, in `api/` * - * An Oak server over SQLite that answers the API and the WebSocket, and serves - * the built frontend. `api/main.ts` builds the application and listens. A - * request goes through a route, which parses it and answers. The route calls - * services, which hold the logic and every SQL query; they read rows through - * the model, and reach other sites only through `remote/`. Each folder holds - * one kind of module, and each file is named after its domain: + * An Oak server over SQLite answers the API and the WebSocket, and serves the + * built frontend. `api/main.ts` builds the application and listens. A request + * goes to a route, which parses it, calls services and answers. The services + * hold the logic and every SQL query, read rows through the model, and reach + * other sites only through `remote/`. Each folder holds one kind of module, + * and each file is named after its domain. * - * - `routes/` — the HTTP API. Each router's page lists its endpoints: method - * and path, who may call it, what it does. - * - `middleware/` — what runs around every route: auth, errors, OG tags. - * - `services/` — the logic and every SQL query, one module per domain. - * - `model/` — per table, its row type, guard and mappers. - * - `validators/` — the request-body guards. - * - `remote/` — the only place that fetches from other sites; its `providers/` - * build the previews of each kind of site. - * - `lib/` — helpers that belong to no layer. - * - `db/` — the handle, its creation, migration and startup housekeeping; - * `db/migrations/` are applied in order, each once. - * - `config.ts` — the settings read from the environment; `scripts/` — the - * icon generator. + * - `routes/` holds the HTTP API. Each router's page lists its endpoints, with + * their method and path, who may call them, and what they do. + * - `middleware/` holds what runs around the routes, which is auth, errors and + * the Open Graph tags. + * - `services/` holds the logic and every SQL query, one module per domain. + * - `model/` holds each table's row type, guard and mappers. + * - `validators/` holds the guards of the request bodies. + * - `remote/` is the only place that fetches from other sites, and its + * `providers/` build the previews of each kind of site. + * - `lib/` holds the helpers that belong to no layer. + * - `db/` opens, creates and migrates the database, and cleans it up at + * startup. `db/migrations/` are applied in order, each once. + * - `config.ts` reads the settings from the environment, and `scripts/` holds + * the icon generator. * - * ## Shared — `shared/` + * ## Shared, in `shared/` * - * The types and constants both halves use, one module per domain: the API - * envelope, each domain's types and request bodies, the socket protocol, the - * validation limits and the role permissions. It imports nothing from either - * half. The server sends `Date`s; the client reads the same types through - * `Wire<T>`, with every `Date` an ISO string. + * The types and constants both halves use sit here, one module per domain. + * They are the API envelope, each domain's types and request bodies, the + * socket protocol, the validation limits and the role permissions. `shared/` + * imports nothing from either half. The server sends `Date`s, and the client + * reads the same types through `Wire<T>`, where every `Date` is an ISO string. * - * ## Frontend — `src/` + * ## Frontend, in `src/` * - * The React app, built with Vite and translated with Lingui. `main.tsx` mounts - * it, `App.tsx` holds the providers and the route table, `i18n.ts` the - * language. A page reads the API and the socket through contexts and hooks, and - * draws with components; utils hold what isn't React at all. + * The React app is built with Vite and translated with Lingui. `main.tsx` + * mounts it, `App.tsx` holds the providers and the route table, and `i18n.ts` + * sets the language. A page reads the API and the socket through contexts and + * hooks, and draws with components, while utils hold what isn't React at all. * - * - `pages/` — one component per route, each naming it, and the two guards - * that wrap the routes for the signed in and the signed out. - * - `config/` — settings read at startup. - * - `contexts/` — a `FooContext` holding the state and its `FooProvider`. - * - `hooks/` — every hook. - * - `components/` — only components. - * - `utils/` — every module that isn't React. + * - `pages/` holds one component per route, each naming its route, and the two + * guards that keep routes to signed-in or signed-out visitors. + * - `config/` holds the settings read at startup. + * - `contexts/` holds, for each domain, a `FooContext` with its state and the + * `FooProvider` that fills it. + * - `hooks/` holds every hook, and `components/` only components. + * - `utils/` holds every module that isn't React. * * Every layer but `pages/` and `config/` is split into the same domains (auth, * category, chat, comment, dump, editor, feed, form, layout, player, playlist, diff --git a/shared/api.ts b/shared/api.ts index 2588bce8..ad19f58d 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -1,74 +1,77 @@ /** - * The envelope every endpoint answers with, the error codes it carries, the - * shape of a paginated list, and {@link Wire}, which maps a type to what JSON - * makes of it. + * Defines the envelope every JSON endpoint answers with, and {@link Wire}, the + * form a type takes once it has crossed JSON. The routes build the envelope, + * and the frontend's API helpers unwrap it. * * @module */ -/** The error codes an {@link APIError} carries, by name. */ +/** + * The codes an {@link APIError} may carry. The frontend shows the message and + * doesn't read the code, and a code goes with several statuses. + */ export const APIErrorCode = { - /** The request is malformed or not allowed in the current state. */ BAD_REQUEST: "BAD_REQUEST", - /** What the request names doesn't exist, or the caller may not see it. */ + /** Also answers for what exists but the caller may not see. */ NOT_FOUND: "NOT_FOUND", - /** The server failed. */ + /** Sent by the error middleware for any error that isn't an APIException. */ SERVER_ERROR: "SERVER_ERROR", - /** A remote site took too long to answer. */ + /** Nothing sends it. */ TIMEOUT: "TIMEOUT", - /** The caller isn't signed in, or may not do this. */ + /** + * Sent with 401 to a caller not signed in, and with 403 to one who may not. + */ UNAUTHORIZED: "UNAUTHORIZED", - /** The request body failed validation. */ VALIDATION_ERROR: "VALIDATION_ERROR", } as const; -/** One of the {@link APIErrorCode} values. */ +/** Carried by the backend's `APIException`, and sent in an {@link APIError}. */ export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode]; -/** What went wrong, in a failed response. */ +/** + * Sent in an {@link APIFailure}. The frontend shows `message` to the user, so + * it is written for them. + */ export interface APIError { - /** The kind of failure. */ code: APIErrorCode; - /** A human-readable explanation. */ message: string; } -/** A successful response, carrying its payload. */ +/** Sent by a route that succeeded, around its payload. */ export interface APISuccess<T> { - /** Always true. */ success: true; - /** The payload. */ data: T; - /** Never set on success. */ error?: never; } -/** A failed response, carrying its error. */ +/** + * Sent by the error middleware when a request fails, and by the few routes + * that answer an error themselves. + */ export interface APIFailure { - /** Always false. */ success: false; - /** Never set on failure. */ data?: never; - /** What went wrong. */ error: APIError; } -/** What every endpoint answers with, narrowed by `success`. */ +/** + * Returned by every JSON endpoint. A reader tells the two apart by `success`. + */ export type APIResponse<T> = APISuccess<T> | APIFailure; -/** One page of a list. */ +/** + * Returned by the list endpoints, as the `data` of an {@link APISuccess}. + */ export interface PaginatedData<T> { - /** The page's items. */ items: T[]; - /** Whether another page follows. */ hasMore: boolean; - /** The whole list's size — only where it's shown (search results). */ + /** The size of the whole list, set only by search, which shows it. */ total?: number; } /** - * `T` as it arrives over JSON (HTTP, the socket, localStorage): every Date is - * an ISO string. + * `T` as the client reads it from JSON, over HTTP, the socket or + * localStorage, where every `Date` has become an ISO string. */ export type Wire<T> = T extends Date ? string : T extends readonly (infer U)[] ? Wire<U>[] diff --git a/shared/category.ts b/shared/category.ts index 90964c2f..19590e74 100644 --- a/shared/category.ts +++ b/shared/category.ts @@ -1,42 +1,41 @@ /** - * Categories, the admin-managed topics a dump can be filed under, and the - * bodies that create and edit them. + * Declares the categories that dumps are filed under, and the bodies that + * create and change them. Only admins manage categories, through the API, since + * no page does it. The client lists them in its `CategoriesProvider`. * * @module */ -/** A category, which lists its dumps at `/<slug>`. */ +/** + * Returned by the category routes. A category's feed lives at `/<slug>` on the + * client, and a dump names its categories by `id` in `categoryIds`. + */ export interface Category { - /** The category's id. */ id: string; - /** The category's URL slug, at the site root. */ slug: string; - /** The category's display name. */ name: string; - /** The category's place in the list, ascending. */ + /** Sorts the categories, lowest first. */ position: number; - /** When the category was created. */ createdAt: Date; - /** When the category was last changed, if ever. */ updatedAt?: Date; } -/** The body of `POST /api/categories`. */ +/** + * Passed to `POST /api/categories`. The slug must be free and not reserved by + * a route. + */ export interface CreateCategoryRequest { - /** The new category's slug. */ slug: string; - /** The new category's display name. */ name: string; - /** Its place in the list; 0 when left out. */ + /** 0 when left out. */ position?: number; } -/** The body of `PUT /api/categories/:id`: the fields to change. */ +/** + * Passed to `PUT /api/categories/:id`. A field left out keeps its value. + */ export interface UpdateCategoryRequest { - /** The new slug. */ slug?: string; - /** The new display name. */ name?: string; - /** The new place in the list. */ position?: number; } diff --git a/shared/chat.ts b/shared/chat.ts index 0679d0b5..08db5a6d 100644 --- a/shared/chat.ts +++ b/shared/chat.ts @@ -1,35 +1,31 @@ /** - * A message of the site-wide chat. + * Defines a message of the site-wide chat, as the server sends it to the + * chatbox. * * @module */ -/** A chat message, with what the chat shows of its author and its reply. */ +/** + * Returned by `GET /api/chat`, and sent by the server in `chat_message` and + * `chat_message_updated`. The client reads it through + * `deserializeChatMessage`, which turns its dates back into Dates. + */ export interface ChatMessage { - /** The message's id. */ id: string; - /** The author's id. */ userId: string; - /** The message, in Markdown. */ + /** In Markdown, trimmed by the server. */ body: string; - /** When the message was sent. */ createdAt: Date; - /** When the message was last edited, if ever. */ + /** Absent until the first edit. */ updatedAt?: Date; - /** The author's name. */ authorUsername: string; - /** The author's avatar type, undefined without one. */ authorAvatarMime?: string; /** - * Id of the message this one replies to, if any. Retained even when the - * target is later deleted (the preview fields below then go undefined). + * Kept when the message it points to is deleted, while `replyToAuthor` and + * `replyToBody` then go absent. */ replyToId?: string; - /** - * Author of the replied-to message, for a subtle inline reference. Absent - * when there's no reply, or the target has since been deleted. - */ replyToAuthor?: string; - /** Body of the replied-to message, absent under the same conditions. */ + /** The first 300 characters of the replied-to body, in Markdown. */ replyToBody?: string; } diff --git a/shared/comment.ts b/shared/comment.ts index dadde6b3..fc4232b8 100644 --- a/shared/comment.ts +++ b/shared/comment.ts @@ -1,48 +1,45 @@ /** - * Comments under a dump, and the bodies that post and edit them. + * Declares the comments posted under a dump, and the bodies that post and edit + * them. The dump page's `CommentThread` renders them as a tree, by `parentId`. * * @module */ -/** A comment, with what the thread shows of its author. */ +/** + * Returned by the comment routes, and sent in the `comment_created` and + * `comment_updated` WebSocket messages. It carries its author's name and + * avatar, so the thread needn't look them up. + */ export interface Comment { - /** The comment's id. */ id: string; - /** The id of the dump it is posted under. */ dumpId: string; - /** The author's id. */ userId: string; - /** The id of the comment it replies to; undefined at the top level. */ + /** It is undefined on a top-level comment. */ parentId?: string; - /** The comment, in Markdown; empty once deleted. */ + /** The Markdown text. Deleting the comment empties it. */ body: string; - /** When the comment was posted. */ createdAt: Date; - /** When the comment was last edited, if ever. */ updatedAt?: Date; /** - * Whether the comment was deleted: it stays in the thread, emptied, so its - * replies keep their place. + * A deleted comment stays in the thread, emptied, so its replies keep their + * place. */ deleted: boolean; - /** How many users like the comment. */ likeCount: number; - /** The author's name. */ authorUsername: string; - /** The author's avatar type, undefined without one. */ authorAvatarMime?: string; } -/** The body of `POST /api/dumps/:dumpId/comments`. */ +/** + * Passed to `POST /api/dumps/:dumpId/comments` by `CommentThread`, and by + * `CommentNode` for a reply. The body must not be blank. + */ export interface CreateCommentRequest { - /** The comment, in Markdown. */ body: string; - /** The id of the comment it replies to, for a reply. */ parentId?: string; } -/** The body of `PATCH /api/comments/:commentId`. */ +/** Passed to `PATCH /api/comments/:commentId` by `CommentNode`. */ export interface UpdateCommentRequest { - /** The new text, in Markdown. */ body: string; } diff --git a/shared/dump.ts b/shared/dump.ts index 127e9dd6..5e442c76 100644 --- a/shared/dump.ts +++ b/shared/dump.ts @@ -1,23 +1,26 @@ /** - * Dumps — the links and files users post — their link previews, and the bodies - * that post and edit them. + * Defines the dump, the link or file a user posts, as the API sends it, with + * its link preview and the bodies that create and edit it. The backend's + * `dump-service.ts` produces these types and the frontend's dump pages and + * create form consume them. * * @module */ -/** What a link's page says about itself, as its preview card shows it. */ +/** + * Built by a provider of `rich-content-service.ts` when a link is posted or + * previewed, and stored with the dump as JSON. The frontend's + * `RichContentCard` and player read it. + */ export interface RichContent { /** - * Which provider read it: `youtube`, `bandcamp`, `soundcloud` or `generic`. + * The provider that read the page: `youtube`, `bandcamp`, `soundcloud` or + * `generic`. The player picks how to play the link by it. */ type: string; - /** The link the preview is of. */ url: string; - /** The site's name. */ siteName?: string; - /** The page's title. */ title?: string; - /** The page's description. */ description?: string; /** * A real preview image (og:image, a large content image, a video still). @@ -25,120 +28,112 @@ export interface RichContent { * which is what makes the generated placeholder possible. */ thumbnailUrl?: string; - /** The page's own icon, used as the placeholder's glyph. */ + /** Used as the placeholder's glyph. */ faviconUrl?: string; /** The page's declared brand color, normalized to `#rrggbb`. */ accentColor?: string; - /** The YouTube video's id, for a YouTube link. */ + /** Set for a YouTube link only. */ videoId?: string; - /** The URL of the page's embeddable player, if it has one. */ embedUrl?: string; } -/** A dump: a link or a file a user posted. */ +/** + * Returned by every endpoint that answers dumps, and pushed to clients by the + * `dump_created` and `dump_updated` socket messages. A URL dump has `url` and + * `richContent`, a file dump the `file…` fields. A private dump reaches its + * poster only. + */ export interface Dump { - /** The dump's id. */ id: string; - /** `url` for a link, `file` for an upload. */ kind: "url" | "file"; - /** The dump's title. */ title: string; - /** The dump's URL slug. */ + /** Made from the title. The dump's page URL takes it in place of the id. */ slug?: string; - /** The poster's description, in Markdown. */ + /** The poster's note on the dump, in Markdown. */ comment?: string; - /** The poster's id. */ userId: string; - /** When the dump was posted. */ createdAt: Date; - /** When the dump was last edited, if ever. */ updatedAt?: Date; - /** The link, for a URL dump. */ url?: string; - /** The link's preview, for a URL dump. */ richContent?: RichContent; - /** The uploaded file's name, for a file dump. */ fileName?: string; - /** The uploaded file's type, for a file dump. */ fileMime?: string; - /** The uploaded file's size in bytes, for a file dump. */ fileSize?: number; - /** How many users upvoted the dump. */ voteCount: number; - /** How many comments it has, not counting deleted ones. */ + /** Deleted comments are not counted. */ commentCount: number; - /** Whether only the poster sees the dump. */ isPrivate: boolean; - /** The uploaded thumbnail's type, undefined without one. */ + /** Set when the poster uploaded a custom thumbnail. */ thumbnailMime?: string; - /** The ids of the categories it is filed under. */ categoryIds?: string[]; } /** - * An existing dump pointing at the same URL, as shown by the create form's - * duplicate hint (`GET /api/dumps/by-url`). Display-only projection — see - * `findDumpsByUrl`. + * Returned by `GET /api/dumps/by-url` for each dump already posted with the + * link being typed, and shown by the create form's `DuplicateNotice`. It holds + * only what the notice displays. */ export interface DumpUrlMatch { - /** The dump's id. */ id: string; - /** The dump's URL slug. */ slug?: string; - /** The dump's title. */ title: string; - /** The poster's name. */ + /** The poster's username. */ username: string; - /** When the dump was posted. */ createdAt: Date; - /** How many users upvoted the dump. */ voteCount: number; - /** How many comments it has. */ commentCount: number; } /** - * `GET /api/preview`. `reached` is separate because a failed fetch still yields - * a usable stub (hostname only): without it the create form can't tell "this - * page has no preview" from "this link is dead". + * Returned by `GET /api/preview` to the create form, which shows it before the + * link is posted. */ export interface LinkPreview { - /** Whether the page could be fetched. */ + /** + * False when the page couldn't be fetched. `richContent` is then a stub + * holding the host name, and this flag is how the form tells a dead link + * from a page with no preview. + */ reached: boolean; - /** The preview, null when there is none at all. */ + /** + * Null only for a link that doesn't parse, which the route rejects before + * fetching anyway. + */ richContent: RichContent | null; } -/** The JSON body of `POST /api/dumps`, which posts a link. */ +/** + * Passed by the create form to `POST /api/dumps` as a JSON body, to post a + * link. A file is posted as a multipart form instead. + */ export interface CreateUrlDumpRequest { - /** The link. */ url: string; - /** Overrides the title scraped from the page — the poster edited the preview. */ + /** Replaces the title read from the page, when the poster edited it. */ title?: string; - /** The poster's description, in Markdown. */ comment?: string; - /** Whether only the poster will see it. */ isPrivate?: boolean; - /** The ids of the categories to file it under. */ categoryIds?: string[]; } -/** The body of `PUT /api/dumps/:dumpId`: the fields to change. */ +/** + * Passed to `PUT /api/dumps/:dumpId` by the edit page, and by the dump page's + * inline title edit. A field left out stays as it is. + */ export interface UpdateDumpRequest { - /** The new link, for a URL dump. */ + /** Ignored for a file dump. A changed link has its preview fetched again. */ url?: string; - /** The new title. */ title?: string; - /** `null` removes the note; leaving it out keeps it. */ + /** `null` removes the note. */ comment?: string | null; - /** Whether only the poster will see it. */ isPrivate?: boolean; - /** The ids of the categories to file it under, replacing the current ones. */ + /** Replaces the current categories. */ categoryIds?: string[]; } -/** `POST /api/attachments`: the id the uploaded image is served under. */ +/** + * Returned by `POST /api/attachments` to the text editor, which inserts the + * image into the body as a link to `GET /api/attachments/:id`. + */ export interface AttachmentResponse { - /** The attachment's id, for `GET /api/attachments/:attachmentId`. */ id: string; } diff --git a/shared/follow.ts b/shared/follow.ts index ebe8d6a6..32062569 100644 --- a/shared/follow.ts +++ b/shared/follow.ts @@ -1,13 +1,15 @@ /** - * What a user follows. + * Types what the signed-in user follows, as the client's `FollowProvider` + * loads it to show follow buttons pressed. * * @module */ -/** `GET /api/follows/status`: the ids of what the caller follows. */ +/** + * Returned by `GET /api/follows/status` to `FollowProvider`, which then keeps + * it up to date itself as the user follows and unfollows. + */ export interface FollowStatus { - /** The ids of the users they follow. */ followedUserIds: string[]; - /** The ids of the playlists they follow. */ followedPlaylistIds: string[]; } diff --git a/shared/mention.ts b/shared/mention.ts index 8aa2a6ae..3c90c750 100644 --- a/shared/mention.ts +++ b/shared/mention.ts @@ -1,13 +1,17 @@ /** - * The @mention pattern, one for the server and the chat. + * Defines what counts as an @mention, so that the server notifies the same + * users the chat highlights. * * @module */ /** - * Matches @username not already inside a markdown link ([...] or (...)) and - * not mid-word, so an email address doesn't mention its domain; the name is - * the first group. The server notifies whoever it matches; the chat highlights - * the same names. + * Matches an @username, with the name as its first group. The `@` must not + * follow a word character, so an email address mentions no one. Nor may it + * follow `[` or `(`, which leaves out a Markdown link's text or URL starting + * with `@`, and also a plain `(@name)`. + * `notifyMentions` notifies the users it matches, and the chat's + * `mentionsUser` highlights and chimes by it. `Markdown` links mentions with a + * looser pattern of its own, on text already parsed. */ export const MENTION_RE: RegExp = /(?<![[(\w])@(\w+)/g; diff --git a/shared/notification.ts b/shared/notification.ts index 9dece3c7..21ef3f27 100644 --- a/shared/notification.ts +++ b/shared/notification.ts @@ -1,10 +1,15 @@ /** - * Notifications: their types, and the data each type carries. + * Types the notifications `api/services/notification-service.ts` makes, and + * the data each kind carries. The client's `/notifications` page lists them + * and turns each into a sentence and a link. * * @module */ -/** What a notification is about. */ +/** + * Says which of the `…Data` interfaces a notification's `data` is. Each value + * has one `notify…` trigger in `notification-service.ts`. + */ export type NotificationType = | "playlist_followed" | "user_followed" @@ -15,69 +20,59 @@ export type NotificationType = | "dump_commented" | "comment_liked"; -/** `playlist_followed`: someone followed one of the user's playlists. */ +/** + * Carried by a `playlist_followed` notification, sent to a playlist's owner. + */ export interface PlaylistFollowedData { - /** The follower's id. */ followerId: string; - /** The follower's name. */ followerUsername: string; - /** The playlist's id. */ playlistId: string; - /** The playlist's title. */ playlistTitle: string; } -/** `user_followed`: someone followed the user. */ +/** Carried by a `user_followed` notification, sent to the user followed. */ export interface UserFollowedData { - /** The follower's id. */ followerId: string; - /** The follower's name. */ followerUsername: string; } -/** `user_dump_posted`: a user they follow posted a dump. */ +/** + * Carried by a `user_dump_posted` notification, sent to the followers of a + * user who posted a public dump. + */ export interface UserDumpPostedData { - /** The poster's id. */ dumperId: string; - /** The poster's name. */ dumperUsername: string; - /** The dump's id. */ dumpId: string; - /** The dump's title. */ dumpTitle: string; } -/** `playlist_dump_added`: a dump was added to a playlist they follow. */ +/** + * Carried by a `playlist_dump_added` notification, sent to the followers of a + * public playlist when a public dump is added to it. + */ export interface PlaylistDumpAddedData { - /** The dump's id. */ dumpId: string; - /** The dump's title. */ dumpTitle: string; - /** The playlist's id. */ playlistId: string; - /** The playlist's title. */ playlistTitle: string; } -/** `dump_upvoted`: someone upvoted one of the user's dumps. */ +/** Carried by a `dump_upvoted` notification, sent to the dump's poster. */ export interface DumpUpvotedData { - /** The voter's id. */ voterId: string; - /** The voter's name. */ voterUsername: string; - /** The dump's id. */ dumpId: string; - /** The dump's title. */ dumpTitle: string; } -/** `user_mentioned`: someone @mentioned the user. */ +/** + * Carried by a `user_mentioned` notification, sent to a user @mentioned in a + * comment, a dump, a playlist's description or a chat message. + */ export interface UserMentionedData { - /** The mentioner's id. */ mentionerId: string; - /** The mentioner's name. */ mentionerUsername: string; - /** Where the mention was written. */ contextType: "comment" | "dump" | "playlist" | "chat"; /** The id of the comment, dump, playlist or chat message. */ contextId: string; @@ -86,39 +81,36 @@ export interface UserMentionedData { * empty for the chat. */ contextTitle: string; - /** The dump a comment is under, for a mention in a comment. */ + /** + * Set only for a comment. It names the dump the comment is under, which the + * notification's link opens. + */ dumpId?: string; } -/** `dump_commented`: someone commented on one of the user's dumps. */ +/** Carried by a `dump_commented` notification, sent to the dump's poster. */ export interface DumpCommentedData { - /** The commenter's id. */ commenterId: string; - /** The commenter's name. */ commenterUsername: string; - /** The comment's id. */ commentId: string; - /** The dump's id. */ dumpId: string; - /** The dump's title. */ dumpTitle: string; } -/** `comment_liked`: someone liked one of the user's comments. */ +/** Carried by a `comment_liked` notification, sent to the comment's author. */ export interface CommentLikedData { - /** The liker's id. */ likerId: string; - /** The liker's name. */ likerUsername: string; - /** The comment's id. */ commentId: string; - /** The id of the dump it is under. */ dumpId: string; - /** The title of the dump it is under. */ dumpTitle: string; } -/** The data of any notification; its {@link NotificationType} says which. */ +/** + * A snapshot taken when the notification is made: a name or title changed + * later still shows the old one. The notification's `type` says which member + * it is. + */ export type NotificationData = | PlaylistFollowedData | UserFollowedData @@ -129,18 +121,15 @@ export type NotificationData = | DumpCommentedData | CommentLikedData; -/** A notification sent to a user. */ +/** + * Pushed to its user's sockets in a `notification_created` message when it is + * made, and returned by `GET /api/notifications`. + */ export interface Notification { - /** The notification's id. */ id: string; - /** The id of the user it is for. */ userId: string; - /** What it is about. */ type: NotificationType; - /** Its details, shaped by `type`. */ data: NotificationData; - /** Whether the user has read it. */ read: boolean; - /** When it was sent. */ createdAt: Date; } diff --git a/shared/permissions.ts b/shared/permissions.ts index b1e6a107..adf9197b 100644 --- a/shared/permissions.ts +++ b/shared/permissions.ts @@ -1,6 +1,7 @@ /** - * The role permissions, one table for the server that enforces them and the - * client that hides what a role can't do. + * Maps each role to what it may do. The server enforces the table + * (`api/lib/permissions.ts`, `api/routes/ws.ts`) and the client reads the same + * one to hide what a role can't do (`src/utils/auth/permissions.ts`). * * @module */ @@ -8,8 +9,8 @@ import type { Role } from "./user.ts"; /** - * Named capabilities checked at enforcement points. Extend this union as new - * gated capabilities are added (e.g. "invite:manage"). + * An action only some roles may take. A new one goes in this union and in + * `ALL_PERMISSIONS`, which is what grants it to admins. */ export type Permission = | "dump:moderate" @@ -19,7 +20,6 @@ export type Permission = | "user:manage" | "category:manage"; -/** Every {@link Permission}. */ const ALL_PERMISSIONS: readonly Permission[] = [ "dump:moderate", "comment:moderate", @@ -30,9 +30,8 @@ const ALL_PERMISSIONS: readonly Permission[] = [ ]; /** - * What each role may do. Roles are fixed in code. `admin` is a superset of - * every permission (current and future); `moderator` gets the - * content-moderation permissions only. + * Read through {@link hasPermission}. Roles are fixed in code. An admin holds + * every permission, and a moderator the moderation ones only. */ export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = { admin: ALL_PERMISSIONS, @@ -45,7 +44,10 @@ export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = { user: [], }; -/** Checks whether `role` grants `permission`. */ +/** + * Checks whether `role` grants `permission`. The server's `can` and the + * client's `can` both wrap it. + */ export function hasPermission(role: Role, permission: Permission): boolean { return ROLE_PERMISSIONS[role].includes(permission); } diff --git a/shared/player.ts b/shared/player.ts index 2fcf32e3..64da7f43 100644 --- a/shared/player.ts +++ b/shared/player.ts @@ -1,68 +1,65 @@ /** - * What the player's resolvers answer: a Bandcamp release's streams and a - * YouTube playlist's tracks. + * Defines what the Bandcamp and YouTube routes return to the frontend's + * player when it plays a Bandcamp release or a YouTube playlist. * * @module */ -/** A track of a Bandcamp release, as {@link Tralbum} lists it. */ +/** Listed by a {@link Tralbum}, in the release's order. */ export interface TralbumTrack { /** - * Position in the parsed array — the stable handle used to re-resolve a - * track after its signed URL expires. + * The track's position in the release's list. The player keeps it, with the + * page URL, to resolve the track again once its stream URL expires. */ index: number; - /** The track's number on the release. */ trackNum?: number; - /** The track's title. */ title: string; - /** Seconds, fractional. */ + /** In seconds, with a fraction. */ duration?: number; - /** The signed stream URL, null when the track can't be played. */ + /** A signed URL that expires. Null when the track can't be played. */ streamUrl: string | null; /** - * False for preorder, unreleased and still-encoding tracks. They stay in - * the list so numbering matches the release, but never enter the queue. + * False for preorder, unreleased and still-encoding tracks. They stay in the + * list so that the numbering matches the release, but the player skips them. */ streamable: boolean; - /** Bandcamp caps free plays per track; a capped track may serve a clip. */ + /** Set when Bandcamp capped its free plays, so it may serve only a clip. */ capped: boolean; } -/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */ +/** + * Returned by `GET /api/bandcamp/tracks` for a Bandcamp album or track page, + * when the native player plays it. + */ export interface Tralbum { - /** The page it was resolved from. */ sourceUrl: string; - /** Whether the page is an album or a single track. */ itemType: "album" | "track"; - /** The release's title. */ title?: string; - /** The artist's name. */ artist?: string; - /** The release's cover. */ artworkUrl?: string; - /** The release's tracks, in order. */ tracks: TralbumTrack[]; - /** Epoch ms of the fetch that produced these signed URLs. */ + /** + * When the stream URLs were fetched, in epoch ms. The player resolves the + * page again once they are old enough to have expired. + */ resolvedAt: number; } -/** A video of a YouTube playlist, as {@link YouTubePlaylist} lists it. */ +/** Listed by a {@link YouTubePlaylist}, in the playlist's order. */ export interface YouTubePlaylistTrack { - /** The video's id. */ videoId: string; - /** The video's title. */ title: string; - /** Seconds. */ + /** In seconds. */ duration?: number; } -/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */ +/** + * Returned by `GET /api/youtube/playlist` to the player, which shows the + * titles and lengths of a playlist embed's videos, since the embed exposes + * neither. + */ export interface YouTubePlaylist { - /** The playlist's id. */ listId: string; - /** The playlist's title. */ title?: string; - /** Its videos, in order. */ tracks: YouTubePlaylistTrack[]; } diff --git a/shared/playlist.ts b/shared/playlist.ts index f977666f..39e42ce5 100644 --- a/shared/playlist.ts +++ b/shared/playlist.ts @@ -1,80 +1,91 @@ /** - * Playlists — ordered collections of dumps — and the bodies that create, edit - * and reorder them. + * Declares playlists, the ordered collections of dumps their owners curate, and + * the bodies that create, edit and reorder them. The client shows them on + * `/playlists/:playlistId`, on profiles and in search results. * * @module */ import type { Dump } from "./dump.ts"; -/** A playlist. */ +/** + * Returned by the playlist routes, and by the search, users and dumps routes + * that list playlists. The `playlist_created` and `playlist_updated` WebSocket + * messages carry it too. + */ export interface Playlist { - /** The playlist's id. */ id: string; - /** The owner's id. */ userId: string; - /** The playlist's title. */ title: string; - /** The playlist's URL slug. */ slug?: string; - /** The owner's description, in Markdown. */ + /** It is written in Markdown. */ description?: string; - /** Whether anyone may see it, or only its owner. */ + /** + * Only its owner sees a private playlist. Moderators don't, though they may + * still edit or delete it. + */ isPublic: boolean; - /** When the playlist was created. */ createdAt: Date; - /** When the playlist was last changed, if ever. */ + /** + * `playlistImageUrl` puts it on the cover's URL, so a new cover isn't hidden + * by the cached old one. + */ updatedAt?: Date; - /** The cover image's type, undefined without one. */ + /** It is undefined when the playlist has no cover. */ imageMime?: string; /** - * How many dumps it holds, private ones included, where the query counts - * them. + * It counts the dumps the viewer can't see too. It is undefined when the + * query didn't count them, as for a new playlist or a membership. */ dumpCount?: number; - /** The owner's name, where the query joins it. */ + /** It is undefined where `dumpCount` is. */ ownerUsername?: string; } -/** A playlist with the dumps the caller may see in it, in order. */ +/** + * Returned by `GET /api/playlists/:playlistId` to `PlaylistDetail`, with the + * dumps the caller may see, in the playlist's order. + */ export interface PlaylistWithDumps extends Playlist { - /** The dumps, in the playlist's order. */ dumps: Dump[]; } /** - * `GET /api/playlists/by-dump/:dumpId/memberships`: one of the caller's - * playlists, and whether it holds the dump. + * Returned by `GET /api/playlists/by-dump/:dumpId/memberships`, one per + * playlist of the caller's. `PlaylistMembershipPanel` ticks the playlists + * with `hasDump`, in the add-to-playlist modal and the dump form. */ export interface PlaylistMembership { - /** The playlist. */ playlist: Playlist; - /** Whether it holds the dump. */ hasDump: boolean; } -/** The body of `POST /api/playlists`. */ +/** Passed to `POST /api/playlists` by `PlaylistCreateForm`. */ export interface CreatePlaylistRequest { - /** The new playlist's title. */ title: string; - /** Its description, in Markdown. */ description?: string; - /** Whether anyone may see it. */ isPublic: boolean; } -/** The body of `PUT /api/playlists/:playlistId`: the fields to change. */ +/** + * Passed to `PUT /api/playlists/:playlistId` by `PlaylistDetail`. A field left + * out keeps its value. + */ export interface UpdatePlaylistRequest { - /** The new title. */ title?: string; - /** The new description; `null` removes it. */ + /** `null` removes the description. */ description?: string | null; - /** Whether anyone may see it. */ isPublic?: boolean; } -/** The body of `PUT /api/playlists/:playlistId/order`. */ +/** + * Passed to `PUT /api/playlists/:playlistId/order` by `PlaylistDetail`, after + * a drag or to put back a dump whose removal was undone. + */ export interface ReorderPlaylistRequest { - /** Every dump of the playlist the caller can see, in the new order. */ + /** + * Every dump of the playlist the caller can see, in the new order. The dumps + * hidden from them keep their slots. + */ dumpIds: string[]; } diff --git a/shared/search.ts b/shared/search.ts index 2af19390..dfbcdf8a 100644 --- a/shared/search.ts +++ b/shared/search.ts @@ -1,5 +1,6 @@ /** - * What a search answers. + * Declares the results of the site search, which the `Search` page shows under + * one tab per kind. * * @module */ @@ -9,12 +10,17 @@ import type { Dump } from "./dump.ts"; import type { Playlist } from "./playlist.ts"; import type { PublicUser } from "./user.ts"; -/** `GET /api/search`: what matches the query, in each kind. */ +/** + * Returned by `GET /api/search` to the `Search` page. Only the dumps are paged, + * and each page brings the users and playlists again. + */ export interface SearchResults { - /** A page of the matching dumps, with their total. */ + /** + * It always has its `total`, which the page shows in the dumps tab's label. + */ dumps: PaginatedData<Dump>; - /** The matching users. */ + /** There are 20 at most. */ users: PublicUser[]; - /** The matching playlists. */ + /** There are 20 at most. */ playlists: Playlist[]; } diff --git a/shared/user.ts b/shared/user.ts index 420f5152..8531106f 100644 --- a/shared/user.ts +++ b/shared/user.ts @@ -1,129 +1,144 @@ /** - * Users as the API shows them, their roles, and the bodies and answers of - * sign-up, sign-in, profile edits and invites. + * Types users as the API sends them, their roles, and the bodies and answers + * of the `/api/users` and `/api/invites` endpoints, for the server and the + * client alike. * * @module */ -/** What a user may do beyond the basics; see `ROLE_PERMISSIONS`. */ +/** + * Sets what a user may do beyond the basics, through `ROLE_PERMISSIONS` + * (`shared/permissions.ts`). An admin changes it with + * `PATCH /api/users/:userId/role`. + */ export type Role = "user" | "moderator" | "admin"; /** Every {@link Role}, least to most privileged. */ export const ROLES: readonly Role[] = ["user", "moderator", "admin"]; -/** Checks whether `value` is a {@link Role}. */ +/** + * Checks whether `value` is a {@link Role}, as the role endpoint and the row + * guards must before trusting one. + */ export function isRole(value: unknown): value is Role { return typeof value === "string" && (ROLES as readonly string[]).includes(value); } -/** A user as anyone may see them: no password hash, no email. */ +/** + * Returned by every endpoint that shows a user to others: profiles, the users + * one follows, search results, and the `user_updated` socket message. The + * server makes it with `toPublicUser`, which drops the email. + */ export interface PublicUser { - /** The user's id. */ id: string; - /** The user's name, fixed at sign-up. */ + /** Fixed at sign-up. */ username: string; - /** The user's role. */ role: Role; - /** When the user signed up. */ createdAt: Date; - /** When the user last changed, if ever. */ + /** + * Bumped by any profile or avatar change. The client uses it to reload the + * avatar. + */ updatedAt?: Date; - /** The avatar's type, undefined without one. */ + /** Undefined without an avatar. */ avatarMime?: string; - /** The user's description of themselves, in Markdown. */ + /** Markdown. */ description?: string; - /** The name of whoever invited them. */ invitedByUsername?: string; } -/** A user as they see themselves: everything but the password hash. */ +/** + * Returned to a user about themselves: by sign-up and sign-in in + * {@link AuthResponse}, by `GET /api/users/me`, and by an avatar upload. + */ export interface SelfUser extends PublicUser { - /** The address password resets are sent to. */ - email: string; -} - -/** A match for the @mention autocomplete. */ -export interface UserSuggestion { - /** The user's id. */ - id: string; - /** The user's name. */ - username: string; - /** The avatar's type, null without one. */ - avatarMime: string | null; -} - -/** Someone a user invited, directly or down the line. */ -export interface Invitee { - /** The user's id. */ - id: string; - /** The user's name. */ - username: string; - /** The avatar's type, undefined without one. */ - avatarMime?: string; - /** The id of whoever invited them, to build the tree. */ - invitedById: string; - /** When they signed up. */ - createdAt: Date; -} - -/** The body of `POST /api/users/login`. */ -export interface LoginUserRequest { - /** The user's name. */ - username: string; - /** The user's password. */ - password: string; -} - -/** The body of `POST /api/users/register`. */ -export interface RegisterUserRequest { - /** The name to sign up with. */ - username: string; - /** The password to sign up with. */ - password: string; - /** The invite's token. */ - inviteToken: string; - /** The address password resets will be sent to. */ + /** Where password reset links go. It belongs to this account only. */ email: string; } /** - * Fields of a user to change. `PATCH /api/users/me` takes only `description` - * and `email` from it; the role and the password change through their own - * endpoints. + * Returned by `GET /api/users/search` to the editor's @mention autocomplete. + */ +export interface UserSuggestion { + id: string; + username: string; + /** Null without an avatar. */ + avatarMime: string | null; +} + +/** + * Returned, as a flat list in sign-up order, by + * `GET /api/users/:username/invitees`. The profile page nests it into a tree + * by `invitedById`. + */ +export interface Invitee { + id: string; + username: string; + /** Undefined without an avatar. */ + avatarMime?: string; + invitedById: string; + createdAt: Date; +} + +/** Sent to `POST /api/users/login` by the login page. */ +export interface LoginUserRequest { + username: string; + password: string; +} + +/** Sent to `POST /api/users/register` by the register page. */ +export interface RegisterUserRequest { + username: string; + password: string; + /** From the invite link the register page was opened with. */ + inviteToken: string; + email: string; +} + +/** + * Passed to `updateUser` by the users routes and the password reset. Of a + * client's body, `PATCH /api/users/me` takes only `description` and `email`; + * the role and the password change through their own endpoints. */ export interface UpdateUserRequest { - /** The new name. */ username?: string; - /** The new password. */ password?: string; - /** The new role. */ role?: Role; - /** The new description; `null` removes it. */ + /** An empty string clears it. Null, like undefined, keeps it. */ description?: string | null; - /** The new email address. */ email?: string; } -/** The session token, plus the media-only one for file and thumbnail URLs. */ +/** + * Returned by sign-up, sign-in and a password change. The client keeps both + * tokens with its session. + */ export interface AuthTokens { - /** The session token, sent as a bearer token. */ + /** Sent as `Authorization: Bearer …`, and as `?token=` to the WebSocket. */ token: string; - /** The token media URLs carry as `?token=`, good for nothing else. */ + /** + * Carried as `?token=` by the URLs of private media. It can read media and + * nothing else. + */ mediaToken: string; } -/** What sign-up and sign-in answer: the user and their tokens. */ +/** Returned by sign-up and sign-in. */ export interface AuthResponse extends AuthTokens { - /** The signed-in user. */ user: SelfUser; } -/** `GET /api/users/me/media-token`: a fresh media token. */ +/** + * Returned by `GET /api/users/me/media-token` to a session from before media + * tokens, which has none. + */ export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">; -/** `POST /api/invites`: the new invite's token. */ +/** + * Returned by `POST /api/invites` to the profile page, which builds the invite + * link from `token`. + */ export interface InviteResponse { - /** The token, which the invite link carries. */ token: string; } diff --git a/shared/validation.ts b/shared/validation.ts index c864e386..2ce2a9e6 100644 --- a/shared/validation.ts +++ b/shared/validation.ts @@ -1,39 +1,35 @@ /** - * The limits on what users submit, one set for the server that enforces them - * and the forms that check them before sending. + * Holds the limits on what users submit, shared so that the forms check the + * same limits the server enforces. * * @module */ -/** Limits the server enforces and the forms check before sending. */ +/** + * Read by the validators and services, which refuse what breaks a limit, and + * by the forms, which say so before sending. Lengths are JavaScript string + * lengths, so an emoji may count for two. The category limits are checked on + * the server only. + */ export const VALIDATION = { - /** Shortest username, in characters. */ USERNAME_MIN: 1, - /** Longest username. */ USERNAME_MAX: 32, - /** Shortest password. */ PASSWORD_MIN: 8, - /** Longest password. */ PASSWORD_MAX: 128, - /** Longest dump title. */ DUMP_TITLE_MAX: 200, - /** Longest dump description. */ + /** Applies to a dump's note, which the API calls its comment. */ DUMP_COMMENT_MAX: 5000, - /** Longest playlist title. */ PLAYLIST_TITLE_MAX: 100, - /** Longest playlist description. */ PLAYLIST_DESCRIPTION_MAX: 2000, - /** Longest comment. */ COMMENT_BODY_MAX: 5000, - /** Longest profile description. */ USER_DESCRIPTION_MAX: 2000, - /** Longest category name. */ CATEGORY_NAME_MAX: 50, - /** Longest category slug. */ CATEGORY_SLUG_MAX: 50, - /** Longest chat message. */ CHAT_MESSAGE_MAX: 2000, } as const; -/** Largest file a file dump may hold, in bytes (50 MB). */ +/** + * The largest file a file dump may hold, in bytes. dump-service refuses a + * bigger one, and the create form before uploading it. + */ export const DUMP_MAX_FILE_SIZE_BYTES: number = 50 * 1024 * 1024; diff --git a/shared/ws.ts b/shared/ws.ts index a4bd5080..304be70f 100644 --- a/shared/ws.ts +++ b/shared/ws.ts @@ -1,7 +1,10 @@ /** - * The socket protocol. Every message has a `type` discriminant, so each union - * narrows with a switch. The server sends these as JSON, so the client reads - * `Wire<ServerToClientMessage>`: its Dates arrive as strings. + * Defines the messages of the WebSocket between `WSProvider` in the client and + * `routes/ws.ts` in the server, as JSON objects told apart by `type`. + * + * The server's messages go out from `services/ws-service.ts`, and `WSProvider` + * handles them. Their Dates arrive as strings, so the client reads them as + * `Wire<ServerToClientMessage>` and deserializes what it keeps. * * @module */ @@ -13,117 +16,133 @@ import type { Notification } from "./notification.ts"; import type { Playlist } from "./playlist.ts"; import type { PublicUser } from "./user.ts"; -// ── Client → Server ────────────────────────────────────────────────────────── - -/** A keepalive; the other side answers {@link PongMessage}. Both directions. */ +/** + * Sent by the server to every socket every 30 s. `WSProvider` answers + * {@link PongMessage}, and a socket that missed the previous ping is closed. + * The server also answers a client's ping, though the client never sends one. + */ export interface PingMessage { - /** The discriminant. */ type: "ping"; } -/** The answer to a {@link PingMessage}. Both directions. */ +/** + * Sent in answer to a {@link PingMessage}. The server records the client's to + * keep its socket open, and the client ignores the server's. + */ export interface PongMessage { - /** The discriminant. */ type: "pong"; } -/** Upvotes a dump; answered by {@link VoteAckMessage}. */ +/** + * Sent by `WSProvider` when the user upvotes a dump, after it has shown the + * vote. The server answers {@link VoteAckMessage}, or an {@link ErrorMessage} + * naming the dump, on which the client undoes the vote, as it does when + * neither comes within 5 s. + */ export interface VoteCastMessage { - /** The discriminant. */ type: "vote_cast"; - /** The dump's id. */ dumpId: string; } -/** Withdraws an upvote; answered by {@link VoteAckMessage}. */ +/** Sent by `WSProvider` to withdraw an upvote, as {@link VoteCastMessage}. */ export interface VoteRemoveMessage { - /** The discriminant. */ type: "vote_remove"; - /** The dump's id. */ dumpId: string; } -/** Likes a comment; answered by {@link CommentLikeAckMessage}. */ +/** + * Sent by `WSProvider` when the user likes a comment, and handled as + * {@link VoteCastMessage} is, with {@link CommentLikeAckMessage} for the ack. + */ export interface CommentLikeCastMessage { - /** The discriminant. */ type: "comment_like_cast"; - /** The comment's id. */ commentId: string; } -/** Withdraws a like; answered by {@link CommentLikeAckMessage}. */ +/** + * Sent by `WSProvider` to withdraw a like, as {@link CommentLikeCastMessage}. + */ export interface CommentLikeRemoveMessage { - /** The discriminant. */ type: "comment_like_remove"; - /** The comment's id. */ commentId: string; } -/** Posts a chat message; broadcast back as {@link ChatMessageMessage}. */ +/** + * Sent by `WSProvider` when the user posts in the chatbox, which shows the + * message as pending meanwhile. The server broadcasts it as + * {@link ChatMessageMessage}, or answers a chat {@link ErrorMessage}. + */ export interface ChatSendMessage { - /** The discriminant. */ type: "chat_send"; - /** The message, in Markdown. */ body: string; - /** Id of the message being replied to, if this is a reply. */ + /** Ignored if longer than 64 characters or naming no message. */ replyToId?: string; /** - * Client-chosen id echoed on the broadcast (or on the error), so the - * sender can match the outcome to its pending message. + * Chosen by the client, and echoed on the broadcast or the error, so that + * it can match the outcome to its pending message. Ignored if longer than + * 64 characters. */ nonce?: string; } /** - * Sent while the user is composing (true) and when they stop (false). The - * server rebroadcasts the live set of typers to everyone as - * {@link ChatTypingUpdateMessage}. + * Sent by the chatbox while the user types, every 3 s, and once they pause. + * The server answers every signed-in socket with + * {@link ChatTypingUpdateMessage} when the user starts or stops, and forgets a + * typer it hasn't heard from in 6 s. */ export interface ChatTypingMessage { - /** The discriminant. */ type: "chat_typing"; - /** Whether the user is composing. */ typing: boolean; } /** - * Tells the server whether this client currently has the chatbox open, so that - * mention notifications can be skipped for users who are already reading chat. + * Sent by `WSProvider` when the chatbox opens or closes, when the tab is + * hidden or shown, and after each `welcome`. The server notifies no one of a + * chat mention while one of their sockets says `open`. */ export interface ChatFocusMessage { - /** The discriminant. */ type: "chat_focus"; - /** Whether the chatbox is open. */ + /** True only while the chatbox is open in a visible tab. */ open: boolean; } -/** Edits a chat message: the user's own, or any with `chat:moderate`. */ +/** + * Sent by `WSProvider` when the user edits a chat message, their own or, with + * `chat:moderate`, anyone's. The server broadcasts + * {@link ChatMessageUpdatedMessage}, or answers a chat {@link ErrorMessage}. + */ export interface ChatEditMessage { - /** The discriminant. */ type: "chat_edit"; - /** The message's id. */ id: string; - /** The new text, in Markdown. */ body: string; } -/** Deletes a chat message: the user's own, or any with `chat:moderate`. */ +/** + * Sent by `WSProvider` when the user deletes a chat message, as + * {@link ChatEditMessage}. The server broadcasts + * {@link ChatMessageDeletedMessage}. + */ export interface ChatDeleteMessage { - /** The discriminant. */ type: "chat_delete"; - /** The message's id. */ id: string; } -/** The user has seen the chat up to the message created at `at`. */ +/** + * Sent by `WSProvider`, at most once a second, as the user reads the chat. The + * server moves their read marker to `at`, a message's ISO `createdAt`, and + * answers {@link ChatReadUpdateMessage}. It drops the message silently when it + * is malformed or over its rate limit. + */ export interface ChatReadMessage { - /** The discriminant. */ type: "chat_read"; - /** The creation time of the last message seen, as an ISO string. */ at: string; } -/** Any message a client sends. */ +/** + * Sent by `WSProvider`, and dispatched by the route's `handleMessage`, which + * checks each field's type before using it. + */ export type ClientToServerMessage = | PingMessage | PongMessage @@ -138,285 +157,300 @@ export type ClientToServerMessage = | ChatDeleteMessage | ChatReadMessage; -// ── Server → Client ────────────────────────────────────────────────────────── - -/** A signed-in user with at least one socket open. */ +/** + * Sent by the server in {@link WelcomeMessage}, + * {@link PresenceUpdateMessage} and {@link ChatTypingUpdateMessage}, once per + * signed-in user whatever their number of sockets. + */ export interface OnlineUser { - /** The user's id. */ userId: string; - /** The user's name. */ username: string; - /** Whether the user has an avatar. */ hasAvatar: boolean; - /** When the avatar last changed, to bust the client's cache. */ + /** + * Set when the avatar changed while the user was connected, for the client + * to append to the avatar's URL so it doesn't show the cached one. + */ avatarVersion?: number; } -/** The first message on a new socket: the state the client starts from. */ +/** + * Sent by the server to a socket once it opens, just after the + * {@link PresenceUpdateMessage} that announces it. `WSProvider` replaces its + * state with this one, dropping its pending votes and likes. After a + * reconnect, it also bumps `connectionEpoch`, so that pages refetch what they + * missed. The per-user fields are empty or zero on an anonymous socket. + */ export interface WelcomeMessage { - /** The discriminant. */ type: "welcome"; - /** Who is online. */ users: OnlineUser[]; - /** The ids of the dumps the user upvoted; empty when signed out. */ myVotes: string[]; - /** The ids of the comments the user likes; empty when signed out. */ myCommentLikes: string[]; - /** How many notifications the user hasn't read. */ unreadNotificationCount: number; - /** - * The user's chat read marker (signed-in users only), as an ISO string. - */ + /** Absent on an anonymous socket. */ chatReadAt?: string; - /** How many chat messages came after the read marker. */ unreadChatCount: number; } -/** Who is online, sent to everyone whenever it changes. */ +/** + * Sent by the server to every socket when a socket opens or closes, and when + * a connected user's avatar changes. + */ export interface PresenceUpdateMessage { - /** The discriminant. */ type: "presence_update"; - /** Who is online. */ users: OnlineUser[]; } /** - * A dump's vote count changed; sent to everyone, or only to the poster for a - * private dump. + * Sent by the server after a vote, to every socket, or to the poster's alone + * for a private dump. `WSProvider` updates the count, and when `voterId` is + * the user, it updates their votes too, so their other tabs follow. */ export interface VotesUpdateMessage { - /** The discriminant. */ type: "votes_update"; - /** The dump's id. */ dumpId: string; - /** Its new vote count. */ voteCount: number; - /** Who voted. */ voterId: string; - /** Whether the vote was cast or withdrawn. */ action: "cast" | "remove"; } -/** Confirms the sender's own vote, with the count it led to. */ +/** + * Sent by the server to the socket that sent a {@link VoteCastMessage} or + * {@link VoteRemoveMessage}, once the vote is stored. `WSProvider` confirms + * its optimistic vote with it. + */ export interface VoteAckMessage { - /** The discriminant. */ type: "vote_ack"; - /** The dump's id. */ dumpId: string; - /** Whether the vote was cast or withdrawn. */ action: "cast" | "remove"; - /** The dump's new vote count. */ voteCount: number; } -/** A comment's like count changed. */ +/** + * Sent by the server after a like, as {@link VotesUpdateMessage} is after a + * vote: to the dump's poster alone when the comment is on a private dump. + */ export interface CommentLikesUpdateMessage { - /** The discriminant. */ type: "comment_likes_update"; - /** The comment's id. */ commentId: string; - /** Its new like count. */ likeCount: number; - /** Who liked it. */ likerId: string; - /** Whether the like was cast or withdrawn. */ action: "cast" | "remove"; } -/** Confirms the sender's own like, with the count it led to. */ +/** + * Sent by the server to the socket that liked or unliked a comment, as + * {@link VoteAckMessage} is for a vote. + */ export interface CommentLikeAckMessage { - /** The discriminant. */ type: "comment_like_ack"; - /** The comment's id. */ commentId: string; - /** Whether the like was cast or withdrawn. */ action: "cast" | "remove"; - /** The comment's new like count. */ likeCount: number; } -/** A dump was posted. */ +/** + * Sent by the server to every socket when a public dump is posted, or when a + * private one goes public. `WSProvider` puts it at the top of its recent + * dumps. + */ export interface DumpCreatedMessage { - /** The discriminant. */ type: "dump_created"; - /** The new dump. */ dump: Dump; } -/** A dump changed. */ +/** + * Sent by the server to every socket when a public dump changes. `WSProvider` + * refreshes it in place, without moving it up the feeds. + */ export interface DumpUpdatedMessage { - /** The discriminant. */ type: "dump_updated"; - /** The dump as it is now. */ dump: Dump; } -/** A dump was deleted. */ +/** + * Sent by the server to every socket when a dump is deleted, or when a public + * one goes private. `WSProvider` drops it from every list. + */ export interface DumpDeletedMessage { - /** The discriminant. */ type: "dump_deleted"; - /** The deleted dump's id. */ dumpId: string; } -/** A playlist was created; sent to whoever may see it. */ +/** + * Sent by the server when a playlist is created, to every socket if it is + * public, else to its owner's. `WSProvider` passes it on to the playlist pages + * as `lastPlaylistEvent`, as it does the three messages below. + */ export interface PlaylistCreatedMessage { - /** The discriminant. */ type: "playlist_created"; - /** The new playlist. */ playlist: Playlist; } -/** A playlist changed; sent to whoever may see it, or could before. */ +/** + * Sent by the server when a playlist changes, to every socket if it is public + * or was until this change, so that a playlist gone private leaves the others' + * views. + */ export interface PlaylistUpdatedMessage { - /** The discriminant. */ type: "playlist_updated"; - /** The playlist as it is now. */ playlist: Playlist; } -/** A playlist was deleted; sent to whoever could see it. */ +/** Sent by the server when a playlist is deleted, to those who could see it. */ export interface PlaylistDeletedMessage { - /** The discriminant. */ type: "playlist_deleted"; - /** The deleted playlist's id. */ playlistId: string; - /** Its owner's id. */ userId: string; } -/** A playlist's dumps changed; each recipient gets the ones they may see. */ +/** + * Sent by the server when dumps are added to, removed from or reordered in a + * playlist, to those who can see it. + */ export interface PlaylistDumpsUpdatedMessage { - /** The discriminant. */ type: "playlist_dumps_updated"; - /** The playlist's id. */ playlistId: string; - /** The members this recipient can see, in order. */ + /** + * Only the dumps this socket's user may see, in order: another user's + * private dump is left out. + */ dumpIds: string[]; - /** Every member, as Playlist.dumpCount counts them. */ + /** Counts every dump, hidden ones included, as `Playlist.dumpCount` does. */ dumpCount: number; } -/** A user's public profile changed. */ +/** + * Sent by the server to every socket after a profile edit, a role change or an + * avatar upload. `WSProvider` refreshes the user's avatar wherever it shows. + */ export interface UserUpdatedMessage { - /** The discriminant. */ type: "user_updated"; - /** The user as they are now. */ user: PublicUser; } -/** A comment was posted. */ +/** + * Sent by the server to every socket when a comment is posted on a public + * dump. Comments on a private dump are never broadcast. `WSProvider` passes it + * on to the dump's page as `lastCommentEvent`, as it does the two messages + * below. + */ export interface CommentCreatedMessage { - /** The discriminant. */ type: "comment_created"; - /** The new comment. */ comment: Comment; } -/** A comment was edited. */ +/** + * Sent by the server to every socket when a comment on a public dump is edited. + */ export interface CommentUpdatedMessage { - /** The discriminant. */ type: "comment_updated"; - /** The comment as it is now. */ comment: Comment; } -/** A comment was deleted. */ +/** + * Sent by the server to every socket when a comment on a public dump is + * deleted. + */ export interface CommentDeletedMessage { - /** The discriminant. */ type: "comment_deleted"; - /** The deleted comment's id. */ commentId: string; - /** The id of the dump it was under. */ + /** Lets a dump's page ignore the comments of other dumps. */ dumpId: string; } -/** A notification for the recipient. */ +/** + * Sent by the notification service to every socket of the notified user. + * `WSProvider` bumps the unread count and chimes. + */ export interface NotificationCreatedMessage { - /** The discriminant. */ type: "notification_created"; - /** The notification. */ notification: Notification; } -/** An action the client sent failed. */ +/** + * Sent by the server to the socket whose message failed. With `dumpId` or + * `commentId`, `WSProvider` undoes that optimistic vote or like at once rather + * than after its 5 s timeout. With `scope`, the chatbox shows it, on the + * pending message `nonce` names or as a notice. Any other error is ignored. + */ export interface ErrorMessage { - /** The discriminant. */ type: "error"; - /** What went wrong. */ + /** In English: the chatbox shows it untranslated. */ message?: string; - /** - * The dump of a failed vote. With `commentId` and `action`, it gives the - * client the context of an optimistic-action failure, so it can revert the - * exact pending vote or like immediately instead of waiting for its ACK - * timeout. - */ dumpId?: string; - /** The comment of a failed like. */ commentId?: string; - /** The failed vote's or like's action. */ action?: "cast" | "remove"; - /** Set when a chat action failed, so the client can surface it in the chat. */ scope?: "chat"; - /** Echo of the failed chat_send's nonce. */ + /** Set only on the failure of a {@link ChatSendMessage} that had one. */ nonce?: string; } -/** The session ended — the account was deleted or its token revoked. */ +/** + * Sent by the server to every socket of a user whose account is being deleted, + * just before it closes them. `WSProvider` signs the tab out. + */ export interface ForceLogoutMessage { - /** The discriminant. */ type: "force_logout"; } -/** A chat message was posted. */ +/** + * Sent by the server to every signed-in socket, the sender's included, when a + * chat message is posted. `WSProvider` adds it to the live chat, counts it + * unread unless the user is reading the chat, and may chime. + */ export interface ChatMessageMessage { - /** The discriminant. */ type: "chat_message"; - /** The new message. */ message: ChatMessage; - /** The sender's nonce, so its pending copy can be retired. */ + /** + * The sender's {@link ChatSendMessage} nonce, by which its client swaps the + * pending copy for this one. + */ nonce?: string; } -/** A chat message was edited. */ +/** + * Sent by the server to every signed-in socket when a chat message is edited. + */ export interface ChatMessageUpdatedMessage { - /** The discriminant. */ type: "chat_message_updated"; - /** The message as it is now. */ message: ChatMessage; } -/** A chat message was deleted. */ +/** + * Sent by the server to every signed-in socket when a chat message is deleted. + * The client also keeps the id, to show the replies to it as replies to a + * deleted message. + */ export interface ChatMessageDeletedMessage { - /** The discriminant. */ type: "chat_message_deleted"; - /** The deleted message's id. */ id: string; } /** - * The current set of users composing a chat message. Reuses - * {@link OnlineUser} so the client can render the same avatars as the - * presence row. + * Sent by the server to every signed-in socket when a user starts or stops + * typing in the chat, with everyone typing. The chatbox shows their avatars. */ export interface ChatTypingUpdateMessage { - /** The discriminant. */ type: "chat_typing_update"; - /** Who is composing. */ users: OnlineUser[]; } -/** The user's read marker moved (from any of their tabs or devices). */ +/** + * Sent by the server to every socket of a user after their + * {@link ChatReadMessage}, so that all their tabs and devices update the chat's + * badge. + */ export interface ChatReadUpdateMessage { - /** The discriminant. */ type: "chat_read_update"; - /** The new marker, as an ISO string. */ readAt: string; - /** How many messages came after it. */ + /** Counts only the messages of other users after `readAt`. */ unread: number; } -/** Any message the server sends. */ +/** + * Sent by `services/ws-service.ts` and the WebSocket route, and handled by + * `WSProvider`'s `onmessage`. + */ export type ServerToClientMessage = | PingMessage | PongMessage diff --git a/src/App.tsx b/src/App.tsx index 54bb6448..df27aa02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,25 +1,29 @@ /** - * The app: its providers and its routes. + * Renders the whole app, its providers around its routes. `main.tsx` mounts + * it. The routes, in the order they match, are these. * - * - `/` — redirects to the default feed tab. - * - `/~/:feedTab` — the feed of every dump ({@link Index}). - * - `/:categorySlug/:feedTab` — a category's feed; `/:categorySlug` redirects - * to - * its default tab. - * - `/dumps/:selectedDump` — a dump ({@link Dump}); `/edit` — its editor - * ({@link DumpEdit}), signed in. - * - `/login`, `/register` — sign-in and sign-up ({@link UserLogin}, - * {@link UserRegister}), signed out. - * - `/reset-password` — a new password from a reset link ({@link - * ResetPassword}). - * - `/users/:username` and `/users/:username/~/:profileTab` — a profile - * ({@link UserPublicProfile}); `/dumps`, `/upvoted`, `/playlists` — its full - * lists ({@link UserDumps}, {@link UserUpvoted}, {@link UserPlaylists}). - * - `/playlists/:playlistId` — a playlist ({@link PlaylistDetail}). - * - `/search` and `/search/~/:searchTab` — search results ({@link Search}). - * - `/notifications` — the user's notifications ({@link Notifications}), signed - * in. - * - anything else — {@link NotFound}. + * - `/` redirects to the viewer's default feed tab, keeping the query string. + * - `/~/:feedTab` is the feed of every dump (`Index`). + * - `/dumps/:selectedDump` is a dump (`Dump`), and its `/edit` the dump's + * editor (`DumpEdit`), signed in only. + * - `/register` and `/login` sign up and sign in (`UserRegister`, + * `UserLogin`), signed out only. + * - `/users/:username` and `/users/:username/~/:profileTab` are a profile + * (`UserPublicProfile`). Its `/dumps`, `/upvoted` and `/playlists` are its + * full lists (`UserDumps`, `UserUpvoted`, `UserPlaylists`). + * - `/playlists/:playlistId` is a playlist (`PlaylistDetail`). + * - `/search` and `/search/~/:searchTab` are the search results (`Search`). + * - `/reset-password` sets a new password from an emailed link + * (`ResetPassword`). + * - `/notifications` lists the user's notifications (`Notifications`), signed + * in only. + * - `/:categorySlug/:feedTab` is a category's feed (`Index`), and + * `/:categorySlug` redirects to its default tab. + * - Any other path is `NotFound`. + * + * The pages not marked are open to anyone. Every page is loaded with its + * route. Since category slugs share the root with the other routes, the server + * reserves those routes' names as slugs. * * @module */ @@ -53,7 +57,6 @@ import { ChatFab } from "./components/layout/ChatFab.tsx"; import "./App.css"; -/** The feed page, loaded with its route; so is every page below. */ const Index = lazy(() => import("./pages/Index.tsx").then((m) => ({ default: m.Index })) ); @@ -108,8 +111,8 @@ const NotFound = lazy(() => ); /** - * Returns the default feed tab for the current viewer ("followed" needs a - * session). + * Returns the viewer's default feed tab, from their settings. A signed-out + * viewer never gets "followed", which needs a session. */ function useResolvedDefaultTab() { const { user } = useAuth(); @@ -118,9 +121,9 @@ function useResolvedDefaultTab() { } /** - * Redirects `/` to the default feed tab. The query string has to survive the - * hop: the Web Share Target posts to "/", so an Android share arrives here as - * `/?share_url=…` and the feed below is the only thing that can act on it. + * Redirects `/` to the default feed tab. The query string must survive: the + * web app manifest's share target opens `/?share_url=…` for an Android share, + * and only the feed acts on it. */ function IndexRedirect() { const { search } = useLocation(); @@ -140,9 +143,9 @@ function CategoryRedirect() { } /** - * Renders the feed of `/~/:feedTab` (all) and `/:categorySlug/:feedTab` alike. - * Keying by scope remounts the feed when switching categories so it refetches, - * while switching tabs within a scope keeps it mounted (client-side re-sort). + * Renders the feed of `/~/:feedTab` and `/:categorySlug/:feedTab` alike. The + * key remounts the feed on a change of category, so that it refetches, and + * keeps it mounted on a change of tab, which sorts on the client. */ function ScopedIndex() { const { categorySlug } = useParams(); @@ -217,8 +220,9 @@ function AppRoutes() { } /** - * Renders the app: the theme, session, player, socket, follow and category - * providers around the router, the chat, the routes and the global player. + * Renders the app. The player, socket, follow and chat providers read the + * session, and `ChatProvider` reads the URL, so it sits inside the router. + * `GlobalPlayer` sits outside the routes, so playback survives navigation. */ export function App(): React.JSX.Element { return ( diff --git a/src/components/category/CategoryManager.tsx b/src/components/category/CategoryManager.tsx index cea72c86..51b8116c 100644 Binary files a/src/components/category/CategoryManager.tsx and b/src/components/category/CategoryManager.tsx differ diff --git a/src/components/category/CategorySelect.tsx b/src/components/category/CategorySelect.tsx index 41c22644..bde7686f 100644 --- a/src/components/category/CategorySelect.tsx +++ b/src/components/category/CategorySelect.tsx @@ -1,5 +1,5 @@ /** - * A dump form's category picker. + * Renders the category picker of the dump creation dialog and the dump editor. * * @module */ @@ -7,20 +7,19 @@ import { Trans } from "@lingui/react/macro"; import { useCategories } from "../../hooks/category/useCategories.ts"; -/** The props of {@link CategorySelect}. */ +/** + * Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, which + * hold the selection and send it as the dump's `categoryIds`. + */ export interface CategorySelectProps { - /** The ids of the categories chosen. */ selected: Set<string>; - /** Called with the id of a category toggled. */ onToggle: (id: string) => void; - /** Whether the chips are disabled. */ disabled?: boolean; } /** - * Renders a multi-select of categories as toggle chips. Renders nothing when - * no categories are configured, so dump forms stay unchanged until an admin - * adds the first category. + * Renders the categories as toggle chips, any number of which may be on. It + * renders nothing while the site has no categories. */ export function CategorySelect( { selected, onToggle, disabled }: CategorySelectProps, diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx index a2b438b3..f54577e6 100644 --- a/src/components/category/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -1,5 +1,6 @@ /** - * The feed's category switcher. + * Renders the menu that switches the index page's feed between all dumps and a + * category. * * @module */ @@ -18,14 +19,9 @@ import { useCategories } from "../../hooks/category/useCategories.ts"; import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; /** - * Renders the header dropdown that switches the feed scope between "All" and a - * category, preserving the active feed tab (e.g. /music/new → /cinema/new). - * Rendered as the leading item of the feed-sort tab row so it reads as part of - * the bar. - * - * A custom popover (not a native <select>) keeps it on-theme; the menu uses - * fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the - * sentinel value for the all-categories feed. Hidden when no categories exist. + * Renders the category menu at the head of `FeedTabBar`. Picking a category + * keeps the feed tab, from `/music/new` to `/cinema/new`, and "All" leads to + * `/~/new`. It renders nothing while the site has no categories. */ export function CategorySwitcher(): React.JSX.Element | null { const { categories } = useCategories(); @@ -38,8 +34,8 @@ export function CategorySwitcher(): React.JSX.Element | null { const btnRef = useRef<HTMLButtonElement>(null); const menuRef = useRef<HTMLUListElement>(null); - // Close on outside click / Escape. The menu is portaled out of the wrapper, - // so check it separately or selecting an option would close before navigating. + // The menu is portaled out of the wrapper, so a click in it would count as + // outside and close it before the option's click could navigate. useEffect(() => { if (!open) return; function onDown(e: MouseEvent) { @@ -62,7 +58,8 @@ export function CategorySwitcher(): React.JSX.Element | null { }; }, [open]); - // Anchor the fixed-position menu to the trigger; keep it aligned on scroll. + // The menu is fixed and portaled to <body>, so the tab row's + // `overflow-x: auto` can't clip it. It must follow the button on scroll. useLayoutEffect(() => { if (!open) return; const update = () => { diff --git a/src/components/chat/ChatEditBox.tsx b/src/components/chat/ChatEditBox.tsx index 4ef223f2..847375ce 100644 --- a/src/components/chat/ChatEditBox.tsx +++ b/src/components/chat/ChatEditBox.tsx @@ -1,5 +1,6 @@ /** - * The inline editor of a chat message. + * Renders the box that replaces a chat message's text while its author, or a + * moderator, edits it in the chat modal. * * @module */ @@ -10,19 +11,21 @@ import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { COUNT_FROM, sendable } from "../../utils/chat/message.ts"; import { VALIDATION } from "../../../shared/validation.ts"; -/** The props of {@link ChatEditBox}. */ +/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */ export interface ChatEditBoxProps { - /** The message's current text. */ initial: string; - /** Called with the trimmed new text. */ + /** + * Receives the trimmed text, only when it can be sent. It may be unchanged: + * the caller decides whether to send an edit. + */ onSave: (body: string) => void; - /** Called to leave without saving. */ + /** Also called on Escape. */ onCancel: () => void; } /** - * Renders an editor over a chat message's text, with Save and Cancel; it takes - * focus and scrolls itself into view once its box has settled. + * Renders an editor holding `initial`, with Save and Cancel. Enter saves and + * Shift+Enter starts a new line, as in the composer. */ export function ChatEditBox( { initial, onSave, onCancel }: ChatEditBoxProps, @@ -31,9 +34,8 @@ export function ChatEditBox( const editorRef = useRef<TextEditorHandle>(null); const boxRef = useRef<HTMLDivElement>(null); - // Focus without letting the browser scroll the list mid-transition (the - // message box is still easing to its new height); once it has settled, bring - // the whole edit box, Save included, into view. + // The message is still easing to its new height, so a scroll on focus would + // land short. Scroll once the transition is over, Save button included. useEffect(() => { editorRef.current?.focus({ atEnd: true, preventScroll: true }); const box = boxRef.current; diff --git a/src/components/chat/ChatMessageItem.tsx b/src/components/chat/ChatMessageItem.tsx index 1f1aae3e..7b2a8b3f 100644 --- a/src/components/chat/ChatMessageItem.tsx +++ b/src/components/chat/ChatMessageItem.tsx @@ -1,5 +1,6 @@ /** - * A message in the chat. + * Renders one message of the chat modal's list, with its reply, edit and + * delete actions. * * @module */ @@ -20,41 +21,43 @@ import { ReplyRef } from "./ReplyRef.tsx"; import { messageTextClass } from "../../utils/chat/message.ts"; import { clockTime } from "../../utils/chat/time.ts"; -/** The props of {@link ChatMessageItem}. */ +/** + * Passed to {@link ChatMessageItem} by `ChatModal` for each message row. The + * modal holds the edit, flash and permission state, and sends the edits and + * deletions over the socket. + */ export interface ChatMessageItemProps { - /** The message. */ message: ChatMessage; - /** Continues the previous message's run: no avatar or author line. */ + /** + * Set when the message continues its author's run. It then has no avatar or + * author line: its time shows in the avatar's place on hover, and its actions + * float over its top. + */ grouped: boolean; - /** Whether it @mentions the user, which highlights it. */ + /** Highlights a message that @mentions the user or replies to them. */ mentionsMe: boolean; - /** The replied-to message has been deleted since this one loaded. */ + /** + * Set when the replied-to message was deleted live, after the server filled + * in `replyToAuthor`. + */ replyDeleted: boolean; - /** Whether the user may edit it. */ canEdit: boolean; - /** Whether the user may delete it. */ canDelete: boolean; - /** Whether it is flashed, having just been jumped to. */ flashed: boolean; - /** Whether it is being edited. */ editing: boolean; - /** Starts editing the message `id`. */ onStartEdit: (id: string) => void; - /** Ends the editing. */ onEndEdit: () => void; - /** Saves `body` as the text of message `id`. */ + /** Called only when the text changed. */ onEdit: (id: string, body: string) => void; - /** Deletes message `id`. */ + /** Called once the user has confirmed. */ onDelete: (id: string) => void; - /** Replies to `message`. */ onReply: (message: ChatMessage) => void; - /** Scrolls to message `id`, for the reply reference. */ onJumpTo: (id: string) => void; } /** - * Renders a chat message: its author and time (unless grouped), the reply it - * answers, its text or its editor, and its reply, edit and delete actions. + * Renders a chat message, or the editor over its text while `editing`. Its + * height eases between the two. Deleting asks for confirmation first. */ export function ChatMessageItem( { @@ -136,7 +139,7 @@ export function ChatMessageItem( > {grouped ? ( - // The avatar column holds the time instead, shown on hover. + // The avatar column holds the time instead. <span className="chat-message-gutter"> <Tooltip text={message.createdAt.toLocaleString()}> <time dateTime={message.createdAt.toISOString()}> diff --git a/src/components/chat/ChatModal.tsx b/src/components/chat/ChatModal.tsx index 186fed96..4e87bc7e 100644 --- a/src/components/chat/ChatModal.tsx +++ b/src/components/chat/ChatModal.tsx @@ -1,5 +1,7 @@ /** - * The live chat, in a modal. + * Renders the site-wide chat in a modal, for signed-in users. `ChatProvider` + * mounts it while the chat is open. It reads the history over `GET /api/chat` + * and the live stream from `WSChatContext`, and writes through the latter. * * @module */ @@ -37,41 +39,35 @@ import { COUNT_FROM, sendable, snippet } from "../../utils/chat/message.ts"; import { dayLabel, isSameDay } from "../../utils/chat/time.ts"; import { VALIDATION } from "../../../shared/validation.ts"; -/** - * Matches the server's default page size; a full page means there may be more. - */ +/** The history's page size. A full page means older messages may remain. */ const PAGE_SIZE = 50; -/** - * Resync after a reconnect walks forward from the oldest loaded message in - * pages of this size; past the cap it gives up and reloads the latest page. - */ +/** The page size of the resync after a reconnect, the server's maximum. */ const RESYNC_PAGE = 100; -/** How many pages a resync walks at most. */ -const RESYNC_MAX_PAGES = 5; -/** Consecutive messages from one author within this window share a header. */ -const GROUP_WINDOW = 5 * 60_000; -/** Relative timestamps ("2 minutes ago") are refreshed this often. */ -const TICK = 30_000; -/** How long a failed edit/delete notice stays up. */ -const ERROR_TTL = 6_000; -/** How close to the bottom still counts as "at the bottom" (px). */ -const BOTTOM_SLACK = 80; /** - * How long the composer stays "typing" after the last keystroke. The server - * drops a typer it hasn't heard from in its TYPING_TTL. + * How many pages the resync reads before it gives up and reloads the latest + * page instead. */ +const RESYNC_MAX_PAGES = 5; +/** How close in time two messages of one author must be to share a header. */ +const GROUP_WINDOW = 5 * 60_000; +/** How often relative times and day labels are redrawn, in ms. */ +const TICK = 30_000; +/** How long a failed edit or delete stays shown, in ms. */ +const ERROR_TTL = 6_000; +/** How far from the bottom the list still counts as at the bottom, in px. */ +const BOTTOM_SLACK = 80; +/** How long after the last keystroke the user stops "typing", in ms. */ const TYPING_STOP_DELAY = 3_500; /** - * How often the "typing" signal is refreshed while typing continuously; below - * the server's TYPING_TTL, so a steady typist never flickers out. + * How often "typing" is resent while the user keeps typing, in ms. It must stay + * under the server's `TYPING_TTL` (6s), or a steady typist would flicker out. */ const TYPING_REFRESH = 3_000; -/** The session's `authFetch`. */ type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>; /** - * Returns the chat messages `query` selects from `GET /api/chat`. + * Returns the messages `GET /api/chat?<query>` selects, oldest first. * * @throws {Error} When the API answers a failure. */ @@ -85,18 +81,14 @@ async function fetchChat( return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage); } -/** - * Orders two messages as the stream does, matching the server's: by time, then - * id to break ties. - */ +/** Orders two messages by time, then by id, as the server pages them. */ function compareMessages(a: ChatMessage, b: ChatMessage): number { return a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); } /** - * A row of the message list: a day separator, the "new" divider, a message, or - * a pending message. + * A row of the list, which is a day separator, the "new" divider or a message. */ type Row = | { kind: "sep"; key: string; label: string } @@ -109,19 +101,18 @@ type Row = grouped: boolean; }; -/** The props of {@link ChatModal}. */ +/** Passed to {@link ChatModal} by `ChatProvider`. */ export interface ChatModalProps { - /** Called to close the chat. */ + /** Closes the chat, and drops the `?chat=open` deep link if there is one. */ onClose: () => void; } /** - * Renders the live chat in a modal: the history, loaded a page at a time as it - * is scrolled up and resynced after a reconnect, merged with the live stream - * and the messages still on their way, grouped by author and split by day, a - * "new" divider at the read marker, who is typing, and the composer, which - * keeps its draft and reply target. The read marker follows what is scrolled - * into view. + * Renders the chat, opening on the "new" divider when the unread messages + * don't fit, else at the bottom. Reaching the bottom of a visible tab moves the + * read marker. The draft and its reply target survive closing and reloading + * (`utils/chat/draft.ts`). While it is open in a visible tab, the server sends + * the user no notification for the mentions they see live. */ export function ChatModal( { onClose }: ChatModalProps, @@ -163,31 +154,27 @@ export function ChatModal( ); const [editingId, setEditingId] = useState<string | null>(null); const [flashId, setFlashId] = useState<string | null>(null); - // False until the first placement decides: opening onto the "new" divider - // must not count as having read down to the bottom. + // False until the first placement: opening onto the "new" divider must not + // mark everything below it read. const [atBottom, setAtBottom] = useState(false); const listRef = useRef<HTMLDivElement>(null); const composerRef = useRef<TextEditorHandle>(null); - // Whether the view is pinned to the bottom — controls auto-scroll on new - // messages so reading older history isn't yanked away. + // Whether new content scrolls the list down. A ref, as the scroll handler + // and the layout effects must see it at once, without a render. const stickToBottomRef = useRef(true); - // Set once the first history load has been positioned, so the layout effect - // does the initial placement exactly once. const didInitialScrollRef = useRef(false); - // Where the view was before older messages were prepended, so the layout - // effect can keep the same messages under the reader's eye. + // Where the list was before older messages were prepended, so the layout + // effect keeps the same messages in view. const prependAnchorRef = useRef<{ height: number; top: number } | null>( null, ); - // Typing-signal bookkeeping: timer that sends "stopped" after a pause, and - // the timestamp of the last "typing" refresh we sent. + // lastTypingSentRef is 0 while the server thinks the user isn't typing. const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const lastTypingSentRef = useRef(0); const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); - // Relative timestamps and "Today"/"Yesterday" go stale while the chat sits - // open; a periodic re-render keeps them honest. + // Relative times and "Today" go stale while the chat sits open. const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), TICK); @@ -198,13 +185,11 @@ export function ChatModal( writeDraft(userId, { draft, replyTarget }); }, [userId, draft, replyTarget]); - // Focus the composer when the chat opens so you can type straight away. useEffect(() => { composerRef.current?.focus(); }, []); - // Merge fetched history with the live buffer, drop deleted ones, dedupe by id - // (live buffer wins so edits show), sort into stream order. + // The live copy wins over the fetched one, since it carries later edits. const messages = useMemo(() => { const byId = new Map<string, ChatMessage>(); for (const m of history) byId.set(m.id, m); @@ -218,13 +203,12 @@ export function ChatModal( messagesRef.current = messages; }); - // Others (not me) currently composing — drives the typing indicator. + // The server's list includes the user. const typingOthers = useMemo( () => typingUsers.filter((u) => u.userId !== userId), [typingUsers, userId], ); - // Initial history load (and its retry). useEffect(() => { let cancelled = false; (async () => { @@ -248,10 +232,10 @@ export function ChatModal( setLoadAttempt((n) => n + 1); }, []); - // Whatever happened while the socket was down — new messages, edits, - // deletions — never reached the live buffer. After a reconnect, re-read the - // stream from the oldest loaded message onward and take it as the truth for - // that stretch; if too much happened for that, start over from the latest. + // Messages, edits and deletions made while the socket was down never reached + // the live buffer. After a reconnect, the stream is read again from the + // oldest loaded message on and taken as the truth for that stretch; when + // too much happened, the list starts over from the latest page. const seenEpochRef = useRef(connectionEpoch); useEffect(() => { if (connectionEpoch === seenEpochRef.current) return; @@ -261,6 +245,7 @@ export function ChatModal( try { const from = messagesRef.current[0]?.createdAt ?? new Date(0); const collected: ChatMessage[] = []; + // No `afterId` yet: the first page includes the message at `from`. let cursor = `after=${encodeURIComponent(from.toISOString())}`; let complete = false; for (let page = 0; page < RESYNC_MAX_PAGES; page++) { @@ -305,8 +290,8 @@ export function ChatModal( }; }, [authFetch, connectionEpoch, reconcileChat]); - // Whether the tab is on screen: a chat left open in a background tab isn't - // being read, so its messages should stay unread until the user comes back. + // A chat left open in a background tab isn't being read: its messages stay + // unread until the user comes back. const [tabVisible, setTabVisible] = useState(() => !document.hidden); useEffect(() => { const onVisibility = () => setTabVisible(!document.hidden); @@ -314,14 +299,12 @@ export function ChatModal( return () => document.removeEventListener("visibilitychange", onVisibility); }, []); - // Tell the server the chatbox is open so it skips redundant mention - // notifications while the user is reading; clear the flag on close. useEffect(() => { setChatOpen(true); return () => setChatOpen(false); }, [setChatOpen]); - // Stop announcing "typing" when the chatbox closes, whatever state we're in. + // Closing mid-sentence must not leave the user "typing" for others. useEffect(() => { return () => { if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current); @@ -330,16 +313,17 @@ export function ChatModal( }; }, [setChatTyping]); - // A failed edit/delete notice clears itself after a while. useEffect(() => { if (!chatError) return; const id = setTimeout(dismissChatError, ERROR_TTL); return () => clearTimeout(id); }, [chatError, dismissChatError]); - // The read marker as it stood when the chat opened: the "new" divider goes + // The read marker as it stood when the chat opened. The "new" divider goes // before the first message from someone else after it, and stays put while - // the chat is open even as the marker moves on. + // the chat is open, although the marker moves on. The marker may only come + // after the chat opens, with the socket's `welcome`, hence the render-time + // checks. const [openedReadAt, setOpenedReadAt] = useState(chatReadAt); if (!openedReadAt && chatReadAt) setOpenedReadAt(chatReadAt); const [dividerId, setDividerId] = useState<string | null | undefined>( @@ -352,15 +336,14 @@ export function ChatModal( setDividerId(first?.id ?? null); } - // What the reader has scrolled past at the bottom, for the "new messages" - // pill. Starts at the read marker so unread messages below the divider count. + // The newest time the user has seen at the bottom, for the "new messages" + // pill. It starts at the read marker, so the unread messages count. const [seenUpTo, setSeenUpTo] = useState(0); if (seenUpTo === 0 && openedReadAt) setSeenUpTo(openedReadAt.getTime()); const newest = messages.at(-1); if (atBottom && newest && newest.createdAt.getTime() > seenUpTo) { setSeenUpTo(newest.createdAt.getTime()); } - // At the bottom of a visible chat, everything is read. useEffect(() => { if (atBottom && newest && tabVisible) markChatRead(newest.createdAt); }, [atBottom, newest, tabVisible, markChatRead]); @@ -372,12 +355,10 @@ export function ChatModal( [messages, userId, seenUpTo], ); - // Keep the view where it belongs as content changes: the first placement - // lands on the "new" divider when there's more unread than fits (else the - // bottom), prepending older history keeps the same messages in view, and - // otherwise new content follows the bottom while pinned there. A layout - // effect runs before paint so there's no visible jump, and a follow-up frame - // catches late height changes (e.g. an image that loads after first paint). + // Places the list before paint as content changes. The first placement waits + // for the history and the divider. Prepended history keeps the same + // messages in view. Otherwise the list follows the bottom while pinned + // there, again one frame later for content that grows after the first paint. useLayoutEffect(() => { const el = listRef.current; if (!el) return; @@ -412,9 +393,8 @@ export function ChatModal( const node = listRef.current; if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight; }); - // `replyTarget` and `chatError` are dependencies because their bars insert - // themselves between the list and the composer, shrinking the list: when - // pinned we must re-anchor so the last message isn't hidden behind them. + // `replyTarget` and `chatError` are dependencies because their bars shrink + // the list, which would hide the last message. }, [ messages, pendingChatMessages, @@ -426,11 +406,9 @@ export function ChatModal( openedReadAt, ]); - // Follow the bottom through any change in the content's height while pinned - // there — not just new messages, but a message growing back after an edit - // (frame by frame, through its height transition), an image loading, an - // embed unfolding. Without this the list stays put and the end slides out - // of view below. + // Content also grows with no new message: an edited message easing back to + // its height, an image loading, an embed unfolding. While pinned, the list + // follows it frame by frame. const contentRef = useRef<HTMLDivElement>(null); useEffect(() => { const content = contentRef.current; @@ -483,14 +461,14 @@ export function ChatModal( setHistory((prev) => [...older, ...prev]); } } catch { - // ignore — user can retry + // The button stays, to try again. } finally { setLoadingOlder(false); } }, [authFetch, messages, loadingOlder]); - // Announce that the user is composing, throttled, and schedule a "stopped" - // signal once they pause. Called on every keystroke via the composer. + // Called on every keystroke: "typing" goes out at most every TYPING_REFRESH, + // and "stopped" after a pause of TYPING_STOP_DELAY. const pingTyping = useCallback(() => { const now = Date.now(); if (now - lastTypingSentRef.current > TYPING_REFRESH) { @@ -530,8 +508,8 @@ export function ChatModal( composerRef.current?.focus(); }, []); - // Scroll a referenced message into view and flash it briefly. No-op if it's - // not currently loaded (older history the user hasn't paged in). + // Does nothing when the message isn't loaded: older history isn't fetched to + // reach it. const jumpToMessage = useCallback((id: string) => { const node = listRef.current?.querySelector<HTMLElement>( `[data-mid="${CSS.escape(id)}"]`, @@ -553,8 +531,8 @@ export function ChatModal( stickToBottomRef.current = true; }, [draft, replyTarget, sendChatMessage, stopTyping]); - // Take a failed message back into the composer to rework it. An unfinished - // draft already there is kept, with the failed text after it. + // An unfinished draft already in the composer is kept, the failed text after + // it. const restorePending = useCallback((pending: PendingChatMessage) => { setDraft((prev) => prev.trim() ? `${prev}\n\n${pending.body}` : pending.body @@ -564,9 +542,8 @@ export function ChatModal( composerRef.current?.focus(); }, [discardChatMessage]); - // An edit swaps a message's text for a box of a different height, and the - // edit box scrolls itself into view: whether the list was following the - // bottom when the edit began is remembered, so ending it returns there. + // The edit box changes the message's height and scrolls itself into view, + // which can unpin the list. Ending the edit pins it again if it was pinned. const editPinnedRef = useRef(false); const startEdit = useCallback((id: string) => { editPinnedRef.current = stickToBottomRef.current; @@ -584,7 +561,7 @@ export function ChatModal( e.preventDefault(); handleSend(); } else if (e.key === "Escape" && replyTarget) { - // Cancel the reply first; a second Escape closes the modal. + // Only cancels the reply: a second Escape closes the modal. e.preventDefault(); setReplyTarget(null); } else if (e.key === "ArrowUp" && !draft) { @@ -601,9 +578,8 @@ export function ChatModal( const deletedIds = deletedChatIds; const username = user?.username; - // Build the rendered stream: day separators whenever the calendar day - // changes, the "new" divider, and consecutive messages from one author run - // together under a single header. Pending sends go last. + // A reply, a day separator or the "new" divider starts a new header. Pending + // messages go last, after the ones the server has. const rows = useMemo(() => { const out: Row[] = []; let prev: { userId: string; at: Date } | null = null; diff --git a/src/components/chat/PendingItem.tsx b/src/components/chat/PendingItem.tsx index dad8ff60..e77d3772 100644 --- a/src/components/chat/PendingItem.tsx +++ b/src/components/chat/PendingItem.tsx @@ -1,5 +1,6 @@ /** - * A chat message on its way. + * Renders a message the user sent to the chat that the server hasn't echoed + * back yet, at the end of the chat modal's list. * * @module */ @@ -12,27 +13,26 @@ import type { PublicUser } from "../../../shared/user.ts"; import { ReplyRef } from "./ReplyRef.tsx"; import { messageTextClass } from "../../utils/chat/message.ts"; -/** The props of {@link PendingItem}. */ +/** + * Passed to {@link PendingItem} by `ChatModal` for each of `WSChatContext`'s + * `pendingChatMessages`. + */ export interface PendingItemProps { - /** The message. */ pending: PendingChatMessage; - /** Continues the previous message's run: no avatar or author line. */ + /** Set when it continues the user's run, which hides the avatar and name. */ grouped: boolean; - /** The sender: the user. */ + /** The signed-in user, who sent it. */ user: PublicUser; - /** Resends message `nonce`. */ onRetry: (nonce: string) => void; - /** Drops message `nonce`. */ onDiscard: (nonce: string) => void; - /** Takes the message back into the composer. */ + /** Takes the failed text back into the composer, and discards it. */ onRestore: (pending: PendingChatMessage) => void; - /** Scrolls to message `id`, for the reply reference. */ onJumpTo: (id: string) => void; } /** - * Renders a message on its way: dimmed while sending; if it fails, it says why - * and offers to try again, take it back into the composer, or drop it. + * Renders a pending message, dimmed while it is sending. Once it has failed, + * it shows why and offers to retry it, edit it in the composer, or discard it. */ export function PendingItem( { pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }: diff --git a/src/components/chat/ReplyRef.tsx b/src/components/chat/ReplyRef.tsx index d4a3b815..d9daab46 100644 --- a/src/components/chat/ReplyRef.tsx +++ b/src/components/chat/ReplyRef.tsx @@ -1,5 +1,6 @@ /** - * A chat message's reference to the message it replies to. + * Renders the line above a chat message, sent or pending, that quotes the + * message it replies to. * * @module */ @@ -7,21 +8,23 @@ import { Trans } from "@lingui/react/macro"; import { snippet } from "../../utils/chat/message.ts"; -/** The props of {@link ReplyRef}. */ +/** + * Passed to {@link ReplyRef} by `ChatMessageItem`, from the message's + * `replyTo*` fields, and by `PendingItem`, from its `replyTo`. + */ export interface ReplyRefProps { - /** The replied-to message's id. */ replyToId: string; - /** Its author; undefined when it was deleted. */ + /** Undefined when the replied-to message was deleted. */ author?: string; - /** Its text. */ + /** Possibly cut short by the server, as `replyToBody` is. */ body?: string; - /** Scrolls to message `id`. */ onJumpTo: (id: string) => void; } /** - * Renders a subtle one-line reference to the replied-to message; clicking jumps - * to it. Falls back to a muted note when the target was deleted. + * Renders the replied-to message's author and a one-line snippet of its text, + * as a button that scrolls to it. When it was deleted, the button is disabled + * and says so. */ export function ReplyRef( { replyToId, author, body, onJumpTo }: ReplyRefProps, diff --git a/src/components/comment/CommentForm.tsx b/src/components/comment/CommentForm.tsx index d9e614be..c7277f95 100644 --- a/src/components/comment/CommentForm.tsx +++ b/src/components/comment/CommentForm.tsx @@ -1,5 +1,6 @@ /** - * The comment editor, for posting, replying and editing. + * Renders the form of a dump's comment thread that posts a comment, a reply, + * or an edit. * * @module */ @@ -12,37 +13,43 @@ import { FormError } from "../form/FormError.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; -/** The props of {@link CommentForm}. */ +/** + * Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by + * `CommentNode`, for a reply or an edit. + */ export interface CommentFormProps { - /** Async submit; throw to surface a root error, resolve to clear/close. */ + /** + * Sends the text. The form empties itself once it resolves. When it throws, + * as `expectOk` does, the form keeps the text and shows the error under + * `errorTitle`. + */ onSubmit: (body: string) => Promise<void>; - /** The submit button's label. */ submitLabel: React.ReactNode; - /** The submit button's label while submitting. */ pendingLabel: React.ReactNode; - /** The title of the error shown when the submit fails. */ errorTitle: string; - /** The editor's placeholder. */ placeholder?: string; - /** The initial text; empty by default. */ defaultValue?: string; - /** Called by the Cancel button; no Cancel button without it. */ + /** Without it, the form has no Cancel button. */ onCancel?: () => void; - /** Top-level form: only show Cancel once there's text to discard. */ + /** + * Shows Cancel only once there is text to throw away, and doesn't cancel on + * Escape. For the new-comment form, which has nothing to close. + */ cancelWhenEmpty?: boolean; - /** Forwarded to the editor so callers can focus it. */ editorRef?: React.Ref<TextEditorHandle>; - /** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */ + /** + * `top` puts `leading` beside the editor, for the new-comment form. `inline` + * is for replies and edits, inside a comment. + */ layout?: "inline" | "top"; - /** Shown before the editor in the `top` layout (the user's avatar). */ leading?: React.ReactNode; - /** An extra class for the form. */ + /** Applies to the `inline` layout only. */ className?: string; } /** - * Renders a single-field comment editor (post / reply / edit). Wraps the shared - * react-hook-form lifecycle so the three call sites stay identical. + * Renders a comment editor and its submit button, disabled while the text is + * blank or longer than `VALIDATION.COMMENT_BODY_MAX`. */ export function CommentForm({ onSubmit, @@ -84,8 +91,8 @@ export function CommentForm({ value={field.value} onChange={field.onChange} onSubmit={() => void submit()} - // Escape backs out of a reply or an edit; the top-level box has - // nothing to back out of. + // Escape closes a reply or an edit. The new-comment form has + // nothing to close. onCancel={onCancel && !cancelWhenEmpty ? () => { form.reset({ body: "" }); diff --git a/src/components/comment/CommentNode.tsx b/src/components/comment/CommentNode.tsx index 0081ce38..46765bde 100644 --- a/src/components/comment/CommentNode.tsx +++ b/src/components/comment/CommentNode.tsx @@ -1,5 +1,5 @@ /** - * A comment in the thread, with its replies. + * Renders a comment of a dump's thread and, recursively, its replies. * * @module */ @@ -31,31 +31,31 @@ import { useAuth } from "../../hooks/auth/useAuth.ts"; import { expectOk } from "../../utils/shared/api.ts"; import { CommentForm } from "./CommentForm.tsx"; -/** The props of {@link CommentNode}. */ +/** + * Passed to {@link CommentNode} by `CommentThread` for each top-level comment, + * and by `CommentNode` for each reply. The callbacks are `CommentThread`'s, + * passed down unchanged. + */ export interface CommentNodeProps { - /** The comment. */ comment: Comment; - /** Every comment's replies, by parent id. */ + /** The replies of every comment, by parent id, from `CommentThread`. */ tree: Map<string, Comment[]>; - /** How deep it is nested. */ + /** Sets the replies' `--depth`: the deeper, the narrower their indent. */ depth: number; - /** The id of the dump it is under. */ dumpId: string; - /** The signed-in user, null when signed out. */ currentUser: SelfUser | null; - /** The session token. */ + /** Only checked for being set before a delete. */ token: string | null; - /** Called with a reply posted. */ onCommentCreated: (comment: Comment) => void; - /** Called with the id of a comment deleted. */ onCommentDeleted: (commentId: string) => void; - /** Called with a comment edited. */ onCommentUpdated: (comment: Comment) => void; } /** - * Renders a comment — its author, time, text and like button, with reply, edit - * and delete for whoever may — and under it its replies, recursively. + * Renders a comment with its like button, and the reply, edit and delete + * actions for whoever may use them. Its author and moderators may edit and + * delete it. A deleted comment shows as "[deleted]", with its replies. The + * element's id, `comment-<id>`, is the anchor notifications link to. */ export function CommentNode({ comment, @@ -109,7 +109,7 @@ export function CommentNode({ } async function handleEditSave(body: string) { - // Saving it unchanged would only stamp it "edited". + // Saving it unchanged would only mark it "edited". if (body === comment.body) { setEditOpen(false); return; diff --git a/src/components/comment/CommentThread.tsx b/src/components/comment/CommentThread.tsx index 7b2a525d..6d0fea05 100644 --- a/src/components/comment/CommentThread.tsx +++ b/src/components/comment/CommentThread.tsx @@ -1,5 +1,5 @@ /** - * A dump's comment thread. + * Renders the comments under a dump on its page, `/dumps/:dumpId`. * * @module */ @@ -18,27 +18,28 @@ import { expectOk } from "../../utils/shared/api.ts"; import { CommentForm } from "./CommentForm.tsx"; import { CommentNode } from "./CommentNode.tsx"; -/** The props of {@link CommentThread}. */ +/** + * Passed to {@link CommentThread} by the `Dump` page, which holds the comments. + * The page applies the changes this user makes through the callbacks, and the + * ones others make from `WSContext`'s `lastCommentEvent`, so a change can come + * both ways and must be applied once. + */ export interface CommentThreadProps { - /** The dump's id. */ dumpId: string; - /** Every comment on the dump. */ + /** + * Every comment on the dump, flat and oldest first, deleted ones included so + * their replies keep their place. + */ comments: Comment[]; - /** The signed-in user, null when signed out. */ currentUser: SelfUser | null; - /** The session token. */ token: string | null; - /** Called with a comment posted. */ onCommentCreated: (comment: Comment) => void; - /** Called with the id of a comment deleted. */ onCommentDeleted: (commentId: string) => void; - /** Called with a comment edited. */ onCommentUpdated: (comment: Comment) => void; } /** - * Returns the replies of each comment of `comments`, by parent id (`root` for - * the top level). + * Returns each comment's replies by parent id, under `root` for the top level. */ function buildTree(comments: Comment[]): Map<string, Comment[]> { const map = new Map<string, Comment[]>(); @@ -51,8 +52,8 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> { } /** - * Renders the form that posts a comment (for a signed-in user), and the thread - * under it. + * Renders the comment count, the form that posts a comment when someone is + * signed in, and the tree of {@link CommentNode}. */ export function CommentThread({ dumpId, diff --git a/src/components/dump/DumpCard.tsx b/src/components/dump/DumpCard.tsx index dbd5cadd..6dd65783 100644 --- a/src/components/dump/DumpCard.tsx +++ b/src/components/dump/DumpCard.tsx @@ -1,5 +1,6 @@ /** - * A dump, as a card in the hot and new feeds. + * Renders the card that shows a dump in the feeds, search results, profiles + * and playlists. * * @module */ @@ -25,32 +26,28 @@ import { VoteButton } from "../social/VoteButton.tsx"; import { Markdown } from "../editor/Markdown.tsx"; import { Tooltip } from "../shared/Tooltip.tsx"; -/** The props of {@link DumpCard}. */ +/** + * Passed to {@link DumpCard} by each list of dumps. The vote state comes from + * the parent, usually `WSContext`'s passed down through the feed's props. + */ export interface DumpCardProps { - /** The dump. */ dump: Dump; - /** Its live vote count. */ + /** The socket's live count if it has one, else `dump.voteCount`. */ voteCount: number; - /** Whether the user upvoted it. */ voted: boolean; - /** Whether the user may vote (signed in). */ canVote: boolean; - /** Upvotes dump `id`; `shownCount` is the count on screen. */ castVote: (id: string, shownCount?: number) => void; - /** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */ removeVote: (id: string, shownCount?: number) => void; - /** An extra class. */ className?: string; - /** Whether the viewer posted it: no unread dot. */ + /** Hides the unread dot and shows the "private" badge. */ isOwner?: boolean; } /** - * Renders a dump as a card: its preview (playable where it can be), title, - * note, categories, poster, age, comment count and vote button, with an unread - * dot until it is opened. The card opens the dump. Memoized: a feed re-renders - * on every live event (a vote anywhere, a new dump), and a card whose props - * haven't changed has nothing to redraw. + * Renders a dump as a card that opens the dump when clicked. A ctrl-click or + * middle click opens it in a new tab (`useDumpCardLink`). The card is an + * `<li>`, for a `<ul>`. It is memoized because feeds re-render on every live + * event, so its vote callbacks must be stable (`WSContext`'s are). */ export const DumpCard: React.MemoExoticComponent< (props: DumpCardProps) => React.JSX.Element @@ -64,6 +61,7 @@ export const DumpCard: React.MemoExoticComponent< const dumpCategories = (dump.categoryIds ?? []) .map((id) => byId.get(id)) .filter((c) => c !== undefined); + // Posted in the last 7 days and never opened on this device. const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); @@ -74,6 +72,7 @@ export const DumpCard: React.MemoExoticComponent< {...cardProps} > <div className="dump-card-left"> + {/* Play buttons must not open the dump, unless ctrl/middle-clicked. */} <div className="dump-card-preview" onClick={dump.richContent ? (e) => e.stopPropagation() : undefined} @@ -95,6 +94,7 @@ export const DumpCard: React.MemoExoticComponent< : <ThumbnailPlaceholder url={dump.url} />} </div> + {/* Voting must not open the dump. */} <div className="dump-card-vote" onClick={(e) => e.stopPropagation()}> <VoteButton dumpId={dump.id} @@ -112,6 +112,7 @@ export const DumpCard: React.MemoExoticComponent< to={dumpUrl(dump)} className="dump-card-title" onClick={(e) => { + // Or the card's handler navigates a second time. e.stopPropagation(); markDumpVisited(dump.id); }} diff --git a/src/components/dump/FilePreview.tsx b/src/components/dump/FilePreview.tsx index f3d9f002..d1fd0099 100644 --- a/src/components/dump/FilePreview.tsx +++ b/src/components/dump/FilePreview.tsx @@ -1,6 +1,6 @@ /** - * The preview of an uploaded file: an image, a player, a PDF or a download - * link. + * Shows an uploaded file by its type, in a card or on a dump's page. Audio and + * video play in the global player or inline. * * @module */ @@ -32,24 +32,26 @@ import { import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -/** The props of {@link FilePreview}. */ +/** + * Passed to {@link FilePreview} by `DumpCard` (`compact`), by `Dump` (`global`) + * and by `DumpEdit` (neither). + */ export interface FilePreviewProps { - /** The file dump. */ + /** + * Expected to be a file dump. A link dump has no `fileMime`, so it shows as a + * download. + */ dump: Dump; - /** Whether to render the small thumbnail of a card. */ + /** Shows a card's thumbnail, whose play button uses the global player. */ compact?: boolean; - /** Whether audio and video play in the global player rather than inline. */ + /** Plays full-size audio and video in the global player, not inline. */ global?: boolean; } /** - * Returns the global-player item for an uploaded file, so every entry point - * (compact cards, the detail page, the waveform) puts the same title, artwork - * and subtitle in the player header. - * - * Artwork is only claimed when one actually exists: videos get their generated - * still and any dump can carry an uploaded thumbnail, but a bare audio file has - * neither, and pointing at the endpoint anyway would just render a placeholder. + * Returns the global player's item for an uploaded file, the same from a card + * and from the dump's page. A bare audio file gets no artwork URL, since the + * thumbnail endpoint has nothing to serve for it. */ function filePlayerItem( dump: Dump, @@ -70,9 +72,8 @@ function filePlayerItem( } /** - * Renders the waveform preview for the dump detail page — routes to the global - * player, reflects live play state and position from PlayerContext and - * PlayerTimeContext. + * Renders an audio file on its dump's page as a waveform that plays in the + * global player and shows its progress there. */ function AudioFilePreview( { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, @@ -112,8 +113,8 @@ function AudioFilePreview( if (isActive) { seekTo(ratio * duration); } else { - // Start playing and seek once it loads — seekTo after play() is a no-op - // until MediaPlayer mounts; the fraction is best-effort on first click + // The player has no media to seek yet, so a first click plays from the + // start and ignores where it landed. play(filePlayerItem(dump, fileUrl, mime, token)); } }; @@ -202,9 +203,10 @@ function mimeIcon(mime: string): string { } /** - * Renders an uploaded file by its type: compact, a thumbnail or icon that plays - * it; full size, the image, a player (or a play button for the global player), - * the PDF, or a download link. + * Renders an uploaded file by its MIME type. Compact, it is a thumbnail or an + * icon. Full size, it is the image, a player, the embedded PDF, or a download + * link for anything else. File URLs carry the viewer's media token, so private + * files load too. */ export function FilePreview( { dump, compact = false, global: useGlobal = false }: FilePreviewProps, @@ -220,8 +222,7 @@ export function FilePreview( if (compact) { if (mime.startsWith("image/")) { - // The server's preview (or the custom thumbnail): a card doesn't need - // the full upload. + // A card needs the server's scaled-down copy, not the full upload. return ( <img src={dumpThumbnailUrl(dump, token)} @@ -244,6 +245,7 @@ export function FilePreview( isPlaying ? " is-playing" : "" }`} onClick={(e) => { + // Playing must not open the dump, as a click on the card does. e.preventDefault(); e.stopPropagation(); play(filePlayerItem(dump, fileUrl, mime, token)); @@ -265,6 +267,7 @@ export function FilePreview( isPlaying ? " is-playing" : "" }`} onClick={(e) => { + // Playing must not open the dump, as a click on the card does. e.preventDefault(); e.stopPropagation(); play(filePlayerItem(dump, fileUrl, mime, token)); diff --git a/src/components/dump/JournalCard.tsx b/src/components/dump/JournalCard.tsx index 027c96e9..f8accded 100644 --- a/src/components/dump/JournalCard.tsx +++ b/src/components/dump/JournalCard.tsx @@ -1,5 +1,5 @@ /** - * A dump, as a tile of the journal's mosaic. + * Renders a dump as a tile of the "journal" tab's mosaic. * * @module */ @@ -31,30 +31,29 @@ import { usePlayRichContent, } from "../../hooks/player/usePlayRichContent.ts"; -/** The props of {@link JournalCard}. */ +/** + * Passed to {@link JournalCard} by `JournalFeed`, with the vote state `Index` + * takes from `WSContext`, as for `DumpCard`. + */ export interface JournalCardProps { - /** The dump. */ dump: Dump; - /** The tile's footprint in the mosaic. */ + /** From `composeJournal`. It sets the tile's grid span, not its look. */ shape: JournalShape; - /** Its live vote count. */ + /** The socket's live count if it has one, else `dump.voteCount`. */ voteCount: number; - /** Whether the user upvoted it. */ voted: boolean; - /** Whether the user may vote (signed in). */ canVote: boolean; - /** Upvotes dump `id`; `shownCount` is the count on screen. */ castVote: (id: string, shownCount?: number) => void; - /** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */ removeVote: (id: string, shownCount?: number) => void; - /** Whether the viewer posted it: no unread dot. */ + /** Hides the unread dot and shows the "private" badge. */ isOwner?: boolean; } /** - * Renders a dump as a mosaic tile — an image card, a pull-quote or a text card, - * by its content. A playable card plays; one that can't opens the dump. - * Memoized, like DumpCard: live events re-render the feed, not every card. + * Renders a dump as an image, a pull-quote or a text tile, depending on + * whether it has a thumbnail or a note. Clicking an image tile plays its media + * when it can, and any other tile opens the dump. It is memoized like + * `DumpCard`, so its vote callbacks must be stable. */ export const JournalCard: React.MemoExoticComponent< (props: JournalCardProps) => React.JSX.Element @@ -68,16 +67,14 @@ export const JournalCard: React.MemoExoticComponent< const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); - // A playable card plays. If playback turns out to be impossible — a Bandcamp - // page with no streams and no embed to fall back to — the card must still do - // what an unplayable one does rather than swallowing the click. + // Playback can still fail, on a Bandcamp page with no streams and no embed, + // and the click must then open the dump rather than do nothing. async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) { if (!await playRichContent(rc, dumpUrl(dump))) go(); } - // Mirrors FilePreview (the hot/new feeds) so a video shows its generated - // still here too, rather than degrading to a text card with a 🎬. An image - // shows the server's scaled-down preview, not the full upload. + // Like FilePreview's compact mode: a video shows the server's still and an + // image its scaled-down copy, not the full upload. const thumbnailUrl = dump.thumbnailMime ? dumpThumbnailUrl(dump, mediaToken) : dump.kind === "file" @@ -87,9 +84,6 @@ export const JournalCard: React.MemoExoticComponent< : null) : (dump.richContent?.thumbnailUrl ?? null); - // Content mode is independent of grid footprint: a thumbnailed dump reads as - // an image card, a thumbnail-less dump with a note becomes a pull-quote, and - // everything else falls back to a typographic text card. const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl ? "image" : hasQuote(dump) @@ -106,9 +100,8 @@ export const JournalCard: React.MemoExoticComponent< : "🔗"; const richContent = dump.richContent; - // In native mode a Bandcamp page is playable even with no stored embedUrl. - // The card's own thumbnail (dump upload or provider) becomes the player's - // header artwork. + // The tile's thumbnail, the dump's own before the provider's, becomes the + // player's artwork. const playable = richContent && canPlayRichContent(richContent) ? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined } : null; @@ -118,6 +111,7 @@ export const JournalCard: React.MemoExoticComponent< to={dumpUrl(dump)} className="journal-card-title" onClick={(e) => { + // Or the tile's own handler would navigate, or play, as well. e.stopPropagation(); markDumpVisited(dump.id); }} @@ -152,6 +146,7 @@ export const JournalCard: React.MemoExoticComponent< ); const vote = ( + // Voting must not open the dump. <div className="journal-card-vote" onClick={(e) => e.stopPropagation()} @@ -177,10 +172,9 @@ export const JournalCard: React.MemoExoticComponent< const className = `journal-card journal-card--${shape} journal-card--${mode}`; if (mode === "image") { - // feature shows its note; tall/wide/square keep the image as the subject. + // Only a feature tile has room for the note over its image. const showComment = shape === "feature" && !!dump.comment; - // For playable media the whole card plays; the title link still navigates. - // Keeping the handler on the <li> means the text overlay can't cover it. + // The handler sits on the <li>, so the text overlay can't block it. return ( <li className={className} @@ -195,8 +189,8 @@ export const JournalCard: React.MemoExoticComponent< <Thumbnail src={thumbnailUrl ?? undefined} placeholder={dump.kind === "file" - // No ffmpeg on the host means no still — the mime glyph says more - // about the dump than an initial taken from its filename. + // A video still fails without ffmpeg on the server, and the MIME + // glyph says more than an initial taken from the file name. ? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon } : { url: dump.url, @@ -239,7 +233,6 @@ export const JournalCard: React.MemoExoticComponent< ); } - // text return ( <li className={className} {...cardProps}> <span className="journal-card-glyph" aria-hidden="true"> diff --git a/src/components/dump/RichContentCard.tsx b/src/components/dump/RichContentCard.tsx index ef9f50b6..63e80553 100644 --- a/src/components/dump/RichContentCard.tsx +++ b/src/components/dump/RichContentCard.tsx @@ -1,5 +1,6 @@ /** - * A link's preview card. + * Shows a link's preview, as scraped by the server, in a card or on a dump's + * page, and plays it in the global player when it can. * * @module */ @@ -14,22 +15,27 @@ import { } from "../../hooks/player/usePlayRichContent.ts"; import { Thumbnail } from "../shared/Thumbnail.tsx"; -/** The props of {@link RichContentCard}. */ +/** + * Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`, + * `DumpEdit` and `DumpCreateModal`, the last two without a dump to play from. + */ export interface RichContentCardProps { - /** The link's preview. */ richContent: RichContent; - /** Whether to render the small thumbnail of a card. */ + /** Shows a card's thumbnail alone, as a play button or a link out. */ compact?: boolean; - /** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */ + /** + * The dump's uploaded thumbnail, shown instead of the provider's and used as + * the player's artwork. + */ thumbnailOverrideUrl?: string; - /** Link back to the owning dump, surfaced as the global player's title. */ + /** The dump's page, which the global player's title links to. */ dumpHref?: string; } /** - * Renders a link's preview: its thumbnail (or a placeholder), which plays it in - * the global player when it can be played and otherwise opens the link, and - * full size its site, title and description. + * Renders a link's preview. Its thumbnail plays the media in the global player + * when it can be played, and otherwise opens the link in a new tab. Full size, + * it also shows the site, title and description, linking out. */ export function RichContentCard( { richContent, compact = false, thumbnailOverrideUrl, dumpHref }: @@ -40,10 +46,9 @@ export function RichContentCard( const canPlay = canPlayRichContent(richContent); const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl; - // The dump's own thumbnail overrides the provider's, in the player header too. const playable = { ...richContent, thumbnailUrl: thumbnailSrc }; - // Nothing to play and no embed to fall back to: the card body is a link to - // the source, so the thumbnail behaves like one rather than going dead. + // When playback fails, the thumbnail opens the source like the card's body + // does, rather than doing nothing. const playOrOpen = async () => { if (!await playRichContent(playable, dumpHref)) { globalThis.open(richContent.url, "_blank", "noopener,noreferrer"); @@ -85,6 +90,7 @@ export function RichContentCard( }${pending ? " is-pending" : ""}`} disabled={pending} onClick={(e) => { + // Playing must not open the dump the card links to. e.preventDefault(); e.stopPropagation(); // The button says "Pause" once this card is the one playing, so it @@ -110,6 +116,7 @@ export function RichContentCard( target="_blank" rel="noopener noreferrer" className="rich-content-compact" + // Opens the source, not the dump the card links to. onClick={(e) => e.stopPropagation()} aria-label={richContent.siteName ? t`Open on ${richContent.siteName}` diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx index 8cdccaba..ac177000 100644 --- a/src/components/dump/create/DumpCreateModal.tsx +++ b/src/components/dump/create/DumpCreateModal.tsx @@ -1,5 +1,6 @@ /** - * The dump creation modal. + * The modal that posts a dump from a link or a file, then offers to add it to + * the poster's playlists. Every "new dump" button opens it. * * @module */ @@ -57,16 +58,15 @@ import { LocalFilePreview } from "./LocalFilePreview.tsx"; import { type Step, StepIndicator } from "./StepIndicator.tsx"; import { TitleField } from "./TitleField.tsx"; -/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */ +/** Kept stable so `DuplicateNotice` doesn't re-render on every keystroke. */ const NO_DUPLICATES: DumpUrlMatch[] = []; -/** The link's preview, as fetched for the form. */ +/** `reached` is false when the server couldn't fetch the page at all. */ type UrlPreview = | { status: "idle" } | { status: "loading" } | { status: "done"; reached: boolean; richContent: RichContent | null }; -/** The form's fields. */ interface CreateValues { url: string; file: File | null; @@ -76,31 +76,34 @@ interface CreateValues { } /** - * Checks whether the pointer is coarse — a touch device, where autofocusing the URL - * input raises a keyboard that covers most of the modal before the user has - * seen it. + * Checks whether the pointer is coarse. On a touch device, focusing a field + * raises a keyboard that covers most of the modal before the user has seen it. */ function prefersNoAutoFocus(): boolean { return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false; } -/** The props of {@link DumpCreateModal}. */ +/** + * Passed to {@link DumpCreateModal} by the button that opens it. The parent + * unmounts the modal on `onClose`, which the modal also calls after a + * confirmed dismissal. + */ export interface DumpCreateModalProps { - /** Called to close it. */ onClose: () => void; /** - * A link to start from (the Android share sheet); it replaces any saved - * draft. + * A link shared from Android's share sheet, which `AppHeader` passes on from + * `Index`. It replaces any saved draft. */ initialUrl?: string; } /** - * Renders the modal that posts a dump, in three panels: the link (previewed as - * it is typed, with a hint when it was already dumped) or the file, and the - * title; the note, categories and visibility; then, once posted, the playlists - * to add it to. Everything but the file is kept as a draft until it is posted, - * and closing with unsaved work asks first. + * Renders the dump creation modal. The first panel takes a link, previewed + * through `GET /api/preview` and checked against `GET /api/dumps/by-url`, or a + * file. The second takes the "why", categories and visibility, and posts to + * `POST /api/dumps`. The third adds the new dump to playlists. The new dump is + * handed to `WSContext`'s `injectDump` so the feeds show it at once. Everything + * but the file is saved as a draft (`utils/dump/draft.ts`) until it is posted. */ export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, @@ -109,7 +112,6 @@ export function DumpCreateModal( const { injectDump } = useWS(); const [step, setStep] = useState<Step>("what"); - // Which way the next panel should come in from. Purely presentational. const [direction, setDirection] = useState<"forward" | "back">("forward"); const [createdDump, setCreatedDump] = useState<Dump | null>(null); @@ -128,17 +130,18 @@ export function DumpCreateModal( const draft = restoredDraft ?? EMPTY_DUMP_DRAFT; const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft); - // Composition state const [titleEditing, setTitleEditing] = useState(false); const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser); const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({ status: "idle", }); // Tagged with the URL they describe, so a result never lingers over the next - // link during the debounce (and so clearing them needs no effect). + // link during the debounce, and clearing them needs no effect. const [duplicateState, setDuplicateState] = useState< { url: string; matches: DumpUrlMatch[] } >({ url: "", matches: [] }); + // Undefined when no upload runs, null once the body is sent and the server + // is still processing it, else the fraction sent. const [uploadProgress, setUploadProgress] = useState<number | null>(); const [confirmClose, setConfirmClose] = useState(false); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); @@ -177,7 +180,6 @@ export function DumpCreateModal( return next; }); - // Playlist step state const [memberships, setMemberships] = useState<PlaylistMembership[]>([]); const [playlistsLoading, setPlaylistsLoading] = useState(false); @@ -190,10 +192,9 @@ export function DumpCreateModal( setTitleEditing(false); }, [setValue]); - // Canonical form of `url`, used (rather than the raw input text) to decide - // whether the preview actually needs to re-fetch — e.g. "example.com" and - // "https://example.com" (set later by onBlur's normalizeUrl) canonicalize - // to the same value, so they shouldn't trigger two separate fetches. + // The effects below key on this rather than on the raw input, so that + // "example.com" and the "https://example.com" onBlur rewrites it to don't + // fetch the preview twice. let canonicalUrl: string | null; try { const u = new URL(normalizeUrl(url)); @@ -204,8 +205,8 @@ export function DumpCreateModal( canonicalUrl = null; } - // Idle state is derived below from canonicalUrl rather than set here, so - // this effect only ever needs to set loading/done (inside the timeout). + // Deriving "idle" here spares the effect below from resetting the state when + // the link is cleared. const urlPreview: UrlPreview = canonicalUrl ? urlFetchState : { status: "idle" }; @@ -214,20 +215,18 @@ export function DumpCreateModal( ? duplicateState.matches : NO_DUPLICATES; - // Debounced preview + "already dumped?" lookup. Both answer questions about - // the same link and settle at the same time, so they share one debounce. + // The preview and the duplicate lookup concern the same link, so they share + // one debounce. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (!canonicalUrl) return; - // Deferred to a microtask (rather than called directly) so the immediate - // "loading" feedback doesn't fire synchronously within the effect body — - // same timing for the user, just not in the same tick. + // Deferred: the set-state-in-effect lint rule forbids setting state + // synchronously in an effect. queueMicrotask(() => setUrlFetchState({ status: "loading" })); - // A preview can take seconds (the server fetches the page), so the link - // may have changed by the time one lands: the answer for the old link must - // not show, or suggest its title, under the new one. + // A preview can take seconds, since the server fetches the page. An answer + // for a link that has since changed must not show, or set the title. const controller = new AbortController(); const { signal } = controller; @@ -254,8 +253,7 @@ export function DumpCreateModal( }); }); - // Signed in, so that a private dump of the poster's own counts as a - // duplicate for them and for nobody else. + // Signed in, so that the poster's own private dumps count as duplicates. authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal }) .then((res) => res.json()) .then((body) => { @@ -280,9 +278,8 @@ export function DumpCreateModal( }; }, [canonicalUrl, authFetch]); - // Until the poster takes the field over, the title *is* the current page's - // title — including when the new link suggests nothing, which is why this - // writes an empty suggestion too rather than leaving the previous link's. + // Until the poster edits it, the title follows the preview. An empty + // suggestion is written too, or the previous link's title would stay. const suggestedTitle = urlPreview.status === "done" ? urlPreview.richContent?.title ?? "" : ""; @@ -291,8 +288,7 @@ export function DumpCreateModal( setValue("title", suggestedTitle); }, [mode, titleFromUser, suggestedTitle, setValue]); - // Mirror the draft on every change so a dismissal — deliberate or not — never - // costs more than the attached file, which cannot be stored. + // Once posted, the draft is cleared and must not be written back. useEffect(() => { if (step === "playlist") return; saveDumpDraft({ @@ -328,34 +324,30 @@ export function DumpCreateModal( setDraftNoticeVisible(false); }; - // Land the caret in the "why" box on arrival, so the second panel is - // immediately typeable — except on touch, where it would raise a keyboard - // over the categories this panel exists to show. + // Not on touch, where the keyboard would cover the categories. useEffect(() => { if (step !== "details" || prefersNoAutoFocus()) return; commentEditorRef.current?.focus(); }, [step]); - // Only the attached file is unrecoverable, so it is the only thing worth - // interrupting a dismissal for — the text comes back on its own. + // Only the attached file is lost on close, since the draft keeps the rest, + // so only a file makes the modal ask first. const handleBeforeClose = useCallback(() => { if (step === "playlist" || submitting || !file || confirmClose) return true; setConfirmClose(true); return false; }, [step, submitting, file, confirmClose]); - /** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */ + // Cancel asks first too, like Escape and the ✕. const requestClose = () => { if (handleBeforeClose()) onClose(); }; - // Paste handler + // A paste anywhere in the page takes a file or a link. useEffect(() => { const handler = (e: ClipboardEvent) => { - // Fields own their paste: the comment editor uploads pasted images as - // inline attachments, and the URL input has its own file handler below. - // Without this guard, pasting an image into the description would flip - // the whole modal over to a file dump. + // Fields handle their own paste: the "why" editor uploads a pasted image + // as an attachment, and the link field has its own handler below. const tag = (e.target as HTMLElement).tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; const pastedFile = e.clipboardData?.files[0]; @@ -381,9 +373,8 @@ export function DumpCreateModal( const [dragging, setDragging] = useState(false); - // Dropping is accepted over the whole modal, not just the drop zone: once a - // link is being typed the zone is hidden, and aiming at it was never the - // point of dragging a file into a "new dump" dialog. + // The whole modal takes a drop, since the drop zone is hidden while a link + // is typed. const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragging(false); @@ -408,8 +399,8 @@ export function DumpCreateModal( setValue("url", normalizedUrl); const body: CreateUrlDumpRequest = { url: normalizedUrl, - // Only when the poster typed it: otherwise the server would be pinned - // to whatever the preview happened to scrape a moment ago. + // Left to the server unless typed, so it takes the page's title + // itself. title: titleFromUser && title.trim() ? title.trim() : undefined, comment: comment.trim() || undefined, isPrivate, @@ -466,8 +457,7 @@ export function DumpCreateModal( const toggleMembership = async (membership: PlaylistMembership) => { if (!createdDump) return; const { playlist, hasDump } = membership; - // The box only changes once the server agrees; a refusal or a dropped - // connection leaves it showing the truth. + // Not optimistic: the box changes only once the server agrees. try { const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, @@ -486,14 +476,12 @@ export function DumpCreateModal( const canProceed = !!file || !!canonicalUrl; - // What the second panel's choices apply to, in one line. const recap = title.trim() || file?.name || normalizeUrl(url); const uploadPercentLabel = t`Uploading ${ Math.round((uploadProgress ?? 0) * 100) }%`; - // Enter in the link field advances rather than posts, so the panels behave - // like a form the whole way through. + // Enter on the first panel moves on to the second rather than posting. const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => { if (step === "what") { e.preventDefault(); @@ -598,7 +586,7 @@ export function DumpCreateModal( <form onSubmit={onFormSubmit} className="form"> <FormError title={t`Failed to post`} /> - {/* Keyed so each panel re-enters from the side it came from. */} + {/* Keyed so a new panel remounts and plays its slide-in. */} <div key={step} className={`dump-panel dump-panel--${direction}`} @@ -687,8 +675,7 @@ export function DumpCreateModal( )} { - /* Hidden only while a link is being written: an empty - panel offers both ways in at once, no mode to pick. */ + /* An empty panel offers both a link and a file. */ } {!(mode === "url" && url.trim()) && ( <FileField<CreateValues> @@ -718,8 +705,7 @@ export function DumpCreateModal( : ( <> { - /* The link itself is a panel back, so name the thing - these choices are about. */ + /* The link or file is a panel back, so recall it. */ } <p className="dump-panel-recap" title={recap}> <span aria-hidden="true"> diff --git a/src/components/dump/create/DuplicateNotice.tsx b/src/components/dump/create/DuplicateNotice.tsx index 5354f029..cdc1f06d 100644 --- a/src/components/dump/create/DuplicateNotice.tsx +++ b/src/components/dump/create/DuplicateNotice.tsx @@ -1,5 +1,6 @@ /** - * The create form's "already dumped" hint. + * Tells the poster, in the dump creation modal, that the link they typed was + * already dumped. * * @module */ @@ -10,17 +11,20 @@ import type { DumpUrlMatch } from "../../../../shared/dump.ts"; import { dumpUrl } from "../../../utils/shared/urls.ts"; import { relativeTime } from "../../../utils/shared/relativeTime.ts"; -/** The props of {@link DuplicateNotice}. */ +/** + * Passed to {@link DuplicateNotice} by `DumpCreateModal`, with the matches of + * `GET /api/dumps/by-url`. + */ export interface DuplicateNoticeProps { - /** The dumps already pointing at the link, most recent first. */ + /** Non-empty, latest first: the notice links the first one. */ matches: DumpUrlMatch[]; - /** Called when a link to one of them is followed. */ + /** Closes the modal when the poster follows the link. */ onNavigate: () => void; } /** - * Renders the "Already dumped" hint — never blocking, since reposts are - * sometimes on purpose. + * Renders the "already dumped" hint. It doesn't block posting, since a repost + * is sometimes on purpose. */ export function DuplicateNotice( { matches, onNavigate }: DuplicateNoticeProps, diff --git a/src/components/dump/create/LocalFilePreview.tsx b/src/components/dump/create/LocalFilePreview.tsx index 5831f78c..3bbada00 100644 --- a/src/components/dump/create/LocalFilePreview.tsx +++ b/src/components/dump/create/LocalFilePreview.tsx @@ -1,5 +1,5 @@ /** - * The preview of a picked file, before it is uploaded. + * Previews the file picked in the dump creation modal, before it is uploaded. * * @module */ @@ -7,13 +7,15 @@ import { useEffect, useState } from "react"; import { MediaPlayer } from "../../player/MediaPlayer.tsx"; -/** The props of {@link LocalFilePreview}. */ +/** Passed to {@link LocalFilePreview} by `DumpCreateModal`. */ export interface LocalFilePreviewProps { - /** The file picked. */ file: File; } -/** Renders what a picked file will look like, before it's uploaded. */ +/** + * Renders an image, video or audio file from a blob URL, which it revokes when + * `file` changes or it unmounts. Any other file renders nothing. + */ export function LocalFilePreview( { file }: LocalFilePreviewProps, ): React.JSX.Element | null { @@ -21,7 +23,8 @@ export function LocalFilePreview( useEffect(() => { const url = URL.createObjectURL(file); - // Blob URL lifecycle requires setState in effect — no synchronous alternative. + // The URL must be created and revoked with the effect, so it can't be + // derived during render. // eslint-disable-next-line react-hooks/set-state-in-effect setSrc(url); return () => { diff --git a/src/components/dump/create/StepIndicator.tsx b/src/components/dump/create/StepIndicator.tsx index de8714e4..cdfa5784 100644 --- a/src/components/dump/create/StepIndicator.tsx +++ b/src/components/dump/create/StepIndicator.tsx @@ -1,5 +1,6 @@ /** - * The create modal's steps, and where the user is in them. + * The panels of the dump creation modal, and the indicator showing which one + * is open. * * @module */ @@ -7,30 +8,25 @@ import { t } from "@lingui/core/macro"; /** - * A panel of the create modal. Posting a dump walks three panels. The split is - * deliberate: categories and visibility used to sit behind a disclosure, where - * they read as optional and went untouched — they are neither, so "why & where" - * is a stop on the way to posting rather than something to go looking for. + * A panel of `DumpCreateModal`, in the order they are walked. Categories and + * visibility have a panel of their own, "details", because behind a + * disclosure they went untouched. */ export type Step = "what" | "details" | "playlist"; -/** The panels, in order, with their labels. */ +// Labels are functions so that `t` runs at render, in the current locale. const STEPS: { id: Step; label: () => string }[] = [ { id: "what", label: () => t`Link or file` }, { id: "details", label: () => t`Why & where` }, { id: "playlist", label: () => t`Playlists` }, ]; -/** The props of {@link StepIndicator}. */ +/** Passed to {@link StepIndicator} by `DumpCreateModal`. */ export interface StepIndicatorProps { - /** The panel shown. */ current: Step; } -/** - * Renders where you are in the three panels — and, past the first, how far you - * got. - */ +/** Renders the three panels as numbered steps, the passed ones ticked. */ export function StepIndicator( { current }: StepIndicatorProps, ): React.JSX.Element { diff --git a/src/components/dump/create/TitleField.tsx b/src/components/dump/create/TitleField.tsx index 5a173243..cc068a62 100644 --- a/src/components/dump/create/TitleField.tsx +++ b/src/components/dump/create/TitleField.tsx @@ -1,5 +1,6 @@ /** - * The create form's title row. + * The title field of the dump creation modal, read-only until the poster + * chooses to edit it. * * @module */ @@ -10,25 +11,23 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { VALIDATION } from "../../../../shared/validation.ts"; -/** The props of {@link TitleField}. */ +/** + * Passed to {@link TitleField} by `DumpCreateModal`, which owns the editing + * state: it marks the title as the poster's own on `onEdit`. + */ export interface TitleFieldProps { - /** The title field's react-hook-form registration. */ registration: UseFormRegisterReturn<"title">; - /** Whether the title is being edited. */ editing: boolean; - /** Starts editing it. */ onEdit: () => void; - /** Puts the suggestion back; no reset button without it. */ + /** Puts the suggested title back. Without it, no reset button shows. */ onReset?: () => void; - /** Whether the row is disabled. */ disabled: boolean; } /** - * Renders the title row shared by both kinds of dump: the suggested title - * (scraped from the page, or derived from the filename) sits there read-only - * until the ✎ is used, which is enough to stop stray typing while keeping a - * correction one click away. Once edited, a reset offers the suggestion back. + * Renders the title of a link or file dump. The suggested title, from the + * link's preview or the file's name, stays read-only until the ✎ button is + * pressed, so stray typing can't change it. */ export function TitleField({ registration, @@ -63,6 +62,7 @@ export function TitleField({ className="dump-create-title-edit-btn" onClick={() => { onEdit(); + // The input is still disabled until the parent re-renders. setTimeout(() => inputRef.current?.focus(), 0); }} disabled={disabled} diff --git a/src/components/editor/EmojiPickerPopup.tsx b/src/components/editor/EmojiPickerPopup.tsx index 282bdeca..63f1d856 100644 --- a/src/components/editor/EmojiPickerPopup.tsx +++ b/src/components/editor/EmojiPickerPopup.tsx @@ -1,5 +1,6 @@ /** - * The editor's floating emoji picker. + * Renders the emoji picker that `TextEditor` portals to `<body>`, built on + * frimousse. `TextEditor` owns its state and its keyboard handling. * * @module */ @@ -20,7 +21,10 @@ import { Trans } from "@lingui/react/macro"; import { useLingui } from "@lingui/react"; import { readSkinTone, SKIN_TONE_KEY } from "../../utils/editor/skinTone.ts"; -/** Remembers the skin tone picked in the selector for the next picker. */ +/** + * Saves the tone picked in the selector, which only frimousse's context holds, + * so that the next picker opens with it (`readSkinTone`). + */ function SkinTonePersist() { const [skinTone] = useSkinTone(); useEffect(() => { @@ -33,34 +37,34 @@ function SkinTonePersist() { return null; } -/** The props of {@link EmojiPickerPopup}. */ +/** + * Passed to {@link EmojiPickerPopup} by `TextEditor`, which keeps the refs to + * detect outside presses, focus the search and step the keyboard into the grid. + */ export interface EmojiPickerPopupProps { - /** The ref of the floating panel, for outside-click checks. */ floatRef: Ref<HTMLDivElement>; - /** The ref of the picker's search field, to focus it. */ searchRef: Ref<HTMLInputElement>; - /** The ref of the picker's scrolling grid. */ + /** Focused by `TextEditor` to hand the arrow keys to frimousse. */ viewportRef: Ref<HTMLDivElement>; - /** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */ + /** From `useAnchoredFloat`, or hidden until it has placed the picker. */ style: CSSProperties; - /** Inline, the query is typed in the textarea and the search only shows it; - * browsing, the search field takes it. */ + /** + * Set for a `:query` typed in the textarea. The search field then only + * mirrors `query` and can't take focus. + */ inline: boolean; - /** What the picker searches for. */ query: string; - /** Called as the search field is typed in. */ onQueryChange: (query: string) => void; - /** Called with the emoji picked. */ onSelect: (emoji: string) => void; - /** Called to close the picker. */ onClose: () => void; - /** The editor's key handling while focus is in the picker. */ + /** Runs before frimousse sees the key, so it can take Escape and Enter. */ onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void; } /** - * Renders the floating emoji picker the editor opens from its button or a - * `:query`, in the current locale, remembering the skin tone. + * Renders the picker in the current locale, with the skin tone last picked on + * this device. It fetches its emoji data from the site itself, where the Vite + * build copies it (`VITE_EMOJIBASE_URL`). */ export function EmojiPickerPopup( { @@ -113,8 +117,8 @@ export function EmojiPickerPopup( </div> <EmojiPicker.Viewport ref={viewportRef} - // tabIndex={-1} makes the div programmatically focusable so - // frimousse's onFocusCapture can detect it and arm arrow-key nav + // Focusable from code only: frimousse listens for the arrows only + // once focus is inside the viewport. tabIndex={-1} > <EmojiPicker.Loading> diff --git a/src/components/editor/Markdown.tsx b/src/components/editor/Markdown.tsx index 1655cf1c..5f74915b 100644 --- a/src/components/editor/Markdown.tsx +++ b/src/components/editor/Markdown.tsx @@ -1,6 +1,6 @@ /** - * Markdown, rendered: GFM, @mentions as profile links, line breaks kept, and - * links into the app kept in the app. + * Renders the Markdown that `TextEditor` writes, wherever a dump's note, a + * comment, a chat message or a description is shown. * * @module */ @@ -10,24 +10,25 @@ import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -/** The props of {@link Markdown}. */ +/** Passed to {@link Markdown} by any component that shows a user's text. */ export interface MarkdownProps { - /** The Markdown source. */ children: string; - /** An extra class. */ className?: string; - /** Whether it is a line-clamped teaser inside a card: images are dropped. */ + /** + * Drops the images, for a line-clamped teaser in a card, where an image + * would break the layout. The full text is one click away. + */ inline?: boolean; } /** - * The mention rule the server notifies by (see shared/mention.ts), on parsed - * text: an `@` not preceded by a word character, so an email address isn't a - * mention. Link syntax is already parsed away here. + * The rule of shared/mention.ts, which the server notifies by, applied to + * parsed text: link syntax is already parsed away here, so only a preceding + * word character (an email address) rules a mention out. */ const MENTION_RE = /(?<!\w)@(\w+)/g; -/** A node of the Markdown syntax tree, the part the plugins read. */ +/** The part of a Markdown syntax tree node that the plugins below read. */ interface MdNode { type: string; value?: string; @@ -36,8 +37,8 @@ interface MdNode { } /** - * Turns bare @username into a link to that user. Works on the parsed tree - * rather than the source text, so code keeps its @s verbatim and a URL that + * Turns a bare `@username` into a link to that user's profile. It works on the + * parsed tree rather than the source, so code keeps its `@`s and a URL that * contains one (`https://mastodon.social/@bob`) isn't split in two. */ function remarkMentions() { @@ -74,10 +75,9 @@ function remarkMentions() { } /** - * Turns a single newline into a line break, as in chat everywhere: markdown's - * own rule folds it into a space, so a message typed on three lines with - * Shift+Enter came out as one. (Code keeps its newlines; it has no text - * nodes.) + * Turns a single newline into a line break, as chat apps do. Markdown folds it + * into a space, so a message typed on three lines with Shift+Enter would show + * as one. Code keeps its newlines, since it has no text nodes. */ function remarkBreaks() { const walk = (node: MdNode) => { @@ -97,17 +97,16 @@ function remarkBreaks() { return (tree: MdNode) => walk(tree); } -/** The remark plugins, in order. */ const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks]; /** - * Returns the path of a link into this app — relative, or absolute to this - * origin — or null for anywhere else (the API's files included). + * Returns the path of a link into this app, relative or absolute to this + * origin, or null for anywhere else. The API's files and attachments count as + * elsewhere, since they aren't routes of the app. */ function internalPath(href: string | undefined): string | null { if (!href) return null; if (href.startsWith("/") && !href.startsWith("//")) { - // Files and attachments are the API's, not routes: a new tab, as before. return href.startsWith("/api/") ? null : href; } try { @@ -120,17 +119,15 @@ function internalPath(href: string | undefined): string | null { } } -/** react-markdown's element overrides. */ type Components = React.ComponentProps<typeof ReactMarkdown>["components"]; /** - * The element overrides, defined once at module scope to avoid recreation on - * every render. Mentions and links to other pages here stay in the app; the - * rest open in a new tab; a wide GFM table scrolls inside its own box. + * Overrides react-markdown's elements. It is built once, since a new object on + * each render would re-render every element. */ const MARKDOWN_COMPONENTS: Components = { - // Mentions and links to other pages here stay in the app; the rest open - // in a new tab. + // Mentions and links to the app navigate in place, without a reload. The + // rest open in a new tab. a: ({ href, children: linkChildren }) => { const path = internalPath(href); if (path) return <Link to={path}>{linkChildren}</Link>; @@ -151,20 +148,16 @@ const MARKDOWN_COMPONENTS: Components = { ), }; -/** - * The element overrides of an inline rendering. Inline renderings are - * line-clamped teasers inside cards, where an embedded image blows the layout - * apart; images are dropped there — the full description is one click away on - * the detail page. - */ +/** Overrides react-markdown's elements for an `inline` rendering. */ const INLINE_MARKDOWN_COMPONENTS: Components = { ...MARKDOWN_COMPONENTS, img: () => null, }; /** - * Renders Markdown. Memoized: parsing runs the whole remark pipeline, and the - * text rarely changes when the component around it re-renders. + * Renders GFM with `@mentions` as profile links and single newlines kept. It + * is memoized because parsing runs the whole remark pipeline, and the text + * rarely changes when its parent re-renders. */ export const Markdown: React.MemoExoticComponent< (props: MarkdownProps) => React.JSX.Element diff --git a/src/components/editor/MentionDropdown.tsx b/src/components/editor/MentionDropdown.tsx index ad45bd5f..3d258602 100644 --- a/src/components/editor/MentionDropdown.tsx +++ b/src/components/editor/MentionDropdown.tsx @@ -1,5 +1,6 @@ /** - * The editor's @mention suggestions. + * Renders the `@mention` suggestions that `TextEditor` portals to `<body>`. + * `useMentionAutocomplete` fetches them and tracks the highlighted one. * * @module */ @@ -8,30 +9,32 @@ import { type CSSProperties, useLayoutEffect, useRef } from "react"; import { Avatar } from "../shared/Avatar.tsx"; import type { UserSuggestion } from "../../../shared/user.ts"; -/** The props of {@link MentionDropdown}. */ +/** + * Passed to {@link MentionDropdown} by `TextEditor`, from the state of + * `useMentionAutocomplete`. + */ export interface MentionDropdownProps { - /** For the textarea's `aria-controls` / `aria-activedescendant`. */ + /** + * The textarea's `aria-controls`. Option `i` gets the id `${id}-${i}`, which + * the textarea names as its `aria-activedescendant`. + */ id: string; - /** The matching users. */ results: UserSuggestion[]; - /** The highlighted match. */ selectedIndex: number; - /** Called with the name picked. */ onSelect: (username: string) => void; - /** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */ + /** From `useAnchoredFloat`, or hidden until it has placed the list. */ style?: CSSProperties; } /** - * Renders the users matching the `@query` as a listbox, keeping the highlighted - * one in view; nothing when none match. + * Renders the matching users as a listbox, scrolled to keep the highlighted one + * in view, or nothing when none match. The keyboard stays in the textarea. */ export function MentionDropdown( { id, results, selectedIndex, onSelect, style }: MentionDropdownProps, ): React.JSX.Element | null { const listRef = useRef<HTMLUListElement>(null); - // Arrowing past the visible rows keeps the selection in view. useLayoutEffect(() => { listRef.current ?.querySelector<HTMLElement>("[aria-selected='true']") @@ -50,7 +53,6 @@ export function MentionDropdown( {results.map((user, i) => ( <li key={user.id} - // TextEditor points aria-activedescendant at `${id}-${index}`. id={`${id}-${i}`} role="option" aria-selected={i === selectedIndex} @@ -58,7 +60,8 @@ export function MentionDropdown( i === selectedIndex ? " mention-dropdown-item--selected" : "" }`} onMouseDown={(e) => { - e.preventDefault(); // keep textarea focused + // A click would blur the textarea, which closes the list. + e.preventDefault(); onSelect(user.username); }} > diff --git a/src/components/editor/TextEditor.tsx b/src/components/editor/TextEditor.tsx index 69d600a3..0403f39f 100644 --- a/src/components/editor/TextEditor.tsx +++ b/src/components/editor/TextEditor.tsx @@ -1,6 +1,7 @@ /** - * The site's text editor: a Markdown textarea with @mention and emoji - * autocompletes, image upload by paste or drop, and a character counter. + * Provides the Markdown textarea behind every text a user writes. Forms reach + * it through `RichTextField`, while the comment and chat composers and the + * inline editors of the profile and playlist pages use it directly. * * @module */ @@ -27,58 +28,70 @@ import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { API_URL } from "../../config/api.ts"; -/** What a ref to {@link TextEditor} can do. */ +/** + * Exposed through the ref of {@link TextEditor}, so that the component that + * opens an editor (a reply, an edit, the chat composer) can focus it. + */ export interface TextEditorHandle { - /** `atEnd` puts the caret after the last character (and scrolls the text - * there); `preventScroll` leaves the page and its scrollers where they are. */ + /** + * Focuses the textarea. `atEnd` puts the caret after the last character and + * scrolls the text there. `preventScroll` leaves the page and its scrollers + * where they are. + */ focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void; } -/** The props of {@link TextEditor}. */ +/** + * Passed to {@link TextEditor} by the component that owns the text, which keeps + * it in its state (the editor is controlled). + */ export interface TextEditorProps { - /** The text. */ value: string; - /** Called with the new text. */ + /** Called on typing, and with the Markdown of the images once uploaded. */ onChange: (value: string) => void; - /** The textarea's placeholder. */ placeholder?: string; - /** Whether the textarea is disabled. */ disabled?: boolean; - /** The textarea's height, in rows. */ rows?: number; - /** The textarea's id. */ id?: string; - /** An extra class. */ className?: string; - /** Whether the textarea grows with its text. */ + /** Grows the textarea with its text instead of scrolling it. */ autoResize?: boolean; - /** Ctrl/Cmd+Enter. Not called while an image is still uploading. */ + /** Called on Ctrl/Cmd+Enter, but not while an image is still uploading. */ onSubmit?: () => void; - /** Escape, once no popup of the editor's own is left to close it: for the - * inline editors (an edit, a reply), where it means "never mind". */ + /** + * Called on Escape when no popup of the editor is left to close. Inline + * editors (an edit, a reply) pass it to mean "never mind". + */ onCancel?: () => void; - /** Runs after the editor's own keys (autocompletes, Ctrl+Enter) and never + /** + * Runs after the editor's own keys (autocompletes, Ctrl+Enter), and never * while an IME is composing, so an Enter that confirms a candidate isn't - * taken for "send". */ + * taken for "send". + */ onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; - /** The most characters it takes, counted and warned about. */ + /** + * Shows a counter and marks the textarea invalid past it. It doesn't stop + * the typing, so the caller must still refuse a longer text. + */ maxLength?: number; - /** Show the character counter only from this length. Defaults to 80% of - * `maxLength`: a standing "0 / 5000" is just noise, but the counter turns - * up well before the warning colour does. */ + /** + * The length from which the counter shows, 80% of `maxLength` by default: + * a standing "0 / 5000" is noise, but the counter should turn up well + * before the warning colour does. + */ countFrom?: number; - /** Marks the textarea invalid. */ "aria-invalid"?: boolean; - /** The id of what describes it (its error). */ + /** Joined with the id of the editor's own upload notice when it shows one. */ "aria-describedby"?: string; } -/** Checks whether `dt` carries files. */ function hasFiles(dt: DataTransfer): boolean { return Array.from(dt.types).includes("Files"); } -/** Text to put in once the textarea can take it. */ +/** + * Image Markdown held back until the upload ends and the textarea is writable. + */ interface PendingInsert { start: number; end: number; @@ -87,11 +100,11 @@ interface PendingInsert { } /** - * Renders the editor: a textarea that autocompletes @mentions and `:emoji`, - * opens an emoji picker from its button, uploads pasted or dropped images as - * attachments and inserts them as Markdown, and counts characters near the - * limit. Both popups are portaled to <body> and anchored to the textarea, so - * the cards, modals and scrollers it sits in can't clip them. + * Renders a textarea that completes `@mentions` and `:emoji`, opens an emoji + * picker from its button, and counts characters near `maxLength`. A pasted or + * dropped image is uploaded to `POST /api/attachments` with the session's + * token and inserted as Markdown, so the editor must sit under `AuthProvider`. + * The textarea is read-only while an upload runs. */ export const TextEditor: React.ForwardRefExoticComponent< TextEditorProps & React.RefAttributes<TextEditorHandle> @@ -126,7 +139,7 @@ export const TextEditor: React.ForwardRefExoticComponent< const [notice, setNotice] = useState<string | null>(null); const [dragOver, setDragOver] = useState(false); const pendingInsertRef = useRef<PendingInsert | null>(null); - // Whether the highlighted emoji was chosen with the arrows, rather than + // Set when the highlighted emoji was reached with the arrows, rather than // being frimousse's automatic first result for the query. const arrowedRef = useRef(false); const { authFetch } = useAuth(); @@ -150,12 +163,11 @@ export const TextEditor: React.ForwardRefExoticComponent< }), []); /** - * Puts `text` over `[start, end)` of the textarea, then a space when - * `word` is set and none follows already, and leaves the caret after it. - * Goes through execCommand where it can, so the insertion joins the - * browser's undo stack — Ctrl+Z takes back the emoji or mention itself, - * not everything typed before it — and fires the input event onChange - * listens for. + * Puts `text` over `[start, end)`, followed by a space when `word` is set + * and none follows already, and leaves the caret after it. It goes through + * execCommand where it can, so the insertion joins the browser's undo + * stack (Ctrl+Z takes back the emoji or mention, not everything typed + * before it) and fires the input event `onChange` listens for. */ const replace = useCallback( ( @@ -211,9 +223,9 @@ export const TextEditor: React.ForwardRefExoticComponent< } = useEmojiTrigger(replace); const emojiOpen = emojiMode !== "closed"; - // A press anywhere outside the picker puts it away — except, for an - // inline `:query`, a press in the textarea: that's a caret move, which - // keeps or closes it depending on where the caret lands. + // A press outside the picker closes it, except a press in the textarea + // while a `:query` is open: that moves the caret, and `syncEmojiTrigger` + // decides from where it lands. useEffect(() => { if (emojiMode === "closed") return; const onPointerDown = (e: PointerEvent) => { @@ -230,20 +242,18 @@ export const TextEditor: React.ForwardRefExoticComponent< document.removeEventListener("pointerdown", onPointerDown, true); }, [emojiMode, closeEmoji]); - // Both popups are portaled to <body> and anchored to the textarea: the - // editor sits inside cards, modal bodies and scrollers that clip anything - // absolutely positioned out of their box. + // Both popups are portaled to <body> and anchored to the textarea, since + // the editor sits in cards, modals and scrollers that would clip them. const emojiStyle = useAnchoredFloat(textareaRef, emojiOpen, { width: 320, - chromeHeight: 40, // search row + borders + chromeHeight: 40, // The search row and the borders. minContentHeight: 120, maxContentHeight: 220, }); - // Browsing starts in the picker's search; an inline `:query` keeps typing - // in the textarea and only lends the picker its query. Only once the - // picker is placed: until then it's hidden, and a hidden field can't take - // focus. + // Browsing starts in the picker's search, while an inline `:query` keeps + // the focus in the textarea. The search is focused only once the picker + // is placed, since a hidden field can't take focus. const emojiPlaced = emojiStyle != null; useEffect(() => { arrowedRef.current = false; @@ -255,14 +265,14 @@ export const TextEditor: React.ForwardRefExoticComponent< }, [emojiMode, emojiPlaced]); const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, { - width: "anchor", // it used to be stretched edge to edge by left/right: 0 - chromeHeight: 10, // list padding + borders + width: "anchor", + chromeHeight: 10, // The list's padding and borders. minContentHeight: 90, maxContentHeight: 220, }); - // Before paint, so the first frame — and anything measuring the layout in - // its own layout effect, like a height transition — sees the final size. + // Before paint, so that the first frame, and any layout effect measuring + // it (such as a height transition), sees the final size. useLayoutEffect(() => { if (!autoResize) return; const el = textareaRef.current; @@ -271,8 +281,8 @@ export const TextEditor: React.ForwardRefExoticComponent< el.style.height = `${el.scrollHeight}px`; }, [value, autoResize]); - // The textarea is read-only while images upload (so the spot they're - // going to can't move); once it isn't, the images go in. + // The textarea is read-only while images upload, so the spot they go to + // can't move. They go in once it is writable again. useLayoutEffect(() => { if (uploading) return; const pending = pendingInsertRef.current; @@ -323,8 +333,8 @@ export const TextEditor: React.ForwardRefExoticComponent< const handlePaste = useCallback( (e: React.ClipboardEvent<HTMLTextAreaElement>) => { // Copying from a spreadsheet or a document puts a picture of the - // selection on the clipboard next to its text: the text is what was - // meant. + // selection on the clipboard next to its text, and the text is what + // was meant. if (e.clipboardData.types.includes("text/plain")) return; const images = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/") @@ -339,7 +349,8 @@ export const TextEditor: React.ForwardRefExoticComponent< const handleDrop = useCallback( (e: React.DragEvent<HTMLTextAreaElement>) => { setDragOver(false); - if (!hasFiles(e.dataTransfer)) return; // dragged text: the default + // Dragged text is dropped by the browser. + if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); if (disabled || uploading) return; const files = Array.from(e.dataTransfer.files); @@ -352,9 +363,9 @@ export const TextEditor: React.ForwardRefExoticComponent< [uploadImages, disabled, uploading], ); - // Any file is caught, not just images: a file dropped on a textarea that - // doesn't take it is opened by the browser in place of the page, and a - // half-written message goes with it. + // Any file is caught, not just images: the browser opens a file dropped + // on a textarea that doesn't take it in place of the page, and the + // half-written text goes with it. const handleDragOver = useCallback( (e: React.DragEvent<HTMLTextAreaElement>) => { if (!hasFiles(e.dataTransfer)) return; @@ -368,11 +379,10 @@ export const TextEditor: React.ForwardRefExoticComponent< const handleDragLeave = useCallback(() => setDragOver(false), []); - // The best match for the query among the results on screen. frimousse - // lists matches in emoji order, so ":rocket" would lead with 🧑‍🚀; a name - // that is the query, then one that starts with it, comes first. (It also - // renders a hidden row of unlabelled stand-ins to measure rows with; - // those aren't picks.) + // Finds the best match among the results on screen. frimousse lists + // matches in emoji order, so ":rocket" would lead with 🧑‍🚀: a name equal + // to the query comes first, then one starting with it. The unlabelled + // buttons are a hidden row frimousse measures rows with. const firstEmoji = () => { const buttons = Array.from( floatRef.current?.querySelectorAll<HTMLButtonElement>( @@ -387,9 +397,9 @@ export const TextEditor: React.ForwardRefExoticComponent< buttons[0]; }; - // Moves the keyboard into the picker's grid: frimousse only listens for - // arrows while focus is inside it, and the arrow that got us there is - // replayed so the first press already lands on an emoji. + // Moves the focus into the picker's grid, since frimousse only listens for + // the arrows there, and replays the arrow so that the first press already + // lands on an emoji. const enterPicker = (key: string) => { const viewport = emojiViewportRef.current; if (!viewport) return; @@ -401,7 +411,8 @@ export const TextEditor: React.ForwardRefExoticComponent< }, 0); }; - /** Keys for an inline `:query` picker, pressed in the textarea. */ + // Handles a key pressed in the textarea while a `:query` is open, and + // returns whether it took it. const handleInlineEmojiKey = ( e: React.KeyboardEvent<HTMLTextAreaElement>, ): boolean => { @@ -409,8 +420,8 @@ export const TextEditor: React.ForwardRefExoticComponent< if ((e.key === "Enter" || e.key === "Tab") && plain) { const first = firstEmoji(); if (!first) { - // Nothing to pick (no match, or the data is still loading): the - // key does what it would have. + // With no match, or the data still loading, the key does what it + // would have. closeEmoji(); return false; } @@ -418,9 +429,8 @@ export const TextEditor: React.ForwardRefExoticComponent< first.click(); return true; } - // Every arrow steps into the grid, left and right included: while a - // `:query` is open they pick among its results, not move the caret - // (Escape first to get the caret back). + // Left and right step into the grid too: while a `:query` is open they + // pick among its results, and Escape gives the caret back. if (e.key.startsWith("Arrow") && plain) { if (!firstEmoji()) return false; e.preventDefault(); @@ -479,8 +489,8 @@ export const TextEditor: React.ForwardRefExoticComponent< }} onBlur={() => closeMention()} onKeyDown={(e) => { - // Enter while an IME is composing confirms the candidate; it - // mustn't also pick, send or save. + // Enter while an IME is composing confirms the candidate, and + // must not also pick, send or save. if (e.nativeEvent.isComposing || e.keyCode === 229) return; // Nothing is sent or saved with an image still on its way. if (uploading && e.key === "Enter" && !e.shiftKey) { @@ -527,7 +537,7 @@ export const TextEditor: React.ForwardRefExoticComponent< className={`text-editor-emoji-btn${ emojiMode === "browse" ? " text-editor-emoji-btn--open" : "" }`} - // Keeps the textarea's caret (and focus) where it was. + // Keeps the textarea's focus and caret where they were. onMouseDown={(e) => e.preventDefault()} onClick={openBrowser} disabled={disabled || uploading} @@ -601,8 +611,8 @@ export const TextEditor: React.ForwardRefExoticComponent< } const search = emojiSearchRef.current; if (e.key.startsWith("Arrow")) arrowedRef.current = true; - // Enter takes the emoji arrowed to, or else the best match — - // not the first result frimousse highlights on its own. + // Enter takes the emoji arrowed to, or else the best match + // rather than the first result frimousse highlights. if (e.key === "Enter" && !arrowedRef.current) { e.preventDefault(); e.stopPropagation(); @@ -617,8 +627,8 @@ export const TextEditor: React.ForwardRefExoticComponent< ) return; e.preventDefault(); if (emojiMode === "inline") { - // Typing while arrowing through the grid goes on with the - // `:query` in the textarea, which the search follows. + // Typing in the grid goes on with the `:query` in the + // textarea, which the search mirrors. const el = textareaRef.current; if (!el) return; el.focus({ preventScroll: true }); diff --git a/src/components/feed/DumpFeed.tsx b/src/components/feed/DumpFeed.tsx index e559732c..a708e7bf 100644 --- a/src/components/feed/DumpFeed.tsx +++ b/src/components/feed/DumpFeed.tsx @@ -1,5 +1,6 @@ /** - * The hot and new feeds, and the props every main feed of the index takes. + * The "hot" and "new" tabs of the index page. `Index` fetches the feed and + * passes it down as {@link MainFeedProps}. * * @module */ @@ -13,43 +14,41 @@ import type { Dump } from "../../../shared/dump.ts"; import type { SelfUser } from "../../../shared/user.ts"; import { hotScore } from "../../utils/feed/hotScore.ts"; -/** What the index hands whichever main feed its tab shows. */ +/** + * Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is + * showing. The tabs share this one state, so switching tabs doesn't refetch. + */ export interface MainFeedProps { - /** The dumps loaded so far. */ dumps: Dump[]; - /** Whether the first page is loading. */ loading: boolean; - /** Why loading failed, null when it didn't. */ error: string | null; - /** Whether another page follows. */ hasMore: boolean; - /** Whether the next page is loading. */ loadingMore: boolean; - /** - * The ref of the element that loads the next page when scrolled into view. - */ + /** From `useInfiniteScroll`. The feed places it after the last card. */ sentinelRef: Ref<HTMLDivElement>; - /** Live vote counts, by dump id. */ + /** + * Counts pushed by the socket. A dump missing here shows its fetched + * `voteCount`. + */ voteCounts: Record<string, number>; - /** The ids of the dumps the user upvoted. */ myVotes: Set<string>; - /** The signed-in user, null when signed out. */ user: SelfUser | null; - /** Upvotes dump `id`; `shownCount` is the count on screen. */ castVote: (id: string, shownCount?: number) => void; - /** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */ removeVote: (id: string, shownCount?: number) => void; } -/** The props of {@link DumpFeed}. */ +/** + * Passed to {@link DumpFeed} by `Index` for the "hot" and "new" tabs. It adds + * the active tab, as `order`, to the state all tabs share. + */ export interface DumpFeedProps extends MainFeedProps { - /** Whether the list is ordered by hot score or by date. */ order: "hot" | "new"; } /** - * Renders the hot and new tabs: the same list of dump cards, ordered by hot - * score or by date, loading more at its end. + * Renders the "hot" and "new" tabs as a list of {@link DumpCard}, sorted on the + * client, followed by the infinite-scroll sentinel. It also renders the + * loading, error and empty states. `Index` is its only user. */ export function DumpFeed( { @@ -67,6 +66,7 @@ export function DumpFeed( removeVote, }: DumpFeedProps, ): React.JSX.Element { + // The server pages by date, so "hot" only ranks the pages loaded so far. const sorted = useMemo( () => [...dumps].sort((a, b) => diff --git a/src/components/feed/FeedTabBar.tsx b/src/components/feed/FeedTabBar.tsx index 110046d3..957891fd 100644 --- a/src/components/feed/FeedTabBar.tsx +++ b/src/components/feed/FeedTabBar.tsx @@ -1,5 +1,5 @@ /** - * The index's feed tabs. + * The tab bar of the index page, with the category switcher before the tabs. * * @module */ @@ -17,15 +17,17 @@ import { TabBar } from "../shared/TabBar.tsx"; import { CategorySwitcher } from "../category/CategorySwitcher.tsx"; /** - * Renders the feed sort tabs (hot/new/journal/followed). Navigation stays - * within the current scope: `~` for all categories, or the active category slug - * — so the tabs link to `/~/new`, `/music/new`, etc. + * Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in + * user. It reads the category and tab from the URL, so a tab leads to the same + * scope: `/~/new` for every dump, `/music/new` for a category. `Index` renders + * it twice, in the header and below it, and CSS shows one by screen width. */ export function FeedTabBar(): React.JSX.Element { const { user } = useAuth(); const navigate = useNavigate(); const { categorySlug, feedTab } = useParams(); + // The same fallback as Index's, so the highlighted tab is the one shown. const [preferredTab] = useDefaultFeedTab(); const defaultTab: FeedTab = preferredTab === "followed" && !user ? DEFAULT_FEED_TAB diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx index ee7266e4..e7213965 100644 --- a/src/components/feed/FollowedFeed.tsx +++ b/src/components/feed/FollowedFeed.tsx @@ -1,5 +1,6 @@ /** - * The followed feed: dumps from the users and the playlists the user follows. + * The "followed" tab of the index page, which shows the dumps of the users and + * the playlists the signed-in user follows. * * @module */ @@ -25,7 +26,6 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts"; import type { SelfUser } from "../../../shared/user.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts"; -/** A section's state: loading, failed, or loaded with a page of dumps. */ type FeedState = | { status: "loading" } | { status: "error"; error: string } @@ -38,10 +38,10 @@ type FeedState = }; /** - * Returns a page of a followed feed from `res`. + * Returns the page of dumps in the body of `res`. * - * @throws {Error} The server's own message on an error envelope, so it isn't - * mistaken for a lost connection. + * @throws {Error} The server's own message on an error response, so that + * `friendlyFetchError` doesn't report it as a lost connection. */ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> { const body = await res.json(); @@ -51,30 +51,24 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> { return body.data as PaginatedData<Wire<Dump>>; } -/** A section of the followed feed. */ type FollowedSection = "users" | "playlists"; -/** The props of {@link FollowedFeed}. */ +/** + * Passed to {@link FollowedFeed} by `Index`, with the vote state and deleted + * dumps of `WSContext`. Unlike the other tabs, it loads its own dumps. + */ export interface FollowedFeedProps { - /** The category the feed is scoped to; all when undefined. */ + /** Scopes both sections to a category. */ categorySlug?: string; - /** Live vote counts, by dump id. */ voteCounts: Record<string, number>; - /** The ids of the dumps the user upvoted. */ myVotes: Set<string>; - /** The signed-in user. */ + /** `Index` renders the tab for a signed-in user only. */ user: SelfUser; - /** Upvotes dump `id`; `shownCount` is the count on screen. */ castVote: (id: string, shownCount?: number) => void; - /** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */ removeVote: (id: string, shownCount?: number) => void; - /** Ids of dumps deleted this session, filtered out. */ deletedDumpIds: Set<string>; } -// ── FollowedSubFeed ─────────────────────────────────────────────────────────── - -/** The props of {@link FollowedSubFeed}. */ interface FollowedSubFeedProps { state: FeedState; voteCounts: Record<string, number>; @@ -87,7 +81,7 @@ interface FollowedSubFeedProps { onLoadMore: () => void; } -/** Renders a section of the followed feed, loading more on demand. */ +/** Renders one section's list, with its own infinite-scroll sentinel. */ function FollowedSubFeed({ state, voteCounts, @@ -151,11 +145,11 @@ function FollowedSubFeed({ ); } -// ── FollowedFeed ────────────────────────────────────────────────────────────── - /** - * Renders the followed feed in two sections, from people and from playlists, - * each paginated and scoped to `categorySlug`. + * Renders the "followed" tab in two sections, "from people" and "from + * playlists", from `GET /api/follows/feed/users` and + * `GET /api/follows/feed/playlists`. Both load at once, and each keeps its own + * pages, cached like the index's feed. */ export function FollowedFeed({ categorySlug, @@ -168,7 +162,6 @@ export function FollowedFeed({ }: FollowedFeedProps): React.JSX.Element { const { token } = useAuth(); - // Scope key + query suffix applied to every followed-feed request. const scope = categorySlug ?? "all"; const catQuery = categorySlug ? `&category=${encodeURIComponent(categorySlug)}` @@ -211,7 +204,6 @@ export function FollowedFeed({ const [section, setSection] = useState<FollowedSection>("users"); - // WS sync const setUsersItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setUsersState((s) => @@ -230,7 +222,7 @@ export function FollowedFeed({ ); useDumpListSync(setPlaylistsItems); - // Fetch on mount if not cached + // A cached section starts loaded, so only the others are fetched. useEffect(() => { if (!token) return; if (usersState.status === "loading") { @@ -276,7 +268,6 @@ export function FollowedFeed({ } }, [token, usersState.status, playlistsState.status, catQuery]); - // Scroll save useScrollSave( usersState.status === "loaded", useCallback((y) => { @@ -297,7 +288,6 @@ export function FollowedFeed({ }, [playlistsState, savePlaylists]), ); - // Load-more callbacks const loadMoreUsers = useCallback(() => { if ( usersState.status !== "loaded" || !usersState.hasMore || diff --git a/src/components/feed/JournalFeed.tsx b/src/components/feed/JournalFeed.tsx index 24dbad2b..38f5f076 100644 --- a/src/components/feed/JournalFeed.tsx +++ b/src/components/feed/JournalFeed.tsx @@ -1,5 +1,6 @@ /** - * The journal feed: the editorial mosaic. + * The "journal" tab of the index page, which lays the feed out as a mosaic of + * tiles of several sizes. * * @module */ @@ -13,8 +14,9 @@ import { composeJournal } from "../../utils/feed/journalLayout.ts"; import type { MainFeedProps } from "./DumpFeed.tsx"; /** - * Renders the dumps as the journal's mosaic, block by block, loading more at - * its end. + * Renders the feed `Index` passes as blocks of {@link JournalCard}, laid out + * by `composeJournal`, followed by the infinite-scroll sentinel. It also + * renders the loading, error and empty states. */ export function JournalFeed( { @@ -52,10 +54,9 @@ export function JournalFeed( return ( <> { - /* Each sub-block is its own grid, with a themable <hr> between them. - A break sits between two grids rather than inside one because a grid - item would be stretched to the 9rem row track — out here its height - is its own, down to nothing for a theme that wants no separation. */ + /* Each block is its own grid, with a themable <hr> between them. Inside + a grid the <hr> would be stretched to a 9rem row, while out here a + theme can shrink it to nothing. */ } <div className="journal-feed"> {blocks.map((block, index) => ( diff --git a/src/components/feed/PresenceRow.tsx b/src/components/feed/PresenceRow.tsx index 6ccb3485..5ddece2d 100644 --- a/src/components/feed/PresenceRow.tsx +++ b/src/components/feed/PresenceRow.tsx @@ -1,5 +1,5 @@ /** - * The row of who's online, in the feed's header. + * Shows who is online, as a row of avatars next to the index page's tabs. * * @module */ @@ -11,36 +11,30 @@ import { Avatar } from "../shared/Avatar.tsx"; import { useWSPresence } from "../../hooks/ws/useWS.ts"; import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts"; -/** - * Past this many, the row ends in a "+N" counter rather than growing, so a busy - * evening doesn't push the feed tabs out of the header. - */ +// A seat is an avatar or the "+N" counter. Past MAX_SEATS, the counter takes +// the rest, so that a busy evening doesn't push the tabs out of the header. +// Below MIN_SEATS, the tabs scroll instead. const MAX_SEATS = 6; -/** - * However tight the row, it keeps an avatar and the counter — past that, the - * tabs beside it scroll instead. - */ const MIN_SEATS = 2; -/** Each seat (avatar or counter) is 32px wide… */ const SEAT = 32; -/** - * …and overlaps the one before it by 10px (.index-presence-avatar's negative - * margin), so adds 22px. - */ +// A seat overlaps the one before it by .index-presence-avatar's -10px margin. const SEAT_STEP = 22; -/** The props of {@link PresenceRow}. */ +/** + * Passed to {@link PresenceRow} by `Index`, which renders one in the header + * and one below it. Both selectors go to `useRoomBeside`. + */ export interface PresenceRowProps { - /** An ancestor selector bounding the row it shares. */ + /** The ancestor that bounds the row the avatars share. */ fitWithin?: string; - /** A selector for what comes first in that row: the tabs. */ + /** The tabs, which keep their width before the avatars get any. */ fitBeside?: string; } /** - * Renders who's online, as a row of overlapping avatars. Given `fitWithin` and - * `fitBeside`, it gives up seats to what's beside it — as many avatars as the - * room left by the tabs allows, the rest folded into the counter. + * Renders the online users from `WSContext` as overlapping avatars linking to + * their profiles, as many as the tabs leave room for, and a "+N" counter for + * the others. It renders nothing when nobody is online. */ export function PresenceRow( { fitWithin, fitBeside }: PresenceRowProps, diff --git a/src/components/form/FileDropZone.tsx b/src/components/form/FileDropZone.tsx index d0666448..d984024d 100644 --- a/src/components/form/FileDropZone.tsx +++ b/src/components/form/FileDropZone.tsx @@ -1,5 +1,6 @@ /** - * A file drop zone, which is also a file picker. + * Renders the zone that takes a dump's file, by drop or by picker. `FileField` + * binds it to a form. * * @module */ @@ -9,7 +10,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { formatBytes } from "../../utils/shared/format.ts"; -/** Returns the icon for a file of type `mime`. */ function fileIcon(mime: string): string { if (mime.startsWith("image/")) return "🖼"; if (mime.startsWith("video/")) return "🎬"; @@ -18,23 +18,30 @@ function fileIcon(mime: string): string { return "📎"; } -/** The props of {@link FileDropZone}. */ +/** + * Passed to {@link FileDropZone} by `FileField`, from the form's state. The + * zone keeps no file of its own. + */ export interface FileDropZoneProps { - /** The chosen file, null without one. */ file: File | null; - /** Called with the file dropped or picked, or null when it is removed. */ + /** + * Called with the file dropped or picked, or with null when it is removed. + */ onChange: (file: File | null) => void; - /** Whether it takes no file. */ disabled?: boolean; - /** The prompt shown while empty; "Drop a file here" by default. */ + /** Replaces the "Drop a file here" prompt. */ hint?: string; - /** Whether to state the size limit; true by default. */ + /** + * Shows "Max 50 MB", which is written out rather than read from + * `DUMP_MAX_FILE_SIZE_BYTES`. The zone doesn't enforce it. + */ showLimit?: boolean; } /** - * Renders a zone that takes a dropped file, or opens a file picker when - * clicked, and shows the file once chosen, with a button to remove it. + * Renders a zone that takes a dropped file, or opens the file picker on a click + * or Enter. Once a file is chosen, it shows its name and size and a button to + * remove it. Only the first of several dropped files is kept. */ export function FileDropZone({ file, @@ -56,6 +63,7 @@ export function FileDropZone({ ); const handleDragLeave = useCallback((e: React.DragEvent) => { + // Moving onto a child fires dragleave on the zone too. if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDragging(false); } @@ -78,6 +86,7 @@ export function FileDropZone({ const handleClear = useCallback( (e: React.MouseEvent) => { + // Or the zone, empty again, would open the picker. e.stopPropagation(); onChange(null); if (inputRef.current) inputRef.current.value = ""; diff --git a/src/components/form/FileField.tsx b/src/components/form/FileField.tsx index 8369f614..0a24fa10 100644 --- a/src/components/form/FileField.tsx +++ b/src/components/form/FileField.tsx @@ -1,5 +1,6 @@ /** - * A form's file field. + * Renders the file field of the dump forms, a {@link FileDropZone} bound to + * react-hook-form. * * @module */ @@ -14,27 +15,27 @@ import { import { FileDropZone } from "./FileDropZone.tsx"; -/** The props of {@link FileField}. */ +/** + * Passed to {@link FileField} by a dump form. The field it names holds the + * `File` itself, or null. + */ export interface FileFieldProps<T extends FieldValues> { - /** The field's name in the form. */ name: Path<T>; - /** The field's label. */ label?: string; - /** The drop zone's prompt. */ hint?: string; - /** Whether to state the size limit. */ showLimit?: boolean; - /** Whether the field takes no file. */ disabled?: boolean; - /** The field's validation rules. */ rules?: RegisterOptions<T, Path<T>>; - /** Side effect run after the field value changes (e.g. derive a title). */ + /** + * Called after the form's value is set, for a side effect such as deriving + * the title from the file's name. + */ onValueChange?: (file: File | null) => void; } /** - * Renders a `Controller`-wrapped {@link FileDropZone} bound to react-hook-form, - * with the field's error under it. + * Renders the drop zone with its label and the field's error. It must sit under + * a react-hook-form `FormProvider`, such as one given `useApiForm`'s result. */ export function FileField<T extends FieldValues>({ name, diff --git a/src/components/form/FormActions.tsx b/src/components/form/FormActions.tsx index 18d6768a..1b0b2d48 100644 --- a/src/components/form/FormActions.tsx +++ b/src/components/form/FormActions.tsx @@ -1,5 +1,6 @@ /** - * A form's row of actions. + * Renders the row of buttons that ends a form, in the pages and dialogs that + * edit something. * * @module */ @@ -7,22 +8,20 @@ import type { ReactNode } from "react"; import { Trans } from "@lingui/react/macro"; -/** The props of {@link FormActions}. */ +/** Passed to {@link FormActions} by a form, with its submit button as child. */ export interface FormActionsProps { - /** The submit control(s). */ children: ReactNode; - /** Called by the Cancel button; no Cancel button without it. */ + /** Adds a Cancel button, which calls it. */ onCancel?: () => void; - /** The Cancel button's label; "Cancel" by default. */ + /** Replaces the "Cancel" label. */ cancelLabel?: ReactNode; - /** Whether the Cancel button is disabled. */ + /** Disables the Cancel button only, not the children. */ disabled?: boolean; } /** - * Renders a right-aligned form action row: an optional Cancel button followed - * by the submit control(s). Standardizes the `.form-actions > - * .form-actions-right` markup duplicated across forms. + * Renders the Cancel button, if any, then the children, aligned right. The + * Cancel button is `type="button"`, so it never submits the form. */ export function FormActions({ children, diff --git a/src/components/form/FormError.tsx b/src/components/form/FormError.tsx index 1d23fc75..fad11564 100644 --- a/src/components/form/FormError.tsx +++ b/src/components/form/FormError.tsx @@ -1,5 +1,6 @@ /** - * A form's submit error. + * Renders the error of a failed submit, for the forms built with + * `useApiForm`. * * @module */ @@ -9,16 +10,16 @@ import { useFormState } from "react-hook-form"; import { ErrorCard } from "../shared/ErrorCard.tsx"; -/** The props of {@link FormError}. */ +/** Passed to {@link FormError} by a form, to say what failed. */ export interface FormErrorProps { - /** The card's title; "Something went wrong" by default. */ + /** Replaces the "Something went wrong" title. */ title?: string; } /** - * Renders the form's root error (set by `useApiForm`'s `submit` on a - * thrown/failed request) through the shared {@link ErrorCard}, and nothing - * without one. One consistent place for server/submit errors across every form. + * Renders the form's root error as an {@link ErrorCard}, or nothing without + * one. `useApiForm`'s `submit` sets that error when the request fails. It must + * sit under a react-hook-form `FormProvider`. */ export function FormError( { title }: FormErrorProps, diff --git a/src/components/form/RichTextField.tsx b/src/components/form/RichTextField.tsx index 57692235..b661edd7 100644 --- a/src/components/form/RichTextField.tsx +++ b/src/components/form/RichTextField.tsx @@ -1,5 +1,6 @@ /** - * A form's rich-text field. + * Renders a {@link TextEditor} bound to react-hook-form, for the forms that + * take a Markdown text (a dump's note, a playlist's description). * * @module */ @@ -16,34 +17,29 @@ import { import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; -/** The props of {@link RichTextField}. */ +/** Passed to {@link RichTextField} by a form that holds a string field. */ export interface RichTextFieldProps<T extends FieldValues> { - /** The field's name in the form. */ name: Path<T>; - /** The field's label. */ label?: string; - /** The editor's placeholder. */ placeholder?: string; - /** The editor's height, in rows. */ rows?: number; - /** Counted in the editor and enforced on submit, with a message saying so. */ + /** + * Counted in the editor and checked on submit, with a message. It overrides + * a `maxLength` in `rules`. + */ maxLength?: number; - /** Whether the editor grows with its text. */ autoResize?: boolean; - /** Whether the editor is disabled. */ disabled?: boolean; - /** The field's validation rules. */ rules?: RegisterOptions<T, Path<T>>; - /** Forwarded to the underlying TextEditor so callers can focus it. */ + /** The editor's ref, for a caller that focuses it. */ editorRef?: React.Ref<TextEditorHandle>; - /** Ctrl/Cmd+Enter handler (e.g. submit the comment). */ + /** Called on Ctrl/Cmd+Enter. */ onSubmit?: () => void; } /** - * Renders a rich-text field: a `Controller`-wrapped {@link TextEditor} - * (mentions, emoji, image upload, char count) bound to react-hook-form via the - * surrounding `FormProvider`, with the field's error under it. + * Renders the editor with its label and the field's error. It must sit under a + * react-hook-form `FormProvider`, such as one given `useApiForm`'s result. */ export function RichTextField<T extends FieldValues>({ name, @@ -61,8 +57,8 @@ export function RichTextField<T extends FieldValues>({ const hintId = useId(); const { control, formState: { errors } } = useFormContext<T>(); const error = errors[name]; - // A bare numeric rule fails without a message, which left a submit that did - // nothing and said nothing. + // A bare numeric rule fails without a message, and the submit would do + // nothing and say nothing. const allRules = maxLength == null ? rules : { ...rules, maxLength: { diff --git a/src/components/form/SegmentedField.tsx b/src/components/form/SegmentedField.tsx index 5a0076ac..f162c1fa 100644 --- a/src/components/form/SegmentedField.tsx +++ b/src/components/form/SegmentedField.tsx @@ -1,5 +1,6 @@ /** - * A form's segmented control, and the public/private toggle built on it. + * Renders a form's choice among a few values as a row of buttons, and the + * public/private toggle that the dump and playlist forms build on it. * * @module */ @@ -13,30 +14,29 @@ import { useFormContext, } from "react-hook-form"; -/** A choice of a {@link SegmentedField}. */ +/** + * Passed in {@link SegmentedFieldProps.options}. Its `value` is compared to the + * field's with `===` and must be unique as a string, since it keys the button. + */ export interface SegmentedOption<V> { - /** The value it sets. */ value: V; - /** What it shows. */ label: ReactNode; } -/** The props of {@link SegmentedField}. */ +/** + * Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}. + */ export interface SegmentedFieldProps<T extends FieldValues, V> { - /** The field's name in the form. */ name: Path<T>; - /** The choices, in order. */ options: SegmentedOption<V>[]; - /** The field's label. */ label?: string; - /** Whether the control is disabled. */ disabled?: boolean; } /** - * Renders a segmented control (the `.visibility-toggle` style) bound to - * react-hook-form. Generic over the field value, so it works for booleans, - * string unions, etc. + * Renders the options as buttons, the one equal to the field's value marked + * active, in the `.visibility-toggle` style. It must sit under a + * react-hook-form `FormProvider`. */ export function SegmentedField<T extends FieldValues, V>({ name, @@ -72,20 +72,18 @@ export function SegmentedField<T extends FieldValues, V>({ ); } -/** The props of {@link VisibilityToggle}. */ +/** Passed to {@link VisibilityToggle} by a dump or playlist form. */ export interface VisibilityToggleProps<T extends FieldValues> { - /** The boolean field's name in the form. */ + /** A boolean field, true for public. */ name: Path<T>; - /** The field's label. */ label?: string; - /** Whether the toggle is disabled. */ disabled?: boolean; } /** - * Renders a Public/Private toggle on a boolean field (default `isPublic`). - * Forms that persist `isPrivate` should still bind this to an `isPublic` field - * and invert at submit time. + * Renders a Public/Private toggle on a boolean field that is true for public. + * A dump's API takes `isPrivate` instead, so the dump forms bind the toggle to + * an `isPublic` field and invert it on submit. */ export function VisibilityToggle<T extends FieldValues>({ name, diff --git a/src/components/form/SubmitButton.tsx b/src/components/form/SubmitButton.tsx index d9c9df58..a250df84 100644 --- a/src/components/form/SubmitButton.tsx +++ b/src/components/form/SubmitButton.tsx @@ -1,5 +1,6 @@ /** - * A form's submit button. + * Renders the submit button of the forms built with `useApiForm`, which shows + * the submit in progress. * * @module */ @@ -7,24 +8,23 @@ import type { ReactNode } from "react"; import { useFormState } from "react-hook-form"; -/** The props of {@link SubmitButton}. */ +/** Passed to {@link SubmitButton} by a form, usually inside `FormActions`. */ export interface SubmitButtonProps { - /** The button's label. */ children: ReactNode; - /** Replaces the label while the form is submitting (e.g. "Saving…"). */ + /** Replaces the label while the form is submitting, such as "Saving…". */ pendingLabel?: ReactNode; - /** Whether it is disabled, besides while submitting. */ + /** Disables the button, which is disabled while submitting anyway. */ disabled?: boolean; - /** Its class; `btn-primary` by default. */ + /** Replaces the `btn-primary` class. */ className?: string; - /** For an icon-only button (e.g. "✓"), its accessible name. */ + /** Names an icon-only button, such as "✓". */ "aria-label"?: string; } /** - * Renders a submit button that reflects react-hook-form's `isSubmitting` from - * the surrounding `FormProvider`: it disables itself while submitting and swaps - * in `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern. + * Renders a submit button that is disabled while the form submits, so that a + * second click can't post twice. It reads the form's state, so it must sit + * under a react-hook-form `FormProvider`. */ export function SubmitButton({ children, diff --git a/src/components/form/TextField.tsx b/src/components/form/TextField.tsx index ceabf28e..9fae3a8f 100644 --- a/src/components/form/TextField.tsx +++ b/src/components/form/TextField.tsx @@ -1,5 +1,6 @@ /** - * A form's text field. + * Renders a one-line text field bound to react-hook-form, for the account + * forms, a dump's title and a playlist's. * * @module */ @@ -15,37 +16,31 @@ import { import { charCountClass } from "../../utils/shared/charCount.ts"; -/** The props of {@link TextField}. */ +/** Passed to {@link TextField} by a form that holds a string field. */ export interface TextFieldProps<T extends FieldValues> { - /** The field's name in the form. */ name: Path<T>; - /** The field's label. */ label?: string; - /** - * Optional control rendered to the right of the label (e.g. a reset button). - */ + /** Shown at the right of the label, such as a reset button. */ labelAction?: ReactNode; - /** The input's type; `text` by default. */ type?: "text" | "email" | "password" | "url" | "search"; - /** The input's placeholder. */ placeholder?: string; - /** The input's autocomplete hint. */ autoComplete?: string; - /** Whether the input takes focus on mount. */ autoFocus?: boolean; - /** Whether the input is disabled. */ disabled?: boolean; - /** The field's validation rules. */ rules?: RegisterOptions<T, Path<T>>; - /** Cap input length at `max` (native `maxLength`). */ + /** + * Stops the typing at this length, through the input's own `maxLength`. + * Unlike `RichTextField`'s, it adds no rule, so a value set from code isn't + * checked. + */ maxLength?: number; - /** Show a live `n / maxLength` counter. Requires `maxLength`. */ + /** Shows an `n / maxLength` counter, only when `maxLength` is set. */ showCount?: boolean; } /** - * Renders a live `n / max` counter of field `name`; isolated so only counted - * fields subscribe to the value. + * Renders the counter of field `name`. It is a component of its own so that + * only it, not the whole field, re-renders on every keystroke. */ function CharCounter<T extends FieldValues>( { name, max }: { name: Path<T>; max: number }, @@ -60,9 +55,8 @@ function CharCounter<T extends FieldValues>( } /** - * Renders a native text input wired to react-hook-form via the surrounding - * `FormProvider`: a label, the input (with an optional character counter), and - * a validation hint sourced from `formState.errors[name]`. + * Renders the input with its label and the field's error. It must sit under a + * react-hook-form `FormProvider`, such as one given `useApiForm`'s result. */ export function TextField<T extends FieldValues>({ name, diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx index d7b560a8..29c988da 100644 --- a/src/components/layout/AppHeader.tsx +++ b/src/components/layout/AppHeader.tsx @@ -1,6 +1,6 @@ /** - * The site's header: the brand, the search, the user's links and buttons, and - * the socket's status. + * Renders the header at the top of every page, through `PageShell` or directly + * from the index and search pages. * * @module */ @@ -17,47 +17,52 @@ import { UserMenu } from "./UserMenu.tsx"; import { SearchBar } from "./SearchBar.tsx"; import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts"; -/** The dump creation modal, loaded on first open. */ +/** Loaded on first open, so its editor stays out of every page's bundle. */ const DumpCreateModal = lazy(() => import("../dump/create/DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal, })) ); -/** Carried in a search entry's history state. */ +/** Stored in the history state of each search results entry. */ interface SearchEntryState { /** - * The history index of the page the search started from, so leaving search - * returns there in one step however many searches (or result tabs) came in + * The history index of the page the search started from. Closing the search + * goes back there in one step, however many searches or result tabs came in * between. */ searchExit?: number; } /** - * Returns React Router's index of the current history entry — 0 for the first - * page of the session, which has nothing in the app to go back to. + * Returns React Router's index of the current history entry. It is 0 for the + * first page of the session, which has nothing in the app to go back to. */ function historyIndex(): number { return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0; } -/** The props of {@link AppHeader}. */ +/** + * Passed to {@link AppHeader} by the index page. The other pages leave these + * props out. + */ export interface AppHeaderProps { - /** What the page shows in the header's centre, beside the search. */ + /** Shown beside the search. The index page puts its feed tabs there. */ centerSlot?: ReactNode; - /** Whether the new-dump button is disabled (the server is unreachable). */ + /** Set by the index page while its feed fails to load. */ disableNew?: boolean; - /** A link to open the dump creation modal with, at mount. */ + /** + * Opens the dump creation dialog at mount, with this link. The index page + * passes the link shared to the app from Android's share sheet. + */ initialDumpUrl?: string; } /** - * Renders the site's header: the brand; the search, which stays open on the - * results page and slides open under the header on small viewports; for a - * signed-in user their profile and playlists links (an avatar menu on small - * viewports), the chat and notification buttons and the new-dump button, else a - * login button; and a banner while live updates are down. + * 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. */ export function AppHeader( { centerSlot, disableNew, initialDumpUrl }: AppHeaderProps, @@ -67,16 +72,17 @@ export function AppHeader( const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); - // The results page and each of its tabs (/search/~/users…). + // `/search` and its tabs, `/search/~/:searchTab`. const isSearchPage = location.pathname === "/search" || location.pathname.startsWith("/search/"); const query = isSearchPage ? searchParams.get("q") ?? "" : ""; - // On the results page the search stays open, holding the query it shows. + // On the results page the search stays open, showing the query. const [searchOpen, setSearchOpen] = useState(false); const searchExpanded = isSearchPage || searchOpen; const [draft, setDraft] = useState(query); const [prevQuery, setPrevQuery] = useState(query); + // Reset during render when navigation changes the query, as with Back. if (prevQuery !== query) { setPrevQuery(query); setDraft(query); @@ -87,10 +93,10 @@ export function AppHeader( function submitSearch(q: string) { const here = location.state as SearchEntryState | null; - // A search from elsewhere remembers that page; one from the results page - // passes on what it was given, or — for results reached by a link — - // the page before them. Results that opened the session have nowhere to - // return to, so a new search just replaces them. + // A search from elsewhere remembers that page. One from the results page + // passes on what it was given or, for results reached by a link, the page + // before them. Results that opened the session have nowhere to return to, + // so a new search replaces them. const searchExit = !isSearchPage ? historyIndex() : here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null); @@ -112,8 +118,8 @@ export function AppHeader( } } - // Escape leaves an opened search, but only reverts an edited query on the - // results page — leaving that page is the ✕'s job. + // On the results page, Escape only reverts the edited query: leaving the + // page is the ✕'s job. function escapeSearch() { if (isSearchPage) setDraft(query); else closeSearch(); @@ -162,7 +168,6 @@ export function AppHeader( {user ? ( <> - {/* Full text links — hidden below the compact breakpoint */} <div className="nav-links"> <Link to={`/users/${user.username}`} @@ -178,7 +183,7 @@ export function AppHeader( </Link> </div> - {/* Compact avatar menu — hidden above the compact breakpoint */} + {/* CSS shows either the links above or this menu, by width. */} <div className="nav-compact"> <UserMenu user={user} /> </div> @@ -209,7 +214,7 @@ export function AppHeader( </header> { - /* Stays mounted so the row can slide open and shut; `inert` keeps the + /* Stays mounted so the row can slide open and shut. `inert` keeps the collapsed input out of the tab order. */ } <div diff --git a/src/components/layout/ChatButton.tsx b/src/components/layout/ChatButton.tsx index 7f4536b3..0ca50e74 100644 --- a/src/components/layout/ChatButton.tsx +++ b/src/components/layout/ChatButton.tsx @@ -1,5 +1,5 @@ /** - * The header's chat button. + * Renders the chat button of the header, for signed-in users. * * @module */ @@ -9,8 +9,8 @@ import { useWSChat } from "../../hooks/ws/useWS.ts"; import { useChat } from "../../hooks/chat/useChat.ts"; /** - * Renders the header chat button with an unread-message badge. Opens the - * app-wide chat. + * Renders the button that opens the chat, with the socket's count of unread + * messages. `ChatFab` does the same once the header scrolls away. */ export function ChatButton(): React.JSX.Element { const { unreadChatCount } = useWSChat(); diff --git a/src/components/layout/ChatFab.tsx b/src/components/layout/ChatFab.tsx index fe723742..834c038c 100644 --- a/src/components/layout/ChatFab.tsx +++ b/src/components/layout/ChatFab.tsx @@ -1,5 +1,5 @@ /** - * The floating chat button. + * Renders the floating chat button, which `App` mounts once over every page. * * @module */ @@ -12,9 +12,8 @@ import { useWSChat } from "../../hooks/ws/useWS.ts"; import { useChat } from "../../hooks/chat/useChat.ts"; /** - * Renders the floating chat button shown once the header has scrolled out of - * view, mirroring `DumpFab`, with an unread badge. Opens the single app-wide - * chat modal (owned by ChatProvider). Only rendered for signed-in users. + * Renders the chat button that floats above `DumpFab` once the header, and its + * `ChatButton`, have scrolled out of view. It renders nothing when signed out. */ export function ChatFab(): React.JSX.Element | null { const { user } = useAuth(); @@ -23,9 +22,8 @@ export function ChatFab(): React.JSX.Element | null { const location = useLocation(); const [headerHidden, setHeaderHidden] = useState(false); - // Reveal the button once the header's bottom edge scrolls above the viewport. - // Re-reading the header on every tick keeps this correct across navigation - // and lazily-mounted pages, where the header element is replaced. + // Each page renders its own header, so it is queried anew on every check. + // The ResizeObserver catches a lazy page that mounts without a scroll. useEffect(() => { if (!user) return; const update = () => { diff --git a/src/components/layout/DumpFab.tsx b/src/components/layout/DumpFab.tsx index 189fb9a5..015e778d 100644 --- a/src/components/layout/DumpFab.tsx +++ b/src/components/layout/DumpFab.tsx @@ -1,5 +1,6 @@ /** - * The floating new-dump button. + * Renders the floating "new dump" button, which `App` mounts once over every + * page. * * @module */ @@ -9,7 +10,7 @@ import { useLocation } from "react-router"; import { t } from "@lingui/core/macro"; import { useAuth } from "../../hooks/auth/useAuth.ts"; -/** The dump creation modal, loaded on first open. */ +/** Loaded on first open, so its editor stays out of every page's bundle. */ const DumpCreateModal = lazy(() => import("../dump/create/DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal, @@ -17,15 +18,9 @@ const DumpCreateModal = lazy(() => ); /** - * Renders the floating "new dump" button shown once the header has scrolled out - * of view. Sits in the bottom-right corner and lifts above the global player - * (which is full-width on mobile) so the two never overlap. Only rendered for - * signed-in users — dump creation requires an account. - * - * Mounted once at the app root (not per-page) since some pages render their own - * header instead of going through PageShell. Visibility is derived from a live - * read of the current page's `.app-header`, so it survives client-side - * navigation and lazily-mounted route headers. + * Renders the button that opens the dump creation dialog once the header, and + * its "+ Dump" button, have scrolled out of view. It renders nothing when + * signed out. The stylesheet lifts it above the global player. */ export function DumpFab(): React.JSX.Element | null { const { user } = useAuth(); @@ -33,17 +28,14 @@ export function DumpFab(): React.JSX.Element | null { const [headerHidden, setHeaderHidden] = useState(false); const [modalOpen, setModalOpen] = useState(false); - // Reserve clearance at the end of the scroll so the floating button never - // hides the last content when scrolled all the way down. + // `has-fab` pads the page's end, so the button never hides the last content. useEffect(() => { if (!user) return; document.body.classList.add("has-fab"); return () => document.body.classList.remove("has-fab"); }, [user]); - // Reveal the button once the header's bottom edge scrolls above the viewport. - // Re-reading the header on every tick keeps this correct across navigation - // and lazily-mounted pages, where the header element is replaced. + // Each page renders its own header, so it is queried anew on every check. useEffect(() => { if (!user) return; const update = () => { @@ -53,8 +45,8 @@ export function DumpFab(): React.JSX.Element | null { update(); globalThis.addEventListener("scroll", update, { passive: true }); globalThis.addEventListener("resize", update); - // Body height changes when a lazy route finishes mounting — re-check then, - // since no scroll/resize event fires in that case. + // A lazy page that finishes mounting changes the body's height without a + // scroll or resize event. const ro = new ResizeObserver(update); ro.observe(document.body); return () => { @@ -78,8 +70,7 @@ export function DumpFab(): React.JSX.Element | null { onClick={() => setModalOpen(true)} > { - /* Symmetric SVG plus — centers exactly via flex, regardless of the - theme's font metrics (a text "+" sits slightly off the box center). */ + /* An SVG, since a text "+" sits off center in some themes' fonts. */ } <svg className="dump-fab-icon" diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx index dd45ce41..cb35e3ae 100644 --- a/src/components/layout/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -1,5 +1,5 @@ /** - * The header's notification bell. + * Renders the notification bell of the header, for signed-in users. * * @module */ @@ -10,16 +10,16 @@ import { t } from "@lingui/core/macro"; import { useWS } from "../../hooks/ws/useWS.ts"; /** - * Renders the header's notification bell with an unread badge; it rings when a - * notification arrives, and opens the notifications page. + * Renders the bell that leads to `/notifications`, with the socket's count of + * unread notifications. It rings when the socket pushes a new one. */ export function NotificationBell(): React.JSX.Element { const { unreadNotificationCount, lastNotification } = useWS(); const navigate = useNavigate(); const [ringing, setRinging] = useState(false); const animatingRef = useRef(false); - // Initialised to the ID already in context — so the first effect run never - // animates, regardless of whether lastNotification is null or stale. + // Each page mounts its own header, so a notification already in context + // must not ring the bell again after a navigation. const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null); useEffect(() => { diff --git a/src/components/layout/PageError.tsx b/src/components/layout/PageError.tsx index 41ed88ad..d7788927 100644 --- a/src/components/layout/PageError.tsx +++ b/src/components/layout/PageError.tsx @@ -1,5 +1,6 @@ /** - * A page that failed to load. + * Renders the error a page shows in place of its content when what it loads + * fails or doesn't exist. * * @module */ @@ -9,17 +10,19 @@ import { t } from "@lingui/core/macro"; import { PageShell } from "./PageShell.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx"; -/** The props of {@link PageError}. */ +/** Passed to {@link PageError} by a page whose load failed. */ export interface PageErrorProps { - /** What failed; "Something went wrong" by default. */ + /** Defaults to "Something went wrong". */ title?: string; - /** Why. */ message: string; - /** Buttons offered under it. */ + /** Buttons under the message, such as a retry. */ actions?: ReactNode; } -/** Renders an error card as the whole page, under the header. */ +/** + * Renders an error card as the whole page, under the header, so the user can + * still navigate away. + */ export function PageError( { title, message, actions }: PageErrorProps, ): React.JSX.Element { diff --git a/src/components/layout/PageShell.tsx b/src/components/layout/PageShell.tsx index ae963a52..0eb3ebb6 100644 --- a/src/components/layout/PageShell.tsx +++ b/src/components/layout/PageShell.tsx @@ -1,5 +1,6 @@ /** - * A page's frame: the header, and the content under it. + * Renders the frame of every page but the index and search pages, which + * render `AppHeader` themselves. * * @module */ @@ -7,17 +8,16 @@ import { type ReactNode } from "react"; import { AppHeader } from "./AppHeader.tsx"; -/** The props of {@link PageShell}. */ +/** Passed to {@link PageShell} by a page, around its content. */ export interface PageShellProps { - /** The page's content. */ children: ReactNode; - /** Whether the content is centred. */ + /** Centers the content, for a lone card such as a form or an error. */ centered?: boolean; - /** What the page shows in the header's centre. */ + /** Passed on to `AppHeader`. */ centerSlot?: ReactNode; } -/** Renders the header and, under it, the page's content. */ +/** Renders {@link AppHeader}, then the page's content as the `<main>`. */ export function PageShell( { children, centered = false, centerSlot }: PageShellProps, ): React.JSX.Element { diff --git a/src/components/layout/SearchBar.tsx b/src/components/layout/SearchBar.tsx index 0755bc26..83d0bfb4 100644 --- a/src/components/layout/SearchBar.tsx +++ b/src/components/layout/SearchBar.tsx @@ -1,5 +1,5 @@ /** - * The header's search form. + * Renders the search form of the header. * * @module */ @@ -7,24 +7,24 @@ import { useEffect, useRef } from "react"; import { t } from "@lingui/core/macro"; -/** The props of {@link SearchBar}. */ +/** + * Passed to {@link SearchBar} by `AppHeader`, which holds the query and decides + * where a search leads. + */ export interface SearchBarProps { - /** Whether it is open. */ expanded: boolean; - /** The query as typed. */ value: string; - /** Called as the query is typed. */ onValueChange: (v: string) => void; - /** Called with the trimmed query on submit. */ + /** Called with the trimmed query, never an empty one. */ onSubmit: (q: string) => void; - /** Escape pressed in the input. */ + /** Called when Escape is pressed in the input. */ onCancel: () => void; } /** - * Renders the header's search form. The header renders two of these (one in its - * centre column, one in the row that slides open under it on small viewports) - * over one shared state, so the draft survives crossing the breakpoint. + * Renders the search form. `AppHeader` renders two over one state, one in its + * center and one in the row that slides open under it on small screens, so the + * query survives crossing the breakpoint. Opening it focuses the input. */ export function SearchBar( { expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps, @@ -33,11 +33,11 @@ export function SearchBar( const wasExpanded = useRef(expanded); useEffect(() => { - // Only an opening takes focus: a page that mounts with search already - // open (the results page) mustn't raise a phone's keyboard over its - // results. No scroll-into-view either: the below-header row is still - // clipped while it slides open, and scrolling the clip would offset its - // content. Of the two bars, the hidden one just ignores the call. + // Only an opening takes focus, so that the results page, which mounts with + // the search open, doesn't raise a phone's keyboard over its results. No + // scroll either: the row under the header is still clipped as it slides + // open, and scrolling the clip would offset its content. Of the two bars, + // the hidden one ignores the focus. if (expanded && !wasExpanded.current) { inputRef.current?.focus({ preventScroll: true }); } else if (!expanded && inputRef.current === document.activeElement) { @@ -50,8 +50,7 @@ export function SearchBar( e.preventDefault(); const q = value.trim(); if (!q) return; - // Submitting shows results — put a phone's keyboard away so they can be - // seen. + // Puts a phone's keyboard away, so that the results can be seen. inputRef.current?.blur(); onSubmit(q); } diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index e697de3e..3d3e3574 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -1,5 +1,6 @@ /** - * The header's avatar menu, on small viewports. + * Renders the avatar menu that replaces the header's profile and playlists + * links on small screens. * * @module */ @@ -11,15 +12,14 @@ import { Trans } from "@lingui/react/macro"; import { Avatar } from "../shared/Avatar.tsx"; import type { SelfUser } from "../../../shared/user.ts"; -/** The props of {@link UserMenu}. */ +/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */ export interface UserMenuProps { - /** The signed-in user. */ user: SelfUser; } /** - * Renders the signed-in user's avatar, which opens a menu with their profile - * and playlists links. + * Renders the signed-in user's avatar, which opens a menu with links to their + * profile and playlists. A click outside or Escape closes it. */ export function UserMenu({ user }: UserMenuProps): React.JSX.Element { const [open, setOpen] = useState(false); diff --git a/src/components/player/GlobalPlayer.tsx b/src/components/player/GlobalPlayer.tsx index ead73f7d..3753bd21 100644 --- a/src/components/player/GlobalPlayer.tsx +++ b/src/components/player/GlobalPlayer.tsx @@ -1,5 +1,6 @@ /** - * The global player, docked at the bottom of every page. + * Renders the player docked at the bottom of every page. App mounts it once, + * beside the routes, so playback goes on from page to page. * * @module */ @@ -19,7 +20,6 @@ import { Thumbnail } from "../shared/Thumbnail.tsx"; import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts"; import { embedListId, withJsApi } from "../../utils/player/youtube.ts"; -/** An embed item of the player. */ type EmbedItem = Extract<PlayerItem, { kind: "embed" }>; /** @@ -54,17 +54,15 @@ function TrackRow( } /** - * Returns the URL the iframe of `item` loads. The stored embedUrl is the - * canonical, non-playing form — it is also rendered outside the player, so the - * autoplay parameter is added here at playback time rather than baked into - * rich_content. Only a fresh play() sets `autoplay`: a session restored from - * localStorage has no user gesture behind it, so it stays paused, matching how - * MediaPlayer treats file items. Bandcamp's EmbeddedPlayer has no autoplay - * parameter — that is what the native playback path - * (GERBEUR_BANDCAMP_PLAYER=native) exists to solve. + * Returns the URL the iframe of `item` loads. The item keeps the stored + * `embedUrl`, which RichContentCard matches to tell which card plays, so the + * autoplay parameter is added only here, and only for a fresh play: a restored + * session has no user gesture behind it. Bandcamp's embed has no autoplay + * parameter, which is why native Bandcamp playback + * (`GERBEUR_BANDCAMP_PLAYER=native`) exists. */ function playbackUrl(item: EmbedItem, autoplay: boolean) { - // A playlist embed is also driven from the outside — see useYouTubePlaylist. + // useYouTubePlaylist drives a playlist embed through the IFrame API. const base = item.type === "youtube" && embedListId(item.embedUrl) ? withJsApi(item.embedUrl) : item.embedUrl; @@ -77,15 +75,16 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) { } else return base; return url.toString(); } catch { - return base; // malformed stored URL — hand it to the iframe as-is + // A malformed stored URL goes to the iframe as it is. + return base; } } /** - * Renders what the player plays — a media player for files and streams, the - * provider's iframe for embeds — with its artwork, title, link to the dump, - * transport, and the tracklist of the queue or of a YouTube playlist; nothing - * while nothing plays. + * Renders what {@link PlayerContext} plays, with a link to its dump: a + * MediaPlayer for a file or a stream, the provider's iframe for an embed, and + * the tracklist of the queue or of a YouTube playlist. It renders nothing while + * nothing plays. */ export function GlobalPlayer(): React.JSX.Element | null { const { @@ -123,12 +122,15 @@ export function GlobalPlayer(): React.JSX.Element | null { : null; const yt = useYouTubePlaylist(ytEmbedUrl, iframeRef); + // A new item opens the player expanded. const currentKey = current ? playerItemKey(current) : null; if (prevKey !== currentKey) { setPrevKey(currentKey); if (current) setReduced(false); } + // App.css pads the page's bottom by --player-height while `has-player` is + // set, so the docked player never covers the end of a page. useEffect(() => { if (!current) { document.body.classList.remove("has-player"); diff --git a/src/components/player/MediaPlayer.tsx b/src/components/player/MediaPlayer.tsx index 87074acd..a2c76323 100644 --- a/src/components/player/MediaPlayer.tsx +++ b/src/components/player/MediaPlayer.tsx @@ -1,5 +1,7 @@ /** - * The site's audio and video player. + * Renders an audio or video file with the site's own controls. GlobalPlayer + * plays files and streams in it; the file previews of the dump pages and of + * the dump form play a file in it. * * @module */ @@ -20,42 +22,47 @@ import { rememberVolume, storedVolume } from "../../utils/player/volume.ts"; /** How long a playing video's controls stay after the pointer stops, in ms. */ const HIDE_DELAY = 2500; -/** The props of {@link MediaPlayer}. */ +/** + * Passed to {@link MediaPlayer} by GlobalPlayer, which wires the callbacks and + * the handles to PlayerContext, and by the file previews, which pass only the + * media. + */ export interface MediaPlayerProps { - /** The media's URL. */ src: string; - /** Whether it is audio or video. */ kind: "audio" | "video"; - /** The media's type. */ + /** The `<source>` type, when known. */ mime?: string; - /** A video's still before it plays. */ + /** Shown before a video plays. */ poster?: string; - /** Whether it starts playing on mount. */ autoplay?: boolean; - /** Where it starts, in seconds. */ + /** In seconds. Read on mount only: later seeks go through `seekRef`. */ startTime?: number; - /** Called when it starts or stops playing. */ onPlayStateChange?: (playing: boolean) => void; - /** Called as it plays, with the playhead and the duration. */ onTimeUpdate?: (time: number, duration: number) => void; - /** Fired when the media reaches its end — the queue's advance hook. */ + /** Called at the media's end, where GlobalPlayer plays the next item. */ onEnded?: () => void; - /** Fired when the element fails to load or decode. For expiring stream URLs - * this is the signal to re-resolve. */ + /** + * Called when the media fails to load or decode, where a stream's URL is + * re-resolved. + */ onError?: () => void; - /** "waveform" (default) decodes peaks from the source; "progress" draws a - * slim seek bar and never fetches the media a second time. */ + /** + * "waveform" (the default for audio) decodes peaks from the source; + * "progress" draws a seek bar and never fetches the media a second time. + */ trackStyle?: "waveform" | "progress"; - /** Receives its seek handle, for the global player's controls. */ + /** Receives its seek handle while mounted, for PlayerContext's `seekTo`. */ seekRef?: { current: ((t: number) => void) | null }; - /** Receives its play/pause handle, for the global player's controls. */ + /** Receives its play/pause handle while mounted, for `togglePlay`. */ toggleRef?: { current: (() => void) | null }; } /** - * Renders a media element with the site's own controls: play/pause, a waveform - * or seek bar, time, volume (remembered across players) and, for video, - * fullscreen. + * Renders a media element with the site's own controls. Audio gets a waveform + * to seek on, unless `trackStyle` asks for a seek bar; video gets a seek bar, a + * fullscreen button, and controls that hide while it plays. The volume carries + * over to the next MediaPlayer, which matters since GlobalPlayer mounts a new + * one for each track. */ export function MediaPlayer( { @@ -84,16 +91,14 @@ export function MediaPlayer( const [controlsVisible, setControlsVisible] = useState(true); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); - // ── Callback refs ───────────────────────────────────────────────────────────── - // Updated via effects (after render) so the linter doesn't flag render-phase ref - // mutation. No cleanup → no null window. Negligible stale-window between commit - // and effect, acceptable since these are only called from async event handlers. + // The media listeners call the latest callbacks through these refs, without + // being attached again. They are written after render, as the linter wants; + // the gap before the effect doesn't matter to handlers of async events. const onPlayStateChangeRef = useRef(onPlayStateChange); const onTimeUpdateRef = useRef(onTimeUpdate); const onEndedRef = useRef(onEnded); const onErrorRef = useRef(onError); - // Sync prop callbacks after every render useEffect(() => { onPlayStateChangeRef.current = onPlayStateChange; onTimeUpdateRef.current = onTimeUpdate; @@ -101,12 +106,12 @@ export function MediaPlayer( onErrorRef.current = onError; }); - // Stable function refs — updated via effects, indirected by the registration - // effect below so external seekRef/toggleRef never see a null window. + // seekRef and toggleRef get wrappers that call these, so the handles never go + // null while the functions are replaced. const seekToFnRef = useRef((_t: number) => {}); const toggleFnRef = useRef(() => {}); - // seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref) + // Everything it uses is stable, so it is set once. useEffect(() => { seekToFnRef.current = (time: number) => { setCurrent(time); @@ -114,7 +119,7 @@ export function MediaPlayer( }; }, []); - // toggle: captures `playing` — re-synced whenever play state changes + // It reads `playing`, so it is replaced when that changes. useEffect(() => { toggleFnRef.current = () => { const a = mediaRef.current!; @@ -133,14 +138,10 @@ export function MediaPlayer( }; }, [playing]); - // Stable wrappers used everywhere inside the component const seekTo = (time: number) => seekToFnRef.current(time); const toggle = () => toggleFnRef.current(); - // ── Effects ────────────────────────────────────────────────────────────────── - - // Resume to a restored offset once metadata (and thus seekability) is available. - // Runs only on mount for the initial offset — later seeks go through seekTo. + // The element can't seek before its metadata loads. useEffect(() => { if (!startTime) return; const a = mediaRef.current!; @@ -154,8 +155,7 @@ export function MediaPlayer( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Apply the remembered volume to the freshly-mounted element, which always - // starts at 1.0 regardless of what the last track was playing at. + // A new element starts at full volume, whatever the last one played at. useEffect(() => { const a = mediaRef.current; if (!a) return; @@ -165,7 +165,8 @@ export function MediaPlayer( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Autoplay on mount (e.g. triggered by play() in PlayerContext) + // The browser may refuse to play without a user gesture, and the player then + // just shows paused. useEffect(() => { if (!autoplay) return; mediaRef.current?.play() @@ -176,8 +177,8 @@ export function MediaPlayer( .catch(() => {}); }, [autoplay]); - // On unmount: pause and cut callbacks so stale timeupdate/ended events that fire - // between React's commit and the listener-removal effect can't reach the provider. + // Cutting the callbacks on unmount keeps a late timeupdate or ended event of + // this element from reaching PlayerProvider once the next item is playing. useEffect(() => { const a = mediaRef.current!; return () => { @@ -189,8 +190,7 @@ export function MediaPlayer( }; }, []); - // Register imperative handles into provider refs. seekRef/toggleRef are stable - // (created with useRef in PlayerProvider), so this effect runs exactly once. + // The refs are PlayerProvider's, which never change, so this runs once. useEffect(() => { if (seekRef) seekRef.current = (t) => seekToFnRef.current(t); if (toggleRef) toggleRef.current = () => toggleFnRef.current(); @@ -200,10 +200,10 @@ export function MediaPlayer( }; }, [seekRef, toggleRef]); - // Media element event listeners useEffect(() => { const a = mediaRef.current!; const onTime = () => { + // While the seek bar is dragged, its thumb follows the pointer. if (!dragging) { setCurrent(a.currentTime); onTimeUpdateRef.current?.(a.currentTime, a.duration); @@ -249,7 +249,6 @@ export function MediaPlayer( }; }, [dragging]); - // Hide video controls after inactivity useEffect(() => { if (kind !== "video") return; if (hideTimer.current) clearTimeout(hideTimer.current); @@ -264,8 +263,6 @@ export function MediaPlayer( }; }, [playing, kind]); - // ── Render helpers ──────────────────────────────────────────────────────────── - const showingControls = kind !== "video" || !playing || controlsVisible; const showControlsTemporarily = () => { diff --git a/src/components/player/PlayerIcons.tsx b/src/components/player/PlayerIcons.tsx index 03fb300b..503f5a0f 100644 --- a/src/components/player/PlayerIcons.tsx +++ b/src/components/player/PlayerIcons.tsx @@ -1,17 +1,19 @@ /** - * Player control icons, shared by MediaPlayer and FilePreview. + * Draws the icons of the media controls, for MediaPlayer and the dump page's + * audio preview (`FilePreview`). They take the text colour, so the buttons' + * CSS colours them. * * @module */ -/** Renders the play icon. */ +/** Renders the play icon, nudged right so the triangle looks centred. */ export const IconPlay = (): React.JSX.Element => ( <svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}> <polygon points="6,3 20,12 6,21" /> </svg> ); -/** Renders the pause icon. */ +/** Renders the pause icon, the play button's other state. */ export const IconPause = (): React.JSX.Element => ( <svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}> <rect x="5" y="3" width="4" height="18" rx="1" /> @@ -19,13 +21,12 @@ export const IconPause = (): React.JSX.Element => ( </svg> ); -/** The props of {@link IconVolume}. */ +/** Passed to {@link IconVolume} by MediaPlayer's mute button. */ export interface IconVolumeProps { - /** Whether the sound is off. */ muted: boolean; } -/** Renders the volume icon, struck through when muted. */ +/** Renders the mute button's icon, crossed when `muted`. */ export const IconVolume = ( { muted }: IconVolumeProps, ): React.JSX.Element => ( @@ -40,7 +41,7 @@ export const IconVolume = ( </svg> ); -/** Renders the fullscreen icon. */ +/** Renders the icon of a video's fullscreen button. */ export const IconFullscreen = (): React.JSX.Element => ( <svg viewBox="0 0 24 24" fill="currentColor"> <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" /> diff --git a/src/components/player/SeekBar.tsx b/src/components/player/SeekBar.tsx index 15b52f82..94b87d43 100644 --- a/src/components/player/SeekBar.tsx +++ b/src/components/player/SeekBar.tsx @@ -1,29 +1,28 @@ /** - * A slim seek bar. + * Renders the slim seek bar MediaPlayer shows for video and for streams. * * @module */ import { t } from "@lingui/core/macro"; -/** The props of {@link SeekBar}. */ +/** Passed to {@link SeekBar} by MediaPlayer. Times are in seconds. */ export interface SeekBarProps { - /** The playhead, in seconds. */ current: number; - /** The media's length, in seconds. */ duration: number; - /** Called with the time sought to. */ onSeek: (t: number) => void; - /** Called when a drag starts and ends. */ + /** + * Called as the thumb is grabbed and let go, so that MediaPlayer stops moving + * it with the playhead in between. + */ onDragChange: (dragging: boolean) => void; - /** An extra class. */ className?: string; } /** - * Renders a slim progress + scrub bar. Used by video, and by audio sources - * whose peaks can't be decoded (cross-origin streams) — where a waveform would - * be a lie. + * Renders a progress bar with a range input over it to seek. MediaPlayer uses + * it for video, and for streams, whose bytes can't be read cross-origin to + * draw a waveform. */ export function SeekBar( { current, duration, onSeek, onDragChange, className }: SeekBarProps, diff --git a/src/components/player/Waveform.tsx b/src/components/player/Waveform.tsx index 7529a162..65353bc6 100644 --- a/src/components/player/Waveform.tsx +++ b/src/components/player/Waveform.tsx @@ -1,5 +1,5 @@ /** - * An audio file's waveform, which is also its seek bar. + * Renders an audio file's waveform, which MediaPlayer shows as its seek bar. * * @module */ @@ -14,21 +14,19 @@ import { WAVEFORM_H, } from "../../utils/player/waveform.ts"; -/** The props of {@link Waveform}. */ +/** Passed to {@link Waveform} by MediaPlayer. Times are in seconds. */ export interface WaveformProps { - /** The audio's URL. */ + /** Fetched again, apart from the media element, to decode its peaks. */ src: string; - /** The playhead, in seconds. */ current: number; - /** The audio's length, in seconds. */ duration: number; - /** Called with the time clicked. */ onSeek: (t: number) => void; } /** - * Renders the audio's peaks as bars, played ones highlighted, seeking where - * clicked. + * Renders the peaks of `src` as bars, the played ones highlighted, and seeks + * where clicked. Until the peaks are decoded, or if they can't be, it shows a + * plain progress bar that seeks all the same. */ export function Waveform( { src, current, duration, onSeek }: WaveformProps, diff --git a/src/components/playlist/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx index d5e19d58..d4334262 100644 --- a/src/components/playlist/AddToPlaylistModal.tsx +++ b/src/components/playlist/AddToPlaylistModal.tsx @@ -1,5 +1,6 @@ /** - * The modal that adds a dump to playlists. + * Renders the modal in which the dump page's "add to playlist" button adds the + * dump to the user's playlists or takes it out of them. * * @module */ @@ -14,18 +15,16 @@ import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts import { Modal } from "../shared/Modal.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; -/** The props of {@link AddToPlaylistModal}. */ +/** Passed to {@link AddToPlaylistModal} by the dump page. */ export interface AddToPlaylistModalProps { - /** The dump's id. */ dumpId: string; - /** Called to close it. */ onClose: () => void; } /** - * Renders a modal listing the user's playlists, each toggling whether it holds - * the dump, with a form to make a new one. A toggle shows once the server - * agrees. + * Renders a {@link PlaylistMembershipPanel} in a modal, for the playlists + * `GET /api/playlists/by-dump/:dumpId/memberships` answers. A toggle adds or + * removes the dump on the server and shows only once the server agrees. */ export function AddToPlaylistModal( { dumpId, onClose }: AddToPlaylistModalProps, @@ -52,8 +51,7 @@ export function AddToPlaylistModal( const toggleMembership = async (membership: PlaylistMembership) => { const { playlist, hasDump } = membership; - // The box only changes once the server agrees; a refusal or a dropped - // connection leaves it showing the truth. + // A refusal or a dropped connection leaves the box showing the truth. try { const res = await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`, diff --git a/src/components/playlist/NewPlaylistButton.tsx b/src/components/playlist/NewPlaylistButton.tsx index 65356d24..4ec80da2 100644 --- a/src/components/playlist/NewPlaylistButton.tsx +++ b/src/components/playlist/NewPlaylistButton.tsx @@ -1,5 +1,6 @@ /** - * The new-playlist button. + * Renders the button that creates a playlist in a modal, on the user's own + * profile and playlists pages. * * @module */ @@ -11,17 +12,21 @@ import type { Playlist } from "../../../shared/playlist.ts"; import { Modal } from "../shared/Modal.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; -/** The props of {@link NewPlaylistButton}. */ +/** + * Passed to {@link NewPlaylistButton} by the user's profile and playlists + * pages, which add the new playlist to their list through `onCreated`. + */ export interface NewPlaylistButtonProps { - /** Called with the playlist created. */ onCreated: (playlist: Playlist) => void; - /** The button's label; "+ New playlist" by default. */ + /** "+ New playlist" by default. */ toggleLabel?: string; - /** The button's class. */ toggleClassName?: string; } -/** Renders a button that opens the playlist creation form in a modal. */ +/** + * Renders a button that opens {@link PlaylistCreateForm} in a modal, which + * closes once the playlist is created. + */ export function NewPlaylistButton( { onCreated, diff --git a/src/components/playlist/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx index 5c5f47bc..1e026ffb 100644 --- a/src/components/playlist/PlaylistCard.tsx +++ b/src/components/playlist/PlaylistCard.tsx @@ -1,5 +1,6 @@ /** - * A playlist, as a card in a list. + * Renders the card that shows a playlist in profiles, search results and the + * dump page's "In collections" section. * * @module */ @@ -18,20 +19,22 @@ import { } from "../../utils/shared/visited.ts"; import { Tooltip } from "../shared/Tooltip.tsx"; -/** The props of {@link PlaylistCard}. */ +/** Passed to {@link PlaylistCard} by each list of playlists. */ export interface PlaylistCardProps { - /** The playlist. */ playlist: Playlist; - /** Deletes it; no delete button without it. */ + /** + * Shows a delete button, which calls it. The owner's playlists pages pass + * one that asks for confirmation first. + */ onDelete?: () => void; - /** Whether the viewer owns it: no owner link, no unread dot. */ + /** Hides the owner link and the unread dot. */ isOwner?: boolean; } /** - * Renders a playlist as a card: its cover, title, description, visibility, - * owner, size and age, with an unread dot until it is opened. The card opens - * the playlist. + * Renders a playlist as a card that opens the playlist when clicked. Another + * user's recent playlist shows an unread dot until it is opened on this + * device. The card is an `<li>`, for a `<ul>`. */ export function PlaylistCard( { playlist, onDelete, isOwner }: PlaylistCardProps, @@ -70,6 +73,7 @@ export function PlaylistCard( to={playlistUrl(playlist)} className="playlist-card-title" onClick={(e) => { + // Or the card's handler navigates a second time. e.stopPropagation(); markPlaylistVisited(playlist.id); }} @@ -121,6 +125,7 @@ export function PlaylistCard( type="button" className="playlist-card-delete-btn" onClick={(e) => { + // Deleting must not open the playlist. e.stopPropagation(); onDelete(); }} diff --git a/src/components/playlist/PlaylistCreateForm.tsx b/src/components/playlist/PlaylistCreateForm.tsx index 953faea0..064047be 100644 --- a/src/components/playlist/PlaylistCreateForm.tsx +++ b/src/components/playlist/PlaylistCreateForm.tsx @@ -1,5 +1,6 @@ /** - * The playlist creation form. + * Renders the form that creates a playlist, in NewPlaylistButton's modal and in + * PlaylistMembershipPanel. * * @module */ @@ -25,19 +26,18 @@ import { TextField } from "../form/TextField.tsx"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { VisibilityToggle } from "../form/SegmentedField.tsx"; -/** The props of {@link PlaylistCreateForm}. */ +/** + * Passed to {@link PlaylistCreateForm} by NewPlaylistButton and + * PlaylistMembershipPanel. + */ export interface PlaylistCreateFormProps { - /** If provided, the new playlist will have this dump added to it. */ + /** Added to the new playlist, which the submit button says. */ dumpId?: string; - /** Called with the playlist created. */ onCreated: (playlist: Playlist) => void; - /** Called by the Cancel button. */ onCancel: () => void; - /** An extra class for the form. */ className?: string; } -/** The form's fields. */ interface Values { title: string; description: string; @@ -45,8 +45,8 @@ interface Values { } /** - * Renders the form that creates a playlist — title, description, visibility — - * and adds `dumpId` to it when given. + * Renders the form that creates a playlist with `POST /api/playlists`, then + * adds `dumpId` to it when given. A failure to add the dump goes unreported. */ export function PlaylistCreateForm( { dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps, diff --git a/src/components/playlist/PlaylistMembershipPanel.tsx b/src/components/playlist/PlaylistMembershipPanel.tsx index f7a9c40c..ffd2a3ec 100644 --- a/src/components/playlist/PlaylistMembershipPanel.tsx +++ b/src/components/playlist/PlaylistMembershipPanel.tsx @@ -1,5 +1,6 @@ /** - * The list of a user's playlists, each toggling whether it holds a dump. + * Renders the user's playlists as toggles of whether each holds a dump, in + * AddToPlaylistModal and in the last step of the dump form. * * @module */ @@ -9,23 +10,23 @@ import { Trans } from "@lingui/react/macro"; import type { PlaylistMembership } from "../../../shared/playlist.ts"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; -/** The props of {@link PlaylistMembershipPanel}. */ +/** + * Passed to {@link PlaylistMembershipPanel} by AddToPlaylistModal and the dump + * form, which fetch the memberships and call the server on a toggle. The panel + * only shows them. + */ export interface PlaylistMembershipPanelProps { - /** The dump's id. */ dumpId: string; - /** The user's playlists, and whether each holds the dump. */ memberships: PlaylistMembership[]; - /** Whether the list is loading. */ loading: boolean; - /** Called with the playlist toggled. */ onToggle: (membership: PlaylistMembership) => void; /** Called with a playlist created from the panel, which holds the dump. */ onPlaylistCreated: (membership: PlaylistMembership) => void; } /** - * Renders the user's playlists as checkboxes of whether each holds the dump, - * and a form to make a new one with it. + * Renders the playlists as checkboxes, which the keyboard toggles too, and a + * button that opens {@link PlaylistCreateForm} to make one holding the dump. */ export function PlaylistMembershipPanel({ dumpId, diff --git a/src/components/profile/ChangePasswordModal.tsx b/src/components/profile/ChangePasswordModal.tsx index c8df27fa..7a4d821f 100644 --- a/src/components/profile/ChangePasswordModal.tsx +++ b/src/components/profile/ChangePasswordModal.tsx @@ -1,5 +1,6 @@ /** - * The password change modal. + * Renders the password change dialog, which the settings tab of one's own + * profile opens. * * @module */ @@ -19,13 +20,11 @@ import { SubmitButton } from "../form/SubmitButton.tsx"; import { TextField } from "../form/TextField.tsx"; import { useApiForm } from "../../hooks/form/useApiForm.ts"; -/** The props of {@link ChangePasswordModal}. */ +/** Passed to {@link ChangePasswordModal} by `UserPublicProfile`. */ export interface ChangePasswordModalProps { - /** Called to close it. */ onClose: () => void; } -/** The form's fields. */ interface Values { currentPassword: string; newPassword: string; @@ -33,9 +32,8 @@ interface Values { } /** - * Renders the form that changes the user's password, given the current one. The - * change signs out every other session, and this one keeps the fresh tokens it - * is handed. + * Renders the form that changes the user's password, given the current one. + * The change signs out every other session, in every browser. */ export function ChangePasswordModal( { onClose }: ChangePasswordModalProps, @@ -52,8 +50,8 @@ export function ChangePasswordModal( }); const onSubmit = form.submit(async ({ currentPassword, newPassword }) => { - // The change signs out every session opened with the old password, this - // one included, so keep the fresh tokens it hands back. + // The server revokes every token issued before the change, this session's + // included, and hands back fresh ones. Other tabs read them from storage. const tokens = await expectOk<{ token: string; mediaToken: string }>( await authFetch(`${API_URL}/api/users/me/change-password`, { method: "POST", diff --git a/src/components/profile/ProfileSubpageHeader.tsx b/src/components/profile/ProfileSubpageHeader.tsx index 47efc4c6..003ac28a 100644 --- a/src/components/profile/ProfileSubpageHeader.tsx +++ b/src/components/profile/ProfileSubpageHeader.tsx @@ -1,5 +1,6 @@ /** - * The header of a profile's subpage. + * Renders the header of a profile's full lists, `/users/:username/dumps`, + * `/upvoted` and `/playlists`. * * @module */ @@ -9,20 +10,21 @@ import { Link } from "react-router"; import type { PublicUser } from "../../../shared/user.ts"; import { Avatar } from "../shared/Avatar.tsx"; -/** The props of {@link ProfileSubpageHeader}. */ +/** + * Passed to {@link ProfileSubpageHeader} by each of those pages, once it has + * loaded the profile's user. + */ export interface ProfileSubpageHeaderProps { - /** The name in the route, for the back link. */ + /** The `:username` of the route, which the back link leads to. */ username: string; - /** The profile's user. */ profileUser: PublicUser; - /** The subpage's title. */ title: string; - /** Buttons on the title row. */ + /** Buttons at the end of the title row. */ actions?: ReactNode; } /** - * Renders a link back to the profile, and the subpage's title beside the user's + * Renders a link back to the profile, then the page's title beside the user's * avatar. */ export function ProfileSubpageHeader( diff --git a/src/components/shared/Avatar.tsx b/src/components/shared/Avatar.tsx index 0bd54414..f07179ae 100644 --- a/src/components/shared/Avatar.tsx +++ b/src/components/shared/Avatar.tsx @@ -1,5 +1,6 @@ /** - * A user's avatar, or their initial. + * Renders a user's avatar, or their initial, wherever a user is named: cards, + * comments, chat, profiles and mention suggestions. * * @module */ @@ -8,24 +9,26 @@ import { useContext, useState } from "react"; import { API_URL } from "../../config/api.ts"; import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts"; -/** The props of {@link Avatar}. */ +/** + * Passed to {@link Avatar} from whatever data the caller has on the user, which + * may be stale: a live override from `AvatarOverrideContext` wins over + * `hasAvatar` and `version`. + */ export interface AvatarProps { - /** The user's id. */ userId: string; - /** The user's name, for the alt text and the initial. */ username: string; - /** Whether they have an avatar, as the surrounding data says. */ hasAvatar: boolean; - /** The side, in px; 36 by default. */ + /** Sets the side, in pixels. */ size?: number; - /** Cache-busts the avatar URL. */ + /** Added to the URL so that a changed avatar isn't served from cache. */ version?: number; } /** - * Renders a user's avatar, or their initial when they have none or it fails to - * load. A live `user_updated` override (avatar add/remove/change) wins over the - * possibly-stale props the surrounding list was rendered with. + * Renders the image from `GET /api/avatars/:userId`, or the user's initial + * when they have none or it fails to load. An avatar added, changed or removed + * since the caller's data was fetched shows at once, through the + * `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`. */ export function Avatar( { userId, username, hasAvatar, size = 36, version }: AvatarProps, @@ -33,14 +36,12 @@ export function Avatar( const [imgFailed, setImgFailed] = useState(false); const sizeStyle = { width: size, height: size }; - // A live `user_updated` override (avatar add/remove/change) wins over the - // possibly-stale props the surrounding list was rendered with. const override = useContext(AvatarOverrideContext)[userId]; const effectiveHasAvatar = override ? override.hasAvatar : hasAvatar; const effectiveVersion = override ? override.version : version; - // A new image (different version/presence) is a fresh thing to try — clear - // any prior load failure during render rather than in an effect. + // A new image deserves a new try, so a past load failure is cleared. It is + // done during render, since an effect would show the initial for a frame. const imgKey = `${effectiveHasAvatar}:${effectiveVersion ?? ""}`; const [prevImgKey, setPrevImgKey] = useState(imgKey); if (prevImgKey !== imgKey) { diff --git a/src/components/shared/ConfirmModal.tsx b/src/components/shared/ConfirmModal.tsx index 5af25bbf..49fdddbc 100644 --- a/src/components/shared/ConfirmModal.tsx +++ b/src/components/shared/ConfirmModal.tsx @@ -1,5 +1,6 @@ /** - * A confirmation dialog, for destructive actions. + * Renders the dialog that confirms a deletion, on the pages and in the dialogs + * that delete something. * * @module */ @@ -10,21 +11,22 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts"; -/** The props of {@link ConfirmModal}. */ +/** + * Passed to {@link ConfirmModal} by the component that shows it. The caller + * decides when it shows and hides it, on either callback. + */ export interface ConfirmModalProps { - /** The question asked. */ message: string; - /** The confirm button's label; "Delete" by default. */ + /** Replaces the "Delete" label. */ confirmLabel?: string; - /** Called on confirm. */ onConfirm: () => void; - /** Called on cancel, Escape or a backdrop click. */ + /** Called on Cancel, Escape or a click on the backdrop. */ onCancel: () => void; } /** - * Renders a confirmation dialog over the page. It takes Escape before a {@link - * Modal} it sits on, so only the confirm goes. + * Renders the dialog in a portal over the page, and locks the page's scroll. + * Escape closes only the confirmation, even over a {@link Modal}. */ export function ConfirmModal( { message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps, @@ -34,10 +36,9 @@ export function ConfirmModal( useScrollLock(); useEffect(() => { - // A confirm can sit on top of a Modal (deleting a chat message), whose - // own Escape handler is also on the document and was added first. Take - // the key in the capture phase and mark it handled, so the Modal leaves - // it alone and only the confirm goes. + // A confirmation can sit over a Modal (deleting a chat message), whose + // Escape handler is on the document too and was added first. Taking the + // key in the capture phase and marking it handled makes the Modal leave it. const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape" || e.defaultPrevented) return; e.preventDefault(); diff --git a/src/components/shared/CountedInput.tsx b/src/components/shared/CountedInput.tsx index ba948750..63a9d6b1 100644 --- a/src/components/shared/CountedInput.tsx +++ b/src/components/shared/CountedInput.tsx @@ -1,5 +1,6 @@ /** - * A text input with a character counter. + * Renders a controlled text input with a character counter. Unlike `TextField`, + * it takes any input prop, for an inline editor such as a playlist's title. * * @module */ @@ -7,20 +8,20 @@ import { charCountClass } from "../../utils/shared/charCount.ts"; /** - * The props of {@link CountedInput}: an input's, with a required value and - * limit. + * Passed to {@link CountedInput} by its caller, which holds the value. The rest + * goes to the `<input>`. */ export interface CountedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> { - /** The input's value. */ value: string; - /** The most characters it takes. */ + /** Stops the typing at this length, and is the counter's limit. */ maxLength: number; } /** - * Renders a text input with a counter that warns as the value nears its limit. + * Renders the input and an `n / maxLength` counter, which changes colour as the + * value nears its limit (`charCountClass`). */ export function CountedInput( { value, maxLength, ...rest }: CountedInputProps, diff --git a/src/components/shared/ErrorCard.tsx b/src/components/shared/ErrorCard.tsx index bb5aeefa..44ea8538 100644 --- a/src/components/shared/ErrorCard.tsx +++ b/src/components/shared/ErrorCard.tsx @@ -1,22 +1,27 @@ /** - * An error, as a card. + * Renders an error as a card, in place of what failed to load or save. * * @module */ import type { ReactNode } from "react"; -/** The props of {@link ErrorCard}. */ +/** + * Passed to {@link ErrorCard} by a page or a form. The `message` usually comes + * from `friendlyFetchError`. + */ export interface ErrorCardProps { - /** What failed. */ + /** Says what failed. */ title: string; - /** Why. */ message: string; - /** Buttons offered under it (retry, go back…). */ + /** Buttons shown under the card, such as "Retry". */ actions?: ReactNode; } -/** Renders an error as a card, with optional actions under it. */ +/** + * Renders the card as an ARIA alert, so a screen reader announces it when it + * appears. + */ export function ErrorCard( { title, message, actions }: ErrorCardProps, ): React.JSX.Element { diff --git a/src/components/shared/ImagePicker.tsx b/src/components/shared/ImagePicker.tsx index 7a1ed86e..56ef6c3a 100644 --- a/src/components/shared/ImagePicker.tsx +++ b/src/components/shared/ImagePicker.tsx @@ -1,33 +1,35 @@ /** - * An image that is also its own upload button. + * Renders an image that is also its own upload button, for a dump's thumbnail + * and a playlist's cover. * * @module */ import { useRef } from "react"; -/** The props of {@link ImagePicker}. */ +/** + * Passed to {@link ImagePicker} by an edit page, which uploads the file and + * then passes the new image, or a local preview of it, as `src`. + */ export interface ImagePickerProps { - /** The current image, null without one. */ + /** Shows a "+" placeholder when null. */ src: string | null; - /** Its alt text. */ alt?: string; - /** The side, in px; 80 by default. */ + /** Sets the side, in pixels. */ size?: number; - /** The corner radius, in px; 8 by default. */ borderRadius?: number; - /** Called with the file picked. */ + /** Called with the file picked. The same file can be picked again. */ onChange: (file: File) => void; - /** Whether an upload is under way: the picker is disabled meanwhile. */ + /** Disables the picker and shows "…" while the caller uploads. */ uploading?: boolean; - /** The file types offered; JPEG, PNG, GIF and WebP by default. */ + /** Replaces the default types, which are JPEG, PNG, GIF and WebP. */ accept?: string; - /** An extra class. */ className?: string; } /** - * Renders an image (or a placeholder) that opens a file picker when clicked. + * Renders the image, or a placeholder, which opens the file picker on a click, + * Enter or Space. The picker only reads the file, and uploads nothing itself. */ export function ImagePicker({ src, @@ -45,6 +47,7 @@ export function ImagePicker({ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) onChange(file); + // Or picking the same file again would fire no change. e.target.value = ""; }; diff --git a/src/components/shared/Modal.tsx b/src/components/shared/Modal.tsx index c3f791cd..9888f0e0 100644 --- a/src/components/shared/Modal.tsx +++ b/src/components/shared/Modal.tsx @@ -1,5 +1,6 @@ /** - * A modal dialog. + * Renders the dialog frame of the app's dialogs: creating a dump, the chat, + * adding to a playlist, changing a password. * * @module */ @@ -9,27 +10,27 @@ import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts"; -/** The props of {@link Modal}. */ +/** + * Passed to {@link Modal} by the component that shows it, which unmounts it in + * `onClose`. + */ export interface ModalProps { - /** The dialog's title. */ title: string; - /** Called to close it. */ onClose: () => void; - /** The dialog's body. */ children: ReactNode; - /** Whether it takes the wide layout. */ wide?: boolean; /** - * Runs before every dismissal the user did not aim at the content — Escape, - * the backdrop, the ✕. Returning `false` cancels it, which lets a modal - * holding an unsaved draft ask first instead of throwing the work away. + * Runs before a dismissal by Escape, the backdrop or the ✕. Returning + * `false` cancels it, so that a dialog holding an unsaved draft can ask + * first. */ onBeforeClose?: () => boolean; } /** - * Renders a modal dialog over the page, closed by Escape, the backdrop or its - * ✕, and locks the page's scroll while it is open. + * Renders the dialog in a portal over the page, closed by Escape, the backdrop + * or its ✕, and locks the page's scroll while it is open. Content that handles + * Escape itself must call `preventDefault` on it, or the dialog closes too. */ export function Modal( { title, onClose, children, wide = false, onBeforeClose }: ModalProps, diff --git a/src/components/shared/Thumbnail.tsx b/src/components/shared/Thumbnail.tsx index e4e941a2..d288cc66 100644 --- a/src/components/shared/Thumbnail.tsx +++ b/src/components/shared/Thumbnail.tsx @@ -1,5 +1,6 @@ /** - * An external thumbnail, with fallbacks down to a placeholder. + * Renders a dump's preview image from another site, in the cards and the + * player, falling back to a generated placeholder. * * @module */ @@ -11,36 +12,29 @@ import { type ThumbnailPlaceholderProps, } from "./ThumbnailPlaceholder.tsx"; -/** The props of {@link Thumbnail}. */ +/** + * Passed to {@link Thumbnail} by a card or the player, from the dump's rich + * content. + */ export interface ThumbnailProps { - /** Raw image URL. Proxying is decided here — don't pre-proxy it. */ + /** The image's own URL, not proxied, since the fallbacks decide when to. */ src?: string; - /** Its alt text. */ alt?: string; - /** The image's class. */ + /** Applies to the image only. */ className?: string; - /** When to load it; lazily by default. */ loading?: "lazy" | "eager"; - /** Passed through to the placeholder when there's nothing to show. */ + /** Shown when there's no `src` or it fails to load. */ placeholder: Omit<ThumbnailPlaceholderProps, "className">; - /** Extra class for the placeholder element only. */ placeholderClassName?: string; } /** - * Renders an external thumbnail, and always renders *something*. - * - * Three attempts, in order: - * - * 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is - * one click) answers a cross-site referrer with 403, and an empty one with - * 200; - * 2. retry through `/api/proxy-image`, which fetches server-side, for hosts - * that reject an empty referrer too; - * 3. fall back to the generated placeholder. - * - * Before this existed each of these failures ended at `display: none`, leaving - * an empty framed box and no signal anywhere that a load had failed. + * Renders the image, and always renders something. It first loads the image + * with no `Referer`, since hotlink protection (Cloudflare's is one click) + * refuses a cross-site referrer but lets an empty one through. It then retries + * through `GET /api/proxy-image` for the hosts that refuse an empty referrer + * too, and ends with the {@link ThumbnailPlaceholder}. An HTTP image goes + * through the proxy from the first try, to avoid mixed content. */ export function Thumbnail( { @@ -56,8 +50,8 @@ export function Thumbnail( "direct", ); - // A new src is a fresh thing to try — reset during render rather than in an - // effect (same shape as Avatar.tsx). + // A new src starts over. It is reset during render, as in Avatar.tsx, since + // an effect would show the placeholder for a frame. const [prevSrc, setPrevSrc] = useState(src); if (prevSrc !== src) { setPrevSrc(src); diff --git a/src/components/shared/ThumbnailPlaceholder.tsx b/src/components/shared/ThumbnailPlaceholder.tsx index 443b7157..531cb3e4 100644 --- a/src/components/shared/ThumbnailPlaceholder.tsx +++ b/src/components/shared/ThumbnailPlaceholder.tsx @@ -1,5 +1,6 @@ /** - * Generated artwork for a page with no preview image. + * Renders the artwork that stands in for a dump with no preview image, in the + * cards and the player. `Thumbnail` falls back to it when its image fails. * * @module */ @@ -8,31 +9,29 @@ import { useState } from "react"; import { proxyImageUrl } from "../../utils/shared/proxyImage.ts"; import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts"; -/** The props of {@link ThumbnailPlaceholder}. */ +/** + * Passed to {@link ThumbnailPlaceholder} by a card, from the dump and its rich + * content, or through `Thumbnail`'s `placeholder`. + */ export interface ThumbnailPlaceholderProps { - /** The dumped URL — seeds the fallback hue and the initial. */ + /** The dumped URL, whose hostname seeds the hue and the initial. */ url?: string; - /** Explicit hue seed for dumps with no URL to hash (file dumps). */ + /** Seeds the hue for a file dump, which has no URL to hash. */ seed?: string; - /** The target page's declared brand color, if it had one. */ + /** The dumped page's `theme-color`, used as the tint when it is valid. */ accentColor?: string; - /** The target page's icon, drawn contained over the tint. */ faviconUrl?: string; - /** Preferred source for the initial when there's no favicon. */ + /** Gives the initial ahead of the hostname. */ siteName?: string; - /** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */ + /** Replaces the initial, since a file dump says more with 🎬 than with "T". */ glyph?: string; - /** An extra class. */ className?: string; } /** - * Renders stand-in artwork for pages that offer no preview image: the site's - * own color washed over the theme surface, with its favicon centered on top — - * or its initial when the favicon is missing or fails to load. - * - * Fills whatever box it's placed in, so the same component serves the 36px - * compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile. + * Renders the site's colour washed over the theme's surface, with its favicon + * on top, or its initial when the favicon is missing or fails to load. It fills + * whatever box it is placed in, from a small thumbnail to a mosaic tile. */ export function ThumbnailPlaceholder( { url, seed, accentColor, faviconUrl, siteName, glyph, className }: @@ -40,8 +39,8 @@ export function ThumbnailPlaceholder( ): React.JSX.Element { const [iconFailed, setIconFailed] = useState(false); - // A different icon is a fresh thing to try — clear the prior failure during - // render rather than in an effect (same shape as Avatar.tsx). + // A new icon deserves a new try. The failure is cleared during render, as in + // Avatar.tsx, since an effect would show the initial for a frame. const [prevIcon, setPrevIcon] = useState(faviconUrl); if (prevIcon !== faviconUrl) { setPrevIcon(faviconUrl); diff --git a/src/components/shared/Tooltip.tsx b/src/components/shared/Tooltip.tsx index 803e7118..1dbf83d7 100644 --- a/src/components/shared/Tooltip.tsx +++ b/src/components/shared/Tooltip.tsx @@ -1,5 +1,6 @@ /** - * A tooltip. + * Renders a tooltip on hover. Every caller uses it to give the full date over a + * relative time or an "edited" mark. * * @module */ @@ -7,15 +8,18 @@ import { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; -/** The props of {@link Tooltip}. */ +/** Passed to {@link Tooltip} by the component that wraps a label in it. */ export interface TooltipProps { - /** The tooltip's text. */ text: string; - /** What it is shown over. */ children: React.ReactNode; } -/** Renders `children` with a tooltip above them on hover. */ +/** + * Renders `children` in a `<span>`, with `text` above them while the mouse is + * over them. The tooltip is portaled to `<body>`, so no container clips it, + * and it stays where it opened if the page scrolls. It never shows on touch or + * keyboard focus. + */ export function Tooltip({ text, children }: TooltipProps): React.JSX.Element { const [rect, setRect] = useState<DOMRect | null>(null); const ref = useRef<HTMLSpanElement>(null); diff --git a/src/components/social/FollowButton.tsx b/src/components/social/FollowButton.tsx index 08cd6cde..c92ccb23 100644 --- a/src/components/social/FollowButton.tsx +++ b/src/components/social/FollowButton.tsx @@ -1,5 +1,7 @@ /** - * The follow buttons, for a user and for a playlist. + * Renders the Follow buttons of a user's profile and of a playlist's page. + * Both go through `FollowContext`, which follows at once and rolls back when + * the server refuses. * * @module */ @@ -9,25 +11,24 @@ import { Trans } from "@lingui/react/macro"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useFollows } from "../../hooks/social/useFollows.ts"; -/** The props of {@link FollowUserButton}. */ +/** Passed to {@link FollowUserButton} by the `UserPublicProfile` page. */ export interface FollowUserButtonProps { - /** The user's id. */ targetUserId: string; - /** The user's name, for the button's label. */ + /** Names the user in the button's accessible label. */ targetUsername: string; } -/** The props of {@link FollowPlaylistButton}. */ +/** Passed to {@link FollowPlaylistButton} by the `PlaylistDetail` page. */ export interface FollowPlaylistButtonProps { - /** The playlist's id. */ targetPlaylistId: string; - /** Whether it is public; only a public playlist can be followed. */ + /** Hides the button, since only a public playlist can be followed. */ isPublic: boolean; } /** - * Renders the button that follows or unfollows a user; nothing when signed out - * or on one's own profile. + * Renders the button that follows or unfollows a user. It renders nothing for + * a visitor or on the user's own profile, and is disabled until the follows + * have loaded. */ export function FollowUserButton( { targetUserId, targetUsername }: FollowUserButtonProps, @@ -56,8 +57,10 @@ export function FollowUserButton( } /** - * Renders the button that follows or unfollows a playlist; nothing when signed - * out or for a private playlist. + * Renders the button that follows or unfollows a playlist. It renders nothing + * for a visitor or a private playlist, and is disabled until the follows have + * loaded. Unlike {@link FollowUserButton}, it shows on the user's own + * playlists too. */ export function FollowPlaylistButton( { targetPlaylistId, isPublic }: FollowPlaylistButtonProps, diff --git a/src/components/social/LikeButton.tsx b/src/components/social/LikeButton.tsx index 41b0b44f..734e045d 100644 --- a/src/components/social/LikeButton.tsx +++ b/src/components/social/LikeButton.tsx @@ -1,5 +1,5 @@ /** - * A comment's like button. + * Renders the like button of a comment in a dump's thread. * * @module */ @@ -9,25 +9,27 @@ import { t } from "@lingui/core/macro"; import { API_URL } from "../../config/api.ts"; import { UserListPopover } from "./UserListPopover.tsx"; -/** The props of {@link LikeButton}. */ +/** + * Passed to {@link LikeButton} by `CommentNode`, with the state and the + * functions of `WSContext`. + */ export interface LikeButtonProps { - /** The comment's id. */ commentId: string; - /** Its like count. */ + /** The socket's live count if it has one, else the comment's `likeCount`. */ count: number; - /** Whether the user likes it. */ liked: boolean; - /** Whether it is disabled (signed out). */ + /** Set for a visitor, whom the tooltip asks to log in. */ disabled?: boolean; - /** Likes the comment; `shownCount` is the count on screen. */ + /** Called with `count`, which `WSContext` needs until it has a live count. */ onLike: (commentId: string, shownCount?: number) => void; - /** Withdraws the like; `shownCount` is the count on screen. */ + /** Called with `count`, as `onLike` is. */ onUnlike: (commentId: string, shownCount?: number) => void; } /** - * Renders a comment's like button with its count, which opens the list of who - * likes it. + * Renders a comment's like button and its count. Clicking the count, when it + * isn't zero, opens the list of likers (`GET /api/comments/:id/likers`) + * instead. */ export function LikeButton( { commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps, @@ -54,6 +56,7 @@ export function LikeButton( ref={countRef} className="like-count-clickable" onClick={(e) => { + // Or the button would toggle the like too. e.stopPropagation(); setPopoverOpen((o) => !o); }} diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx index ea1418f8..24be0935 100644 --- a/src/components/social/UserListPopover.tsx +++ b/src/components/social/UserListPopover.tsx @@ -1,5 +1,6 @@ /** - * A popover listing users: who voted for a dump, who likes a comment. + * Renders the list of users behind a count. `VoteButton` opens it on a dump's + * voters, and `LikeButton` on a comment's likers. * * @module */ @@ -17,17 +18,21 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts"; import { parseAPIResponse } from "../../utils/shared/api.ts"; import type { PublicUser } from "../../../shared/user.ts"; -/** The props of {@link UserListPopover}. */ +/** + * Passed to {@link UserListPopover} by the button whose count was clicked. The + * button owns the open state and unmounts the popover in `onClose`. + */ export interface UserListPopoverProps { - /** The element it opens against. */ anchorRef: React.RefObject<HTMLElement | null>; - /** Called to close it: an outside click, Escape, or a scroll. */ + /** Called on an outside click, on Escape, and on any scroll. */ onClose: () => void; - /** The paginated endpoint listing the users. */ + /** + * A paginated endpoint answering `PaginatedData<PublicUser>`, without a query + * string: the popover appends `page` and `limit`. + */ fetchUrl: string; } -/** The list's state: loading, failed, or loaded with a page of users. */ type State = | { status: "loading" } | { status: "error"; error: string } @@ -39,7 +44,8 @@ type State = loadingMore: boolean; }; -/** Where the popover sits, in viewport coordinates. */ +// In viewport coordinates. `bottom` replaces `top` when the popover opens above +// the anchor, so that it grows upward. interface Placement { left: number; top?: number; @@ -47,17 +53,17 @@ interface Placement { maxHeight: number; } -/** The popover's preferred largest height, in px. */ const PREFERRED_MAX_HEIGHT = 280; -/** The space kept clear of the viewport's edges, in px. */ const VIEWPORT_MARGIN = 8; -/** The least height worth opening below the anchor, in px. */ +// Below this much room under the anchor, the popover opens above it instead, +// if there is more room there. const MIN_USABLE_HEIGHT = 100; /** - * Renders the users from `fetchUrl`, loading more on demand, in a popover - * against `anchorRef`. It is fixed and portaled to <body> so a card's overflow - * can't clip it, and flips above the anchor when there isn't enough room below. + * Renders the users from `fetchUrl` in a popover next to `anchorRef`, with a + * button that loads the next page. It is fixed and portaled to `<body>`, so a + * card's `overflow` can't clip it. It renders nothing until it has measured + * the anchor. */ export function UserListPopover( { anchorRef, onClose, fetchUrl }: UserListPopoverProps, @@ -67,11 +73,8 @@ export function UserListPopover( const [placement, setPlacement] = useState<Placement | null>(null); const popoverRef = useRef<HTMLDivElement>(null); - // Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed + - // portaled to <body> so the popover can't be clipped by a card's overflow. - // Flips above the anchor (anchored from the viewport bottom edge, so it - // grows upward) when there isn't enough room below, so a long list never - // overflows past the bottom of the viewport. + // Placed once, on open: any scroll closes the popover, so it never needs to + // follow the anchor. useEffect(() => { const anchorRect = anchorRef.current?.getBoundingClientRect(); if (!anchorRect) { @@ -117,6 +120,7 @@ export function UserListPopover( function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } + // Captured, so a scroll inside any container closes it too. function onScroll() { onClose(); } diff --git a/src/components/social/VoteButton.tsx b/src/components/social/VoteButton.tsx index 91e997c4..7e95ee51 100644 --- a/src/components/social/VoteButton.tsx +++ b/src/components/social/VoteButton.tsx @@ -1,5 +1,5 @@ /** - * A dump's vote button. + * Renders the upvote button of a dump, on its card and on its page. * * @module */ @@ -9,24 +9,26 @@ import { t } from "@lingui/core/macro"; import { API_URL } from "../../config/api.ts"; import { UserListPopover } from "./UserListPopover.tsx"; -/** The props of {@link VoteButton}. */ +/** + * Passed to {@link VoteButton} by `DumpCard`, `JournalCard` and the `Dump` + * page, with the state and the functions of `WSContext`. + */ export interface VoteButtonProps { - /** The dump's id. */ dumpId: string; - /** Its vote count. */ + /** The socket's live count if it has one, else the dump's `voteCount`. */ count: number; - /** Whether the user upvoted it. */ voted: boolean; - /** Whether it is disabled (signed out). */ + /** Set for a visitor, whom the tooltip asks to log in. */ disabled?: boolean; - /** Upvotes the dump; `shownCount` is the count on screen. */ + /** Called with `count`, which `WSContext` needs until it has a live count. */ onCast: (dumpId: string, shownCount?: number) => void; - /** Withdraws the vote; `shownCount` is the count on screen. */ + /** Called with `count`, as `onCast` is. */ onRemove: (dumpId: string, shownCount?: number) => void; } /** - * Renders a dump's vote button with its count, which opens the list of voters. + * Renders a dump's upvote button and its count. Clicking the count, when it + * isn't zero, opens the list of voters (`GET /api/dumps/:id/voters`) instead. */ export function VoteButton( { dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps, @@ -50,6 +52,7 @@ export function VoteButton( ref={countRef} className="vote-count-clickable" onClick={(e) => { + // Or the button would toggle the vote too. e.stopPropagation(); setPopoverOpen((o) => !o); }} diff --git a/src/config/api.ts b/src/config/api.ts index 0058a336..2954841d 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -1,11 +1,11 @@ /** - * Where the API and the socket live, and the page sizes the feeds ask for. + * Gives every request of the frontend its base URL, and the feeds their page + * sizes. * - * In dev (Vite dev server), the frontend and API run on different ports, so the - * URLs are absolute; `VITE_API_*` variables can override the defaults. In prod - * (same container), the frontend is served by the API server itself, so both - * share the same origin and the URLs are relative (""): no build-time - * configuration is needed and the image works on any domain. + * In development, Vite serves the frontend on its own port, so the URLs are + * absolute, and `VITE_API_*` variables can point elsewhere. In production the + * API server serves the frontend, so the URLs are relative and the same build + * works on any domain. * * @module */ @@ -14,7 +14,10 @@ const apiHostname = import.meta.env.VITE_API_HOSTNAME; -/** The API's origin, empty when it is the page's own. */ +/** + * The API's origin, which every `fetch` of the frontend prefixes to its path. + * It is empty in production, where the API is the page's own origin. + */ export const API_URL: string = apiHostname ? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${ import.meta.env.VITE_API_PORT || "8000" @@ -23,14 +26,17 @@ export const API_URL: string = apiHostname ? "http://localhost:8000" : ""; -/** The socket's origin, always absolute. */ +/** + * The socket's origin, for `WSProvider`. It is always absolute, since + * `WebSocket` takes no relative URL. + */ export const WS_URL: string = API_URL ? API_URL.replace(/^http/, "ws") : `${location.protocol.replace("http", "ws")}//${location.host}`; -/** The search endpoint. */ +/** The search endpoint, for the search page. */ export const SEARCH_URL = `${API_URL}/api/search`; -/** How many items a feed page asks for. */ +/** How many items a page of a feed or list asks the API for. */ export const DEFAULT_PAGE_SIZE = 20; -/** How many notifications a page asks for. */ +/** How many notifications a page of `/notifications` asks the API for. */ export const NOTIFICATIONS_PAGE_SIZE = 30; diff --git a/src/config/feedTabs.ts b/src/config/feedTabs.ts index 6bb05618..475f3a06 100644 --- a/src/config/feedTabs.ts +++ b/src/config/feedTabs.ts @@ -1,15 +1,20 @@ /** - * The index's feed tabs, and the one it opens on. + * Lists the feed tabs of the index page, shared by the tab bar, the routes and + * the default tab setting. * * @module */ -/** The index's feed tabs, in the tab bar's order. */ +/** + * The feed tabs, in the tab bar's order. Each is the `:feedTab` of + * `/~/:feedTab` and `/:categorySlug/:feedTab`. + */ export const FEED_TABS = ["hot", "new", "journal", "followed"] as const; /** One of the {@link FEED_TABS}. */ export type FeedTab = (typeof FEED_TABS)[number]; /** - * The tab the index opens on for visitors and for users who haven't picked one. + * The tab `/` redirects to for visitors, and for users who haven't picked one + * in their settings. */ export const DEFAULT_FEED_TAB: FeedTab = "journal"; diff --git a/src/config/playerMode.ts b/src/config/playerMode.ts index 17fd9422..f6bc6152 100644 --- a/src/config/playerMode.ts +++ b/src/config/playerMode.ts @@ -1,28 +1,25 @@ /** - * Which Bandcamp playback path this deployment uses. + * Reads which Bandcamp player this deployment uses, for `usePlayRichContent`. * - * The server injects `GERBEUR_BANDCAMP_PLAYER` into a `<meta>` tag at request - * time (api/lib/static.ts and api/middleware/og.ts; mirrored by the Vite dev - * plugin), the same mechanism as site-name/site-emoji. Read once at startup. - * - * Defaults to "embed" whenever the value is missing, unsubstituted or - * unrecognised, so a misconfigured deployment keeps the iframe it has today. + * The server writes `GERBEUR_BANDCAMP_PLAYER` into index.html's + * `bandcamp-player` `<meta>` tag (`api/lib/index-html.ts`, and the Vite config + * in development). A missing or unknown value means "embed", so a + * misconfigured deployment keeps Bandcamp's iframe. * * @module */ /** - * A Bandcamp playback path: Bandcamp's own iframe, or the site's player on the - * resolved streams. + * A Bandcamp player. "embed" is Bandcamp's own iframe, "native" is the site's + * player on the resolved streams. */ export type BandcampPlayer = "embed" | "native"; -/** Reads the playback path from the page's `<meta>` tag. */ function readBandcampPlayer(): BandcampPlayer { const meta = document.querySelector('meta[name="bandcamp-player"]') ?.getAttribute("content")?.trim(); return meta === "native" ? "native" : "embed"; } -/** This deployment's Bandcamp playback path. */ +/** This deployment's Bandcamp player, read once when the app loads. */ export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer(); diff --git a/src/contexts/auth/AuthContext.ts b/src/contexts/auth/AuthContext.ts index 0d770074..3f0c8006 100644 --- a/src/contexts/auth/AuthContext.ts +++ b/src/contexts/auth/AuthContext.ts @@ -1,6 +1,6 @@ /** - * The session context: the signed-in session and its setter, provided by - * AuthProvider and read through `useAuth`. + * Declares the session's React context. `AuthProvider` fills it and `useAuth` + * reads it; nothing else should touch it. * * @module */ @@ -9,15 +9,23 @@ import { createContext, type Dispatch, type SetStateAction } from "react"; import type { Session } from "../../utils/auth/storage.ts"; -/** What {@link AuthContext} holds. */ +/** + * Provided by `AuthProvider` to `useAuth`, which builds the session's API on + * it. + */ export interface AuthContextValue { - /** The signed-in session, null when signed out. */ authResponse: Session | null; - /** Replaces the session; AuthProvider keeps the stored one in step. */ + /** + * Replaces the session in memory only. Its callers store the new session + * themselves, with `storeSession`, so other tabs follow. + */ setAuthResponse: Dispatch<SetStateAction<Session | null>>; } -/** The session context. */ +/** + * The session's context. Its default, a signed-out session whose setter does + * nothing, is only seen outside `AuthProvider`, as in some tests. + */ export const AuthContext: React.Context<AuthContextValue> = createContext< AuthContextValue >({ diff --git a/src/contexts/auth/AuthProvider.tsx b/src/contexts/auth/AuthProvider.tsx index 06215704..440e0e9e 100644 --- a/src/contexts/auth/AuthProvider.tsx +++ b/src/contexts/auth/AuthProvider.tsx @@ -1,6 +1,6 @@ /** - * The session's provider: holds it, keeps it in step with the other tabs and - * the server. + * Holds the session for the whole app and keeps it in step with the other tabs + * and with the server. `App` mounts it near the root. * * @module */ @@ -19,18 +19,16 @@ import { } from "../../utils/auth/storage.ts"; import type { Session } from "../../utils/auth/storage.ts"; -/** The props of {@link AuthProvider}. */ +/** Passed to {@link AuthProvider} by `App`. */ export interface AuthProviderProps { - /** The app, which reads the session. */ children: ReactNode; } /** - * Provides {@link AuthContext}. The session starts as the stored one, follows - * the stored one when another tab signs in, out or changes the password, is - * checked with the server on mount and whenever the tab regains focus (a 401 - * ends it; a changed role is picked up), and gets a media token when it - * predates them. + * Provides {@link AuthContext}. The session starts as the stored one and + * follows it when another tab changes it. The server checks it on mount and + * whenever the tab regains focus: a 401 ends it, and a changed role replaces + * the stored user. A session from before media tokens gets one. */ export function AuthProvider( { children }: AuthProviderProps, @@ -39,9 +37,9 @@ export function AuthProvider( readStoredSession, ); - // Every tab shares the stored session, so follow it: a login, logout or - // password change in another tab applies here too, rather than this tab - // carrying on with a token the server has since revoked. + // A login, logout or password change in another tab applies here too, so + // this tab doesn't carry on with a token the server has revoked. A null key + // means localStorage was cleared. useEffect(() => { const onStorage = (e: StorageEvent) => { if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return; @@ -51,12 +49,10 @@ export function AuthProvider( return () => globalThis.removeEventListener("storage", onStorage); }, []); - // The `exp` pre-check above only catches expiry; it cannot detect a token the - // server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which - // leaves the signature invalid but the token unexpired). Ask the server — the - // only authority on validity — and clear the session if it returns 401, so the - // UI never shows a false "logged in" state. Re-check when the tab regains - // focus to catch tokens invalidated by a server restart while we were away. + // readStoredSession only drops expired tokens. The server also rejects + // tokens revoked by a password change and tokens signed with a former + // GERBEUR_JWT_SECRET, so ask it, and again when the tab comes back, since + // either can happen while it sits in the background. const token = authResponse?.token; useEffect(() => { if (!token) return; @@ -68,16 +64,15 @@ export function AuthProvider( cache: "no-store", }); if (res.status === 401) { - // Only this token is dead; the session may have moved on to a - // fresh one (another tab's password change) while we asked. + // Only this token is dead. The session may have moved on to fresh + // tokens while we asked (a password change, here or in another tab). forgetStoredSession(token); setAuthResponse((prev) => prev?.token === token ? null : prev); return; } - // The stored user is a snapshot from login time; the role (and other - // profile fields) can change server-side — e.g. an admin promoting this - // user. Sync from the authoritative /me response so the UI reflects role - // changes without forcing a logout/login cycle. + // The stored user dates from sign-in, and an admin may have changed + // the role since. Only a role change replaces it, so an unchanged + // session keeps its identity and doesn't re-render the app. if (res.ok) { const json = await res.json(); if (json?.success && json.data) { @@ -91,7 +86,7 @@ export function AuthProvider( } } } catch { - // Network error — don't log out an offline user; only an explicit 401 clears the session. + // Offline is not signed out: only a 401 ends the session. } }; @@ -107,8 +102,8 @@ export function AuthProvider( }; }, [token]); - // Sessions from before media tokens existed have none, and would keep putting - // the full session token in file and thumbnail URLs; fetch one once. + // Without a media token, file and thumbnail URLs carry the session token in + // their query string, where it leaks into logs and history. const needsMediaToken = !!token && !authResponse?.mediaToken; useEffect(() => { if (!token || !needsMediaToken) return; diff --git a/src/contexts/category/CategoriesContext.ts b/src/contexts/category/CategoriesContext.ts index d64c1e38..bd27889f 100644 --- a/src/contexts/category/CategoriesContext.ts +++ b/src/contexts/category/CategoriesContext.ts @@ -1,5 +1,6 @@ /** - * The categories context: every category, loaded once by CategoriesProvider. + * Declares the categories' React context. `CategoriesProvider` fills it and + * `useCategories` reads it. * * @module */ @@ -7,19 +8,28 @@ import { createContext } from "react"; import type { Category } from "../../../shared/category.ts"; -/** What {@link CategoriesContext} holds. */ +/** + * Provided by `CategoriesProvider` to `useCategories`, for the category + * switcher and picker, the dump pages and cards, and the category manager. + */ export interface CategoriesContextValue { - /** Every category, in order. */ + /** Every category, in the server's order, by position then slug. */ categories: Category[]; - /** The categories by id, for resolving dump category chips. */ + /** Resolves a dump's `categoryIds` to its category chips. */ byId: Map<string, Category>; - /** Whether the list has been fetched. */ + /** + * Whether the first fetch has ended. It is also true after a network error, + * with no categories. + */ isLoaded: boolean; - /** Re-fetches the list (used after admin create/update/delete). */ + /** Refetches the list. The category manager calls it after each change. */ refresh: () => Promise<void>; } -/** The categories context. */ +/** + * The categories' context. Its default, an empty list never loaded, is only + * seen outside `CategoriesProvider`. + */ export const CategoriesContext: React.Context<CategoriesContextValue> = createContext< CategoriesContextValue diff --git a/src/contexts/category/CategoriesProvider.tsx b/src/contexts/category/CategoriesProvider.tsx index e339a5c0..e42e9994 100644 --- a/src/contexts/category/CategoriesProvider.tsx +++ b/src/contexts/category/CategoriesProvider.tsx @@ -1,5 +1,6 @@ /** - * The categories' provider: fetches the list once, and again on request. + * Loads the site's categories once for the whole app, and again when the + * category manager changes them. `App` mounts it. * * @module */ @@ -23,7 +24,7 @@ import type { Wire } from "../../../shared/api.ts"; /** * Returns every category, or null when the API answers a failure. * - * @throws {Error} When the request fails. + * @throws {Error} When the request fails or answers an error status. */ async function fetchCategories( signal?: AbortSignal, @@ -35,13 +36,15 @@ async function fetchCategories( return (body.data as Wire<Category>[]).map(deserializeCategory); } -/** The props of {@link CategoriesProvider}. */ +/** Passed to {@link CategoriesProvider} by `App`. */ export interface CategoriesProviderProps { - /** The app, which reads the categories. */ children: ReactNode; } -/** Provides {@link CategoriesContext}, fetching the categories on mount. */ +/** + * Provides {@link CategoriesContext}, fetching the categories on mount. A + * failed fetch keeps the previous list. + */ export function CategoriesProvider( { children }: CategoriesProviderProps, ): React.JSX.Element { @@ -55,6 +58,8 @@ export function CategoriesProvider( setCategories(data); setIsLoaded(true); }) + // A failed fetch still ends the loading, so that pages waiting on it + // don't wait forever. .catch((err) => { if ((err as Error).name !== "AbortError") setIsLoaded(true); }), []); diff --git a/src/contexts/chat/ChatContext.ts b/src/contexts/chat/ChatContext.ts index b375ae46..a467681e 100644 --- a/src/contexts/chat/ChatContext.ts +++ b/src/contexts/chat/ChatContext.ts @@ -1,22 +1,25 @@ /** - * The chat context: whether the chatbox is open, provided by ChatProvider. + * Declares the context through which the header's chat button and the floating + * chat button open the one chat modal, which `ChatProvider` owns. Read it with + * `useChat`. * * @module */ import { createContext } from "react"; -/** What {@link ChatContext} holds. */ +/** Provided by `ChatProvider` through {@link ChatContext}. */ export interface ChatContextValue { - /** Whether the chatbox is open. */ + /** Never true for a visitor, whatever `openChat` or the URL asked. */ isChatOpen: boolean; - /** Opens the chatbox. */ openChat: () => void; - /** Closes the chatbox. */ closeChat: () => void; } -/** The chat context. */ +/** + * Holds the chat modal's open state. Outside `ChatProvider` it is closed and + * its functions do nothing. + */ export const ChatContext: React.Context<ChatContextValue> = createContext< ChatContextValue >({ diff --git a/src/contexts/chat/ChatProvider.tsx b/src/contexts/chat/ChatProvider.tsx index e8cee626..36c37619 100644 --- a/src/contexts/chat/ChatProvider.tsx +++ b/src/contexts/chat/ChatProvider.tsx @@ -1,5 +1,6 @@ /** - * The chat's provider, which owns the one chat modal. + * Provides `ChatContext` and renders the chat modal while it is open. `App` + * mounts it inside the router, around every page. * * @module */ @@ -16,25 +17,23 @@ import { useSearchParams } from "react-router"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { ChatContext, type ChatContextValue } from "./ChatContext.ts"; -/** The chat modal, loaded on first open. */ +/** Loaded on first open, so visitors never download the chat. */ const ChatModal = lazy(() => import("../../components/chat/ChatModal.tsx").then((m) => ({ default: m.ChatModal, })) ); -/** The props of {@link ChatProvider}. */ +/** Passed to {@link ChatProvider} by `App`. */ export interface ChatProviderProps { - /** The app, which opens and closes the chat. */ children: ReactNode; } /** - * Provides {@link ChatContext} and owns the single live-chat modal instance so - * the header button, the floating button, and notification deep-links all drive - * one modal. Open state is derived (no effects): a manual open, or a - * `?chat=open` query param (used by mention notifications), opens it — and it's - * only ever open for signed-in users. + * Provides {@link ChatContext} and renders the one chat modal while it is + * open. `openChat` opens it, and so does a `?chat=open` query parameter on any + * page, which is where a chat mention notification links. It stays closed for + * visitors. */ export function ChatProvider( { children }: ChatProviderProps, @@ -49,7 +48,7 @@ export function ChatProvider( const openChat = useCallback(() => setManuallyOpen(true), []); const closeChat = useCallback(() => { setManuallyOpen(false); - // Strip the deep-link param so a reload/back-nav doesn't reopen the modal. + // Or a reload would open the chat again. if (searchParams.get("chat") === "open") { const next = new URLSearchParams(searchParams); next.delete("chat"); diff --git a/src/contexts/player/PlayerContext.ts b/src/contexts/player/PlayerContext.ts index 0c0d7c2c..68ca8e60 100644 --- a/src/contexts/player/PlayerContext.ts +++ b/src/contexts/player/PlayerContext.ts @@ -1,85 +1,81 @@ /** - * The player contexts: what plays, the queue and the controls, and apart from - * them the playhead. PlayerProvider provides both; GlobalPlayer draws them. + * Defines the player's two contexts, one for the queue and the controls and one + * for the playhead alone. PlayerProvider provides both around the whole app; + * GlobalPlayer draws the player, and the cards and previews that start playback + * read and drive it. * * @module */ import { createContext } from "react"; -/** Fields every playable item shares, whatever its source. */ +/** + * The fields of every {@link PlayerItem}, which GlobalPlayer's header shows. + */ export interface PlayerItemBase { - /** The item's title. */ title?: string; - /** The page of the dump it was played from. */ + /** The dump page the header's title links to. */ dumpHref?: string; - /** - * Square image for the player header — provider thumbnail, album art, or a - * video still. Raw URL: Thumbnail decides whether to proxy it. - */ + /** A raw URL, which `Thumbnail` proxies when it must. */ artworkUrl?: string; /** - * Secondary line under the title: the artist, the site name, the filename — - * whatever identifies the source at a glance. + * Shown under the title. It holds the artist, the site's name or the file's + * name. */ subtitle?: string; } /** - * Something the player can play: a provider's embed, an uploaded file, or an - * expiring stream. + * Built by the cards and previews that start playback, and held in + * PlayerProvider's queue, which localStorage keeps across reloads. An `embed` + * plays in the provider's iframe; a `file` (an upload) and a `stream` play in + * MediaPlayer. */ export type PlayerItem = | (PlayerItemBase & { - /** A provider's iframe player. */ kind: "embed"; - /** The embed's URL. */ embedUrl: string; - /** The provider, e.g. "youtube". */ + /** The provider, as in `RichContent.type`. */ type: string; }) | (PlayerItemBase & { - /** An uploaded file, played by a media element. */ kind: "file"; - /** The file's URL. */ + /** Carries the listener's media token when they are signed in. */ fileUrl: string; - /** The file's type. */ + /** Decides between the audio and the video player. */ mimeType: string; }) /** - * An expiring, remotely-hosted stream (today: a Bandcamp mp3). - * - * Unlike a "file" item the URL is signed and dies after ~24h, so the item - * carries everything needed to fetch a fresh one: `resolveUrl` is the page it - * came from and `resolveIndex` its position in that page's tracklist. That - * pair is also the item's stable identity, since `streamUrl` changes on every - * re-resolve. + * A track streamed from another site, today a Bandcamp mp3. Its URL is + * signed and dies after about 24 hours, so the item keeps the page it came + * from (`resolveUrl`) and its place in that page's tracklist (`resolveIndex`) + * to fetch a fresh one. That pair is also its identity, since `streamUrl` + * changes on every re-resolve. */ | (PlayerItemBase & { - /** An expiring stream. */ kind: "stream"; - /** The signed stream URL. */ streamUrl: string; - /** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */ + /** + * The service, which also styles the player (`global-player--bandcamp`). + */ type: string; - /** Known before playback from the tracklist, so rows can show a length. */ + /** Known from the tracklist before playback, for the tracklist's rows. */ duration?: number; - /** The track's number on its release. */ + /** Numbers the tracklist's row, in place of the queue position. */ trackNum?: number; - /** The page the stream was resolved from. */ resolveUrl: string; - /** The track's position in that page's tracklist. */ resolveIndex: number; - /** Epoch ms of the resolution that produced `streamUrl`. */ + /** When `streamUrl` was signed, in epoch ms, for `isStreamStale`. */ resolvedAt: number; - /** Iframe embed to fall back to if native playback stops working. */ + /** The iframe PlayerProvider falls back to if the stream stays dead. */ embedUrl?: string; }); /** - * Returns a stable identity for `item`, used for active-state comparisons and - * to decide when the player is showing something new. Deliberately independent - * of `streamUrl` so a re-resolved track is still recognised as the same track. + * Returns a key that identifies `item` whatever its stream URL, so that a + * re-resolved track is still the same track. GlobalPlayer keys its tracklist + * on it and tells a new item by it; PlayerProvider tells repeated failures of + * one track by it. */ export function playerItemKey(item: PlayerItem): string { switch (item.kind) { @@ -93,13 +89,13 @@ export function playerItemKey(item: PlayerItem): string { } /** - * What {@link PlayerContext} holds: playback state, readable by any consumer, - * and controls, callable by any consumer. + * Provided by PlayerProvider through {@link PlayerContext}. Cards and previews + * read what plays and call the controls; GlobalPlayer and MediaPlayer use the + * internal fields to report the media's state back. */ export interface PlayerContextValue { - /** The item playing or paused; always `queue[queueIndex]`. */ + /** Always `queue[queueIndex]`, or null when nothing plays. */ current: PlayerItem | null; - /** Whether it is playing. */ playing: boolean; /** @@ -107,62 +103,60 @@ export interface PlayerContextValue { * and albums share one code path. */ queue: PlayerItem[]; - /** The position of `current` in the queue. */ queueIndex: number; - /** Whether an item follows `current`. */ hasNext: boolean; - /** Whether an item precedes `current`. */ hasPrevious: boolean; - /** True while a stream is being (re-)resolved, for spinner affordances. */ + /** True while a stream's URLs are re-resolved, which GlobalPlayer shows. */ resolving: boolean; /** - * Initial seek offset for the active item: non-zero only for an item restored - * from a previous session, or resumed after a re-resolve. + * Where MediaPlayer starts the current item, in seconds: not 0 only for an + * item restored from a previous session, or resumed after a re-resolve. */ startTime: number; /** - * Whether the active MediaPlayer should autoplay. A restored item appears - * paused; a fresh play() autoplays on the user gesture. + * Whether the current item starts on mount. A restored session appears + * paused, since no user gesture is behind it; a fresh `play()` autoplays. */ autoplay: boolean; - /** Plays `item` alone. */ + /** Plays `item` alone, replacing the queue. */ play(item: PlayerItem): void; - /** Plays `items` as the queue, from `startIndex`. */ + /** Replaces the queue with `items` and plays it from `startIndex`. */ playQueue(items: PlayerItem[], startIndex?: number): void; - /** Plays the queue's item at `index`. */ + /** Plays the queue's item at `index`, or restarts it when it is current. */ playAt(index: number): void; - /** Plays the next item. */ next(): void; - /** Plays the previous item. */ + /** + * Restarts the item after its first 3 seconds, else plays the previous one. + */ previous(): void; - /** Stops and closes the player. */ + /** Empties the queue, which closes the player. */ stop(): void; - /** Seeks to `time` seconds. */ + /** Seeks a file or stream to `time` seconds. An embed doesn't move. */ seekTo(time: number): void; - /** Pauses or resumes. */ + /** Pauses or resumes a file or stream. An embed has its own controls. */ togglePlay(): void; - /** - * Internal: where GlobalPlayer registers MediaPlayer's seek handle, so seekTo - * can reach into the actual media element. - */ + /** Where the mounted MediaPlayer registers its seek handle, for `seekTo`. */ seekRef: { current: ((t: number) => void) | null }; - /** Internal: MediaPlayer's play/pause handle, for togglePlay. */ + /** Where the mounted MediaPlayer registers its play/pause handle. */ toggleRef: { current: (() => void) | null }; - /** Internal: GlobalPlayer reports whether the media is playing. */ + /** Called by MediaPlayer when its media starts or stops. */ onPlayStateChange(playing: boolean): void; - /** Internal: GlobalPlayer reports the playhead and the duration. */ + /** Called by MediaPlayer as its media plays. */ onTimeUpdate(time: number, duration: number): void; - /** Current item finished — advances the queue, or stops at the end. */ + /** Called by MediaPlayer at its media's end, to play the next item. */ onEnded(): void; - /** The media element failed. For streams, triggers a re-resolve. */ + /** Called by MediaPlayer when its media fails, to re-resolve a stream. */ onError(): void; } -/** What a consumer outside any provider gets. */ +/** + * Given by {@link PlayerContext} outside PlayerProvider: nothing plays and no + * control does anything. The tests' render helper starts from it. + */ export const defaultPlayerValue: PlayerContextValue = { current: null, playing: false, @@ -189,7 +183,7 @@ export const defaultPlayerValue: PlayerContextValue = { onError: () => {}, }; -/** The player context. */ +/** The player's state and controls, from PlayerProvider. */ export const PlayerContext: React.Context<PlayerContextValue> = createContext< PlayerContextValue >( @@ -197,24 +191,25 @@ export const PlayerContext: React.Context<PlayerContextValue> = createContext< ); /** - * The playhead, apart from the rest: it moves several times a second while - * something plays, and only what draws a position should re-render with it — - * not every card that reads `current` or `playing`. + * Provided by PlayerProvider through {@link PlayerTimeContext}, apart from the + * rest because it changes several times a second while something plays. Only + * what draws a position, like the dump page's audio preview, reads it, so the + * cards that read {@link PlayerContext} don't re-render with it. */ export interface PlayerTimeValue { - /** The playhead, in seconds. */ + /** In seconds. */ currentTime: number; - /** The current item's length, in seconds; 0 until known. */ + /** In seconds, 0 until the media's metadata loads. */ duration: number; } -/** What a consumer outside any provider gets. */ +/** Given by {@link PlayerTimeContext} outside PlayerProvider. */ export const defaultPlayerTimeValue: PlayerTimeValue = { currentTime: 0, duration: 0, }; -/** The playhead context. */ +/** The playhead of the current file or stream, from PlayerProvider. */ export const PlayerTimeContext: React.Context<PlayerTimeValue> = createContext< PlayerTimeValue >( diff --git a/src/contexts/player/PlayerProvider.tsx b/src/contexts/player/PlayerProvider.tsx index 9f08ec08..35d72142 100644 --- a/src/contexts/player/PlayerProvider.tsx +++ b/src/contexts/player/PlayerProvider.tsx @@ -1,6 +1,7 @@ /** - * The player's provider: the queue, the playback state and the controls, kept - * across reloads, and the expiring streams kept fresh. + * Holds the player's queue, playback state and controls, saves them across + * reloads and keeps streams' signed URLs fresh. App mounts it around the whole + * app, and GlobalPlayer draws what it holds. * * @module */ @@ -18,24 +19,18 @@ import { } from "../../utils/player/streamSources.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; -/** The localStorage key of the stored session. */ const STORAGE_KEY = "player"; /** - * Snapshot persisted across reloads: the whole queue, which entry was playing - * and how far in. - * - * v1 stored a single `{ item, time }`. `readSession` still accepts that shape - * so a session written by the previous build survives the upgrade. + * Written to localStorage by {@link PlayerProvider} as the queue changes and on + * `pagehide`, and read back by {@link readSession} on load. Version 1 stored a + * single `{ item, time }`, which readSession still accepts. */ interface StoredSession { - /** The format's version. */ v: 2; - /** The queue. */ queue: PlayerItem[]; - /** The position of the entry that was playing. */ index: number; - /** How far in it was, in seconds. */ + /** How far into `queue[index]`, in seconds. */ time: number; /** * Whose queue this is (null for a guest). File URLs carry the listener's @@ -44,7 +39,10 @@ interface StoredSession { owner?: string | null; } -/** Checks whether `value` looks like a {@link PlayerItem}. */ +/** + * Checks whether `value`, read from localStorage, looks like a + * {@link PlayerItem}. Only its `kind` is checked. + */ function isPlayerItem(value: unknown): value is PlayerItem { if (!value || typeof value !== "object") return false; const kind = (value as { kind?: unknown }).kind; @@ -64,9 +62,8 @@ function readSession( const parsed = JSON.parse(raw) as Record<string, unknown>; const time = Number(parsed.time) || 0; - // Another account's queue, or one saved before queues had owners: the - // latter may be anybody's, and only file URLs hold a token worth keeping - // to themselves. + // A session saved before sessions had owners may be anybody's. Only file + // URLs carry a media token, so it is kept only when it holds none. if (parsed.owner === undefined) { const items = Array.isArray(parsed.queue) ? parsed.queue : [parsed.item]; if (items.some((i) => isPlayerItem(i) && i.kind === "file")) return null; @@ -85,7 +82,7 @@ function readSession( return { queue, index, time }; } - // Legacy v1 single-item session. + // A version 1 session, of a single item. if (isPlayerItem(parsed.item)) { return { queue: [parsed.item], index: 0, time }; } @@ -95,28 +92,30 @@ function readSession( } } -/** Don't retry the same failing track more than once per window. */ +/** + * A track that fails again this soon after its last re-resolve is dead, not + * stale, and is skipped. + */ const RERESOLVE_COOLDOWN_MS = 30_000; -/** The props of {@link PlayerProvider}. */ +/** Passed to {@link PlayerProvider} by App, around the whole app. */ export interface PlayerProviderProps { - /** The app, which plays through the player. */ children: React.ReactNode; } /** * Provides {@link PlayerContext} and {@link PlayerTimeContext}. The queue and - * the position are stored as they change and restored on load, paused; signing - * out, or in as someone else, ends the session. A Bandcamp stream whose signed - * URL expires is re-resolved and resumed where it was, falling back to the - * embed if that fails. + * the position are saved as they change and restored on load, paused; signing + * out, or in as someone else, closes the player. A stream whose signed URL + * expires is re-resolved and resumed where it was, and falls back to its embed + * if that fails. */ export function PlayerProvider( { children }: PlayerProviderProps, ): React.JSX.Element { const { user } = useAuth(); const owner = user?.id ?? null; - // Lazy initialiser: readSession runs exactly once, on first render. + // Read once, for the account AuthProvider restored synchronously. const [restored] = useState(() => readSession(owner)); const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []); @@ -124,10 +123,7 @@ export function PlayerProvider( const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(restored?.time ?? 0); const [duration, setDuration] = useState(0); - // Resume offset for the active item — carried by a restored item, and by an - // item whose stream URL was re-resolved mid-listen. const [startTime, setStartTime] = useState(restored?.time ?? 0); - // Restored items start paused (no user gesture); fresh play() autoplays. const [autoplay, setAutoplay] = useState(false); const [resolving, setResolving] = useState(false); @@ -135,22 +131,19 @@ export function PlayerProvider( const hasNext = queueIndex + 1 < queue.length; const hasPrevious = queueIndex > 0; - // GlobalPlayer registers the active MediaPlayer's imperative handles here const seekRef = useRef<((t: number) => void) | null>(null); const toggleRef = useRef<(() => void) | null>(null); - // Suppresses stale timeupdate callbacks that fire between play() and the old - // MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true). + // Set when the player moves to a new item, until the new media starts: the + // old MediaPlayer can still report its playhead before it unmounts. const suppressUpdates = useRef(false); - // Bumped whenever the player is moved somewhere new — a fresh queue, a queue - // step, a stop. An async re-resolve compares it against the value it captured - // to tell whether it still owns the player by the time it lands. + // Bumped whenever the player moves somewhere new (a fresh queue, a step, a + // stop), so that a re-resolve landing later can tell it no longer applies. const playGen = useRef(0); - // Latest state, for callbacks that must not close over a stale render. - // Written in an effect (after render) rather than during it, the same - // convention MediaPlayer uses for its callback refs. + // The latest state, for callbacks that must not close over an old render. + // Written after render, since the linter forbids writing refs during it. const stateRef = useRef({ current, currentTime, playing, queue, queueIndex }); useEffect(() => { stateRef.current = { current, currentTime, playing, queue, queueIndex }; @@ -173,7 +166,7 @@ export function PlayerProvider( playQueue([item], 0); }, [playQueue]); - /** Move within the existing queue, as a fresh (autoplaying) item. */ + /** Moves to `index` in the queue and autoplays it, as a fresh queue would. */ const advanceTo = useCallback((index: number) => { playGen.current++; suppressUpdates.current = true; @@ -197,7 +190,8 @@ export function PlayerProvider( const seekTo = useCallback((t: number) => { seekRef.current?.(t); - setCurrentTime(t); // optimistic — prevents waveform jitter before timeupdate fires + // Shown at once, or the playhead jumps back until the next timeupdate. + setCurrentTime(t); }, []); const togglePlay = useCallback(() => { @@ -206,13 +200,11 @@ export function PlayerProvider( const playAt = useCallback((index: number) => { if (index < 0 || index >= queue.length) return; - // Re-selecting the playing row restarts it. Going through advanceTo would - // leave the index unchanged — so the element would never remount and never - // seek — while still flipping `playing` false against audio that's running. + // advanceTo to the same index wouldn't remount the element, so it wouldn't + // seek, and it would set `playing` false against audio that runs. if (index === queueIndex) { seekTo(0); - // Rewinding alone would make the click look like a no-op on a row that - // has ended, or that the user paused. Start it again. + // On an ended or paused row, rewinding alone would look like nothing. if (!stateRef.current.playing) togglePlay(); return; } @@ -223,7 +215,10 @@ export function PlayerProvider( if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1); }, [queueIndex, queue.length, advanceTo]); - /** Restart the track first, like every other music player, then step back. */ + /** + * Plays the previous track, unless 3 seconds of this one have played: then + * it restarts this one, like other players. + */ const previous = useCallback(() => { if (stateRef.current.currentTime > 3 || queueIndex === 0) { seekTo(0); @@ -251,10 +246,9 @@ export function PlayerProvider( setPlaying(false); }, [queueIndex, queue.length, advanceTo]); - // ── Expiring streams ─────────────────────────────────────────────────────────── - // Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a - // long listen, will 403. Re-resolve the source page, swap in fresh URLs and - // resume at the same offset; if that fails too, fall back to the iframe embed. + // Bandcamp signs its mp3 URLs for about 24 hours, so a session restored the + // next day, or a long listen, gets a 403. The page is re-resolved and the + // track resumed at the same offset; failing that, the embed takes over. const lastReresolve = useRef<{ key: string; at: number } | null>(null); const fallBackToEmbed = useCallback(( @@ -274,8 +268,8 @@ export function PlayerProvider( }, [playQueue, stop]); /** - * A track that stays dead after a re-resolve is one track, not one album: - * step over it and keep the queue. Only the last one falls back to the embed. + * Skips a track that stays dead after a re-resolve, since the rest of the + * album may play. Only the last track falls back to the embed. */ const skipOrFallBack = useCallback(( item: Extract<PlayerItem, { kind: "stream" }>, @@ -298,19 +292,17 @@ export function PlayerProvider( const now = Date.now(); const last = lastReresolve.current; if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) { - // Already tried recently — the track is genuinely dead, not just stale. skipOrFallBack(item); return; } lastReresolve.current = { key, at: now }; - // Resolving is a network round-trip against Bandcamp. The user can pick - // another dump, skip on, or close the player while it's in flight — and a - // result that lands after that must not drag the player back here. + // The user may play something else, skip or close the player meanwhile, + // and a late result must not drag the player back here. const gen = playGen.current; const stillOurs = () => playGen.current === gen; - // Separate from `gen`: the spinner belongs to the newest resolve, whichever - // item that one is for, so a superseded resolve must not clear it. + // Not `gen`: the spinner belongs to the newest resolve, whichever item it + // is for, and an older one must not clear it. const seq = ++resolveSeq.current; setResolving(true); @@ -320,9 +312,9 @@ export function PlayerProvider( const found = items.findIndex((i) => i.kind === "stream" && i.resolveIndex === item.resolveIndex ); - // The track can be gone from the release, or no longer streamable once - // Bandcamp's free-play cap kicks in. Falling back to the first track is - // fine; carrying the old offset onto it would seek past its end. + // The track may be gone, or capped by Bandcamp's free plays. The first + // track then plays from its start, since the old offset may be past its + // end. const index = found < 0 ? 0 : found; const resume = found < 0 ? 0 : resumeAt; suppressUpdates.current = true; @@ -334,8 +326,7 @@ export function PlayerProvider( setAutoplay(shouldAutoplay); setPlaying(false); } catch { - // The resolve itself failed (offline, endpoint down) — that's the whole - // album, not one track, so the embed is the right fallback. + // The whole album failed to resolve, not one track. if (stillOurs()) fallBackToEmbed(item); } finally { if (resolveSeq.current === seq) setResolving(false); @@ -351,29 +342,26 @@ export function PlayerProvider( }); }, [reresolve]); - // A session restored from a previous day holds URLs that are already dead. - // Refresh them up front so the first press of play doesn't visibly stall. + // A session from a previous day holds dead URLs. Refreshing them now keeps + // the first press of play from stalling. useEffect(() => { if (!restored) return; const item = restored.queue[restored.index]; if (item?.kind === "stream" && isStreamStale(item)) { - // Genuinely an external-system sync: the stored URLs are dead and only - // the network can replace them. The setState this triggers is the point. + // Syncing with the network is the point of the setState this triggers. // eslint-disable-next-line react-hooks/set-state-in-effect void reresolve(item, { resumeAt: restored.time, shouldAutoplay: false, }); } - // Runs once, against the session captured at mount. + // Once, for the session read at mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ── Persistence ──────────────────────────────────────────────────────────────── - // Write the queue to storage as soon as it changes (survives crashes), then - // refresh the playback position on pagehide — which also fires on a normal - // reload — so the resume offset is accurate without thrashing localStorage on - // every timeupdate. + // The queue is saved as soon as it changes, which survives a crash, and the + // position on `pagehide` (a reload fires it too), rather than on every + // timeupdate. useEffect(() => { if (queue.length > 0) { localStorage.setItem( @@ -391,12 +379,12 @@ export function PlayerProvider( } else { localStorage.removeItem(STORAGE_KEY); } - // currentTime intentionally omitted from deps — see pagehide writer below. + // Not on currentTime: the pagehide handler saves the position. // eslint-disable-next-line react-hooks/exhaustive-deps }, [queue, queueIndex, owner]); - // Signing out, or in as someone else, ends the listening session: the queue - // was built with the previous account's media token. Signing in keeps a + // Signing out, or in as someone else, closes the player, since the queue's + // file URLs carry the previous account's media token. Signing in keeps a // guest's queue, which holds no token. const [queueOwner, setQueueOwner] = useState(owner); useEffect(() => { diff --git a/src/contexts/social/FollowContext.ts b/src/contexts/social/FollowContext.ts index 9f3b53b2..66f16e1e 100644 --- a/src/contexts/social/FollowContext.ts +++ b/src/contexts/social/FollowContext.ts @@ -1,31 +1,33 @@ /** - * The follow context: whom and what the signed-in user follows, provided by - * FollowProvider. + * Declares the context that holds the users and playlists the signed-in user + * follows, for the Follow buttons. `FollowProvider` provides it and + * `useFollows` reads it. * * @module */ import { createContext } from "react"; -/** What {@link FollowContext} holds. */ +/** + * Provided by `FollowProvider` through {@link FollowContext}. The four + * functions change the sets at once and undo the change when the request + * fails. Their promises never reject, so a caller can't tell a failure. + */ export interface FollowContextValue { - /** The ids of the users they follow. */ followedUserIds: Set<string>; - /** The ids of the playlists they follow. */ followedPlaylistIds: Set<string>; - /** Follows a user. */ followUser: (userId: string) => Promise<void>; - /** Stops following a user. */ unfollowUser: (userId: string) => Promise<void>; - /** Follows a playlist. */ followPlaylist: (playlistId: string) => Promise<void>; - /** Stops following a playlist. */ unfollowPlaylist: (playlistId: string) => Promise<void>; - /** Whether the follow status has been fetched. */ + /** + * False until the follows are fetched, and always for a visitor. The sets + * are empty until then, so a button would show "Follow" for a followed user. + */ isLoaded: boolean; } -/** What a consumer outside any provider gets. */ +/** Given to a consumer outside `FollowProvider`: nothing is followed. */ export const defaultFollowValue: FollowContextValue = { followedUserIds: new Set(), followedPlaylistIds: new Set(), @@ -36,7 +38,7 @@ export const defaultFollowValue: FollowContextValue = { isLoaded: false, }; -/** The follow context. */ +/** Holds the signed-in user's follows. Read it with `useFollows`. */ export const FollowContext: React.Context<FollowContextValue> = createContext< FollowContextValue >( diff --git a/src/contexts/social/FollowProvider.tsx b/src/contexts/social/FollowProvider.tsx index 58409a08..0dca3c61 100644 --- a/src/contexts/social/FollowProvider.tsx +++ b/src/contexts/social/FollowProvider.tsx @@ -1,6 +1,6 @@ /** - * The follows' provider: what the signed-in user follows, changed - * optimistically. + * Provides `FollowContext`, from `GET /api/follows/status`. `App` mounts it + * around every page. * * @module */ @@ -17,16 +17,15 @@ import { API_URL } from "../../config/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import type { FollowStatus } from "../../../shared/follow.ts"; -/** The props of {@link FollowProvider}. */ +/** Passed to {@link FollowProvider} by `App`. */ export interface FollowProviderProps { - /** The app, which reads and changes the follows. */ children: ReactNode; } /** - * Provides {@link FollowContext}: fetches the follow status when someone signs - * in, clears it when they sign out. A follow or unfollow applies at once and is - * rolled back if the request fails. + * Provides {@link FollowContext}. It fetches the follows whenever the token + * changes, and empties them on sign-out. Follows made in another tab don't + * show until the next sign-in or reload: no socket message carries them. */ export function FollowProvider( { children }: FollowProviderProps, @@ -42,9 +41,8 @@ export function FollowProvider( useEffect(() => { if (!token) { - // Deferred to a microtask (rather than called directly) so this - // doesn't fire synchronously within the effect body — same timing - // for the user, just not in the same tick. + // The React hooks lint forbids a synchronous setState in an effect. The + // microtask changes nothing the user can see. queueMicrotask(() => { setFollowedUserIds(new Set()); setFollowedPlaylistIds(new Set()); @@ -66,6 +64,7 @@ export function FollowProvider( setIsLoaded(true); }) .catch((err) => { + // Enables the buttons anyway, though the sets may be empty or stale. if (err.name !== "AbortError") setIsLoaded(true); }); return () => { diff --git a/src/contexts/theme/ThemeContext.ts b/src/contexts/theme/ThemeContext.ts index 5e4e1e5d..d382a585 100644 --- a/src/contexts/theme/ThemeContext.ts +++ b/src/contexts/theme/ThemeContext.ts @@ -1,12 +1,16 @@ /** - * The theme context: the style and the color scheme, provided by ThemeProvider. + * Declares the theme's React context. `ThemeProvider` fills it and `useTheme` + * reads it. * * @module */ import { createContext } from "react"; -/** A site style, one stylesheet each under src/themes/. */ +/** + * A site style. Each has a stylesheet under `src/themes/`, keyed on the root + * element's `data-style`, which is absent for "smooth". + */ export type StyleName = | "smooth" | "brutalist" @@ -14,22 +18,21 @@ export type StyleName = | "nyt" | "kanagawa" | "melange"; -/** Light, dark, or following the system. */ +/** A color scheme. "auto" follows the system's. */ export type ColorScheme = "auto" | "light" | "dark"; -/** What {@link ThemeContext} holds. */ +/** + * Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the + * choice for this browser. + */ export interface ThemeContextValue { - /** The chosen style. */ style: StyleName; - /** The chosen color scheme. */ colorScheme: ColorScheme; - /** Chooses a style. */ setStyle(s: StyleName): void; - /** Chooses a color scheme. */ setColorScheme(c: ColorScheme): void; } -/** The theme context. */ +/** The theme's context. Its default is only seen outside `ThemeProvider`. */ export const ThemeContext: React.Context<ThemeContextValue> = createContext< ThemeContextValue >({ diff --git a/src/contexts/theme/ThemeProvider.tsx b/src/contexts/theme/ThemeProvider.tsx index 7a54df12..771e9bd0 100644 --- a/src/contexts/theme/ThemeProvider.tsx +++ b/src/contexts/theme/ThemeProvider.tsx @@ -1,6 +1,6 @@ /** - * The theme's provider: the chosen style and color scheme, stored and applied - * to the document. + * Holds the chosen style and color scheme, stores them in localStorage and + * applies them to the document. `App` mounts it outermost. * * @module */ @@ -12,24 +12,26 @@ import { ThemeContext, } from "./ThemeContext.ts"; -/** The props of {@link ThemeProvider}. */ +/** Passed to {@link ThemeProvider} by `App`. */ export interface ThemeProviderProps { - /** The app. */ children: React.ReactNode; } /** - * Provides {@link ThemeContext}. The style and color scheme are read from - * localStorage, stored back when they change, and set on the root element as - * `data-style` and `data-color-scheme` (absent for the defaults, `smooth` and - * `auto`), which the stylesheets key on. + * Provides {@link ThemeContext}. It sets the choices on the root element as + * `data-style` and `data-color-scheme`, which the stylesheets key on, and + * leaves them out for the defaults, "smooth" and "auto". + * + * An inline script in index.html sets the same attributes from the same + * localStorage keys before the first paint, so the page doesn't flash the + * default theme. The two must agree. */ export function ThemeProvider( { children }: ThemeProviderProps, ): React.JSX.Element { const [style, setStyleState] = useState<StyleName>(() => { const stored = localStorage.getItem("style"); - // migrate legacy "default" value + // "smooth" was once stored as "default". if (stored === "default" || stored === null) return "smooth"; return stored as StyleName; }); diff --git a/src/contexts/ws/AvatarOverrideContext.ts b/src/contexts/ws/AvatarOverrideContext.ts index d77916f4..d6a42817 100644 --- a/src/contexts/ws/AvatarOverrideContext.ts +++ b/src/contexts/ws/AvatarOverrideContext.ts @@ -1,5 +1,6 @@ /** - * The avatar override context: avatars changed live, provided by WSProvider. + * Declares the context through which `Avatar` learns of avatars changed since + * the page loaded. `WSProvider` fills it from `user_updated` messages. * * @module */ @@ -7,22 +8,22 @@ import { createContext } from "react"; /** - * Live avatar state learned from `user_updated` WS events. Kept in its own - * context (not WSContext) so that `Avatar` — rendered many times across feeds - * and comment threads — only re-renders when an avatar actually changes, not on - * every vote/comment/dump event. + * Set by `WSProvider` for a user whose profile changed, and read by `Avatar`, + * where it wins over the possibly stale props the page fetched. */ export interface AvatarOverride { - /** - * Whether the user currently has an avatar, so a freshly added or removed - * avatar flips even when the surrounding list data is stale. - */ hasAvatar: boolean; - /** Cache-busts the avatar URL (derived from the user's `updatedAt`). */ + /** + * The user's `updatedAt` in ms, or the arrival time without one. It busts the + * cached image. + */ version: number; } -/** The avatar override context, keyed by user id. */ +/** + * Holds an {@link AvatarOverride} by user id. It is apart from `WSContext`, so + * the many avatars of a page don't re-render on every vote or comment. + */ export const AvatarOverrideContext: React.Context< Record<string, AvatarOverride> > = createContext< diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts index 09abde1f..bf244ec8 100644 --- a/src/contexts/ws/WSContext.ts +++ b/src/contexts/ws/WSContext.ts @@ -1,8 +1,7 @@ /** - * The socket contexts, split three ways by how often each part changes, so a - * consumer re-renders only with what it reads: typing and presence update every - * few seconds while people are around, chat with every message, and a feed has - * no use for either. WSProvider provides all three. + * Declares the three contexts `WSProvider` fills from the WebSocket, and the + * types they carry. They are split by how often each part changes, so that a + * feed doesn't re-render with every chat message or presence change. * * @module */ @@ -16,225 +15,266 @@ import type { OnlineUser } from "../../../shared/ws.ts"; import type { Playlist } from "../../../shared/playlist.ts"; import type { PublicUser } from "../../../shared/user.ts"; -/** A vote someone cast or withdrew. */ +/** + * Set as `lastVoteEvent` by `WSProvider` on a `votes_update`. The profile + * pages read it to add or drop a dump from a user's upvoted list. + */ export interface VoteEvent { - /** The dump's id. */ dumpId: string; - /** Who voted. */ voterId: string; - /** Whether the vote was cast or withdrawn. */ action: "cast" | "remove"; } -/** A like someone cast or withdrew. */ +/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */ export interface LikeEvent { - /** The comment's id. */ commentId: string; - /** Who liked it. */ likerId: string; - /** Whether the like was cast or withdrawn. */ action: "cast" | "remove"; } -/** A playlist created, changed, deleted, or whose dumps changed. */ +/** + * Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message. + * The pages that show playlists apply it, some through `usePlaylistListSync`. + * Which fields are set depends on `type`. + */ export interface PlaylistEvent { - /** What happened. */ type: "created" | "updated" | "deleted" | "dumps_updated"; - /** The playlist's id. */ playlistId: string; - /** The playlist as it is now, when created or updated. */ + /** Set when created or updated. */ playlist?: Playlist; - /** The owner's id, when deleted. */ + /** The owner's id, set when deleted. */ userId?: string; - /** The dumps the user can see in it, in order, when its dumps changed. */ + /** + * Set when the dumps changed. It lists only the dumps this user may see, in + * order. + */ dumpIds?: string[]; - /** How many dumps it holds, when its dumps changed. */ + /** Set when the dumps changed. It counts private dumps too. */ dumpCount?: number; } -/** A comment posted, edited or deleted. */ +/** + * Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The + * `Dump` page applies it to its thread, and `useDumpListSync` to its comment + * counts. + */ export interface CommentEvent { - /** What happened. */ type: "created" | "deleted" | "updated"; - /** The id of the dump it is under. */ dumpId: string; - /** The comment as it is now, when posted or edited. */ + /** Set when created or updated. */ comment?: Comment; - /** The comment's id, when deleted. */ + /** Set when deleted. */ commentId?: string; } -/** A user whose public profile changed. */ +/** + * Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the + * server sends on any change to a profile. `UserPublicProfile` applies it. + */ export interface UserEvent { - /** The user as they are now. */ user: PublicUser; } -/** The replied-to message, as shown in the reply bar and on a pending send. */ +/** + * Chosen in `ChatModal` for the reply bar, and kept on the pending message so + * it shows the reply before the server's echo. Only `id` goes to the server. + */ export interface ChatReplyTarget { - /** The message's id. */ id: string; - /** Its author's name. */ author: string; - /** Its text. */ body: string; } /** - * A message this client sent that the server hasn't echoed back yet — shown in - * the list as "sending", or "failed" with a way to retry or discard it. + * Held by `WSProvider` for a chat message this client sent and the server + * hasn't echoed back yet. `ChatModal` shows it, as `PendingItem`, after the + * messages the server has. */ export interface PendingChatMessage { - /** The id the echo will carry back. */ + /** + * Sent with `chat_send` and returned with the echo or the error. A retry + * gets a new one, so a late echo of the first attempt can't retire it. + */ nonce: string; - /** The message. */ body: string; - /** The message it replies to. */ replyTo?: ChatReplyTarget; - /** When it was sent. */ + /** When this client sent it, to find it in the history after a reconnect. */ createdAt: Date; - /** Whether it is on its way or failed. */ + /** + * `failed` when the server refused it, the socket was down or closed, or no + * echo came in 10 s. A failed message may still have reached the server. + */ status: "sending" | "failed"; - /** Why it failed. */ error?: string; } -/** A failed chat edit or delete, surfaced in the chat until dismissed. */ +/** + * Set as `chatError` by `WSProvider` when the server refuses a chat edit or + * delete. `ChatModal` shows it for a few seconds. + */ export interface ChatError { - /** Distinguishes one error from the next. */ + /** Tells apart two errors with the same message. */ id: number; - /** What went wrong. */ message: string; } /** - * What {@link WSContext} holds: the connection, votes, likes, and the live - * dump, playlist, comment, user and notification events. + * Provided by `WSProvider` through {@link WSContext}, read with `useWS`. The + * `last*Event` fields hold only the latest event of their kind: a consumer + * reacts in an effect when one changes, and an event that another replaces + * within the same render is missed. After a reconnect, `connectionEpoch` tells + * pages to fetch what they missed. */ export interface WSContextValue { - /** The connection's state. */ wsStatus: "connecting" | "connected" | "disconnected"; - /** Why the socket is down, while it is. */ + /** Set while the socket is down, for the banner that says so. */ wsErrorMessage: string | null; - /** Live vote counts, by dump id. */ + /** + * Counts known to the socket, by dump id: pushed by the server, or changed + * optimistically. A missing dump shows its fetched `voteCount`. + */ voteCounts: Record<string, number>; - /** The ids of the dumps the user upvoted. */ + /** Reset from the server's `welcome` on every connection. */ myVotes: Set<string>; - /** Live like counts, by comment id. */ + /** As `voteCounts`, for comments. */ likeCounts: Record<string, number>; - /** The ids of the comments the user likes. */ + /** As `myVotes`, for comments. */ myLikes: Set<string>; - /** Dumps posted (or made public) this session, newest first (capped). */ + /** + * Dumps that arrived this session, newest first and at most 100: the ones + * the server announced, which are public, and the ones this user posted, + * which may be private. `Index` puts them above the feed it fetched. + */ recentDumps: Dump[]; - /** Ids of dumps deleted this session. */ + /** + * Dumps deleted or made private this session, for the lists to filter out. + * A dump made public again leaves it. + */ deletedDumpIds: Set<string>; - /** The latest vote. */ lastVoteEvent: VoteEvent | null; - /** The latest like. */ + /** Read by no consumer at the moment. */ lastLikeEvent: LikeEvent | null; - /** The latest edited dump. */ + /** The dump as it is after its latest edit. */ lastDumpEvent: Dump | null; - /** The latest playlist change. */ lastPlaylistEvent: PlaylistEvent | null; - /** Ids of playlists deleted this session. */ deletedPlaylistIds: Set<string>; - /** The latest comment change. */ lastCommentEvent: CommentEvent | null; - /** The latest profile change. */ lastUserEvent: UserEvent | null; - /** How many notifications the user hasn't read. */ + /** Read from the `welcome`, then counted up by each new notification. */ unreadNotificationCount: number; - /** The latest notification received. */ + /** Read by the bell, which rings, and by the notifications page. */ lastNotification: Notification | null; - /** Increments on each WS reconnect so pages can backfill missed events. */ + /** + * Incremented on each reconnect, never on the first connection, so pages + * fetch again what they missed while the socket was down. + */ connectionEpoch: number; /** - * Upvotes a dump, optimistically. `shownCount`: the count on screen, used - * until a live one arrives. + * Upvotes a dump at once and sends `vote_cast`. The server's answer confirms + * or reverts it, and so does a timeout of 5 s, which covers a closed socket. + * `shownCount` is the count on screen, used when `voteCounts` has none yet. + * Does nothing when the user already voted. */ castVote: (dumpId: string, shownCount?: number) => void; - /** Withdraws an upvote, optimistically; `shownCount` as for `castVote`. */ + /** Withdraws an upvote, as {@link WSContextValue.castVote} casts one. */ removeVote: (dumpId: string, shownCount?: number) => void; - /** Likes a comment, optimistically; `shownCount` as for `castVote`. */ + /** Likes a comment, as {@link WSContextValue.castVote} upvotes a dump. */ castCommentLike: (commentId: string, shownCount?: number) => void; - /** Withdraws a like, optimistically; `shownCount` as for `castVote`. */ + /** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */ removeCommentLike: (commentId: string, shownCount?: number) => void; /** - * Adds a dump this client just posted to `recentDumps`, without waiting for - * its broadcast. + * Adds a dump this user just posted to `recentDumps`, without waiting for + * its broadcast, which a private dump never gets. `DumpCreateModal` calls it. */ injectDump: (dump: Dump) => void; - /** Sets the unread notification count to zero. */ + /** + * Zeroes the bell's count, once the notifications page has marked them read + * on the server. + */ clearUnreadNotifications: () => void; } -/** The chat room: its live stream, unread state and what to send into it. */ +/** + * Provided by `WSProvider` through {@link WSChatContext}, read with + * `useWSChat`. The socket sends chat messages only to signed-in users, so for + * a visitor it stays empty. + */ export interface WSChatContextValue { - /** Live buffer of chat messages received this session (capped). */ + /** + * The messages received this session, at most 200, for `ChatModal` to merge + * with the history it fetches. A reconnect empties it while the chat is + * closed, since `ChatModal` fetches everything again on open. + */ chatMessages: ChatMessage[]; /** - * Ids of chat messages deleted live this session, so fetched history can - * filter them out. + * Messages deleted this session, which `ChatModal` filters out of its + * fetched history. */ deletedChatIds: Set<string>; /** - * Messages from others since the read marker — kept by the server, so it - * survives reloads and agrees across tabs and devices. + * Messages from others after the read marker. The server keeps the marker, + * so the count agrees across tabs and devices. */ unreadChatCount: number; /** - * The server's read marker: created_at of the newest message the user has - * seen. Null until the socket's welcome. + * The time of the newest message the user has seen, which the server keeps. + * Null until the `welcome`, and for a visitor. */ chatReadAt: Date | null; - /** Messages sent and not yet echoed back. */ pendingChatMessages: PendingChatMessage[]; - /** The latest failed edit or delete, until dismissed. */ + /** Only for edits and deletes. A failed send shows on its pending message. */ chatError: ChatError | null; - /** - * Users currently composing a chat message (excludes nobody — the chatbox - * filters out the current user itself). Shaped like presence for avatars. - */ + /** Includes the user when they are typing: `ChatModal` filters them out. */ typingUsers: OnlineUser[]; - /** Sends a message, replying to `replyTo` if given. */ + /** Trims `body` and sends it as a pending message. Ignores a blank body. */ sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; - /** Resends the failed message `nonce`. */ retryChatMessage: (nonce: string) => void; - /** Drops the failed message `nonce`. */ discardChatMessage: (nonce: string) => void; - /** Edits a message. */ + /** + * Sends the edit if the socket is open, else drops it. It shows once the + * server broadcasts it, or fails as `chatError`. + */ editChatMessage: (id: string, body: string) => void; - /** Deletes a message. */ + /** Sends the deletion if the socket is open, as `editChatMessage` does. */ deleteChatMessage: (id: string) => void; - /** Clears `chatError`. */ dismissChatError: () => void; - /** Moves the read marker up to the message created at `at`. */ + /** + * Moves the read marker up to `at`, the time of the newest message seen. It + * zeroes `unreadChatCount` at once and sends the marker at most once a + * second. A marker set offline goes out with the next `welcome`. + */ markChatRead: (at: Date) => void; /** - * Folds an authoritative fetch of the stream between `from` and `to` into the - * live buffer after a reconnect: buffered messages in that range which the - * server no longer has were deleted while the socket was down. + * Replaces the live buffer's messages between `from` and `to` with + * `messages`, which `ChatModal` fetched after a reconnect. A message missing + * from them was deleted while the socket was down. A failed send found among + * them did reach the server, and leaves the pending list. */ reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void; /** - * Informs the server whether the user is currently composing a message, so it - * can show/hide the "is typing" indicator to other clients. + * Tells the server whether the user is typing, when the socket is open. + * `ChatModal` resends `true` every few seconds while typing continues, since + * the server forgets a typer after 6 s. */ setChatTyping: (typing: boolean) => void; /** - * Informs the server whether the chatbox is open (suppresses redundant - * mention notifications while the user is reading chat). + * Tells the server whether the user is reading the chat: open, in a visible + * tab. The server sends no notification for a mention the user reads live, + * and this client doesn't count the message unread. */ setChatOpen: (open: boolean) => void; } -/** Who's connected. */ +/** + * Provided by `WSProvider` through {@link WSPresenceContext}, read with + * `useWSPresence`. + */ export interface WSPresenceContextValue { - /** The signed-in users with a socket open. */ + /** The signed-in users with a socket open, each once. */ onlineUsers: OnlineUser[]; } -/** What a consumer outside any provider gets. */ +/** Given to a consumer outside `WSProvider`, as if it were still connecting. */ export const defaultWSValue: WSContextValue = { wsStatus: "connecting", wsErrorMessage: null, @@ -262,7 +302,7 @@ export const defaultWSValue: WSContextValue = { clearUnreadNotifications: () => {}, }; -/** What a consumer outside any provider gets. */ +/** Given to a consumer outside `WSProvider`: an empty chat. */ export const defaultWSChatValue: WSChatContextValue = { chatMessages: [], deletedChatIds: new Set(), @@ -283,22 +323,25 @@ export const defaultWSChatValue: WSChatContextValue = { setChatOpen: () => {}, }; -/** What a consumer outside any provider gets. */ +/** Given to a consumer outside `WSProvider`: nobody is online. */ export const defaultWSPresenceValue: WSPresenceContextValue = { onlineUsers: [], }; -/** The socket context: connection, votes, likes and live events. */ +/** + * Holds the connection, the votes and likes, and the latest live event of + * each kind. Read it with `useWS`. + */ export const WSContext: React.Context<WSContextValue> = createContext< WSContextValue >(defaultWSValue); -/** The chat context. */ +/** Holds the chat's live stream and read state. Read it with `useWSChat`. */ export const WSChatContext: React.Context<WSChatContextValue> = createContext< WSChatContextValue >( defaultWSChatValue, ); -/** The presence context. */ +/** Holds who is online. Read it with `useWSPresence`. */ export const WSPresenceContext: React.Context<WSPresenceContextValue> = createContext< WSPresenceContextValue diff --git a/src/contexts/ws/WSProvider.tsx b/src/contexts/ws/WSProvider.tsx index 858b8b19..5819d80a 100644 --- a/src/contexts/ws/WSProvider.tsx +++ b/src/contexts/ws/WSProvider.tsx @@ -1,7 +1,8 @@ /** - * The socket's provider: one connection per session, and the live state it - * feeds — presence, votes, likes, dumps, playlists, comments, users, - * notifications and the chat. + * Holds the app's one WebSocket to `/ws` and turns its messages into the state + * of `WSContext`, `WSChatContext`, `WSPresenceContext` and + * `AvatarOverrideContext`. `App` mounts it around every page. The protocol is + * `shared/ws.ts`, and the server's side is `api/routes/ws.ts`. * * @module */ @@ -59,69 +60,55 @@ import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts"; import { playChime } from "../../utils/shared/sound.ts"; import { mentionsUser } from "../../utils/chat/text.ts"; -/** The props of {@link WSProvider}. */ +/** Passed to {@link WSProvider} by `App`. */ export interface WSProviderProps { - /** The app, which reads the live state. */ children: ReactNode; } /** The longest wait between reconnection attempts, in ms. */ const MAX_BACKOFF = 30_000; -/** - * How long an optimistic vote or like waits for its acknowledgement before it - * is rolled back, in ms. - */ +/** How long an optimistic vote or like waits for the server, in ms. */ const ACK_TIMEOUT = 5_000; -/** - * How long a connection attempt may take before the socket is shown as down, in - * ms. - */ +/** How long a connection attempt may take before the banner shows, in ms. */ const CONNECT_TIMEOUT = 2_500; /** - * Cap on the live "new dumps" buffer, so a long-lived session can't grow it - * without bound. Feeds merge these on top of their own paginated list, so a - * generous cap is plenty — older live arrivals are reachable via normal - * pagination. + * The most dumps `recentDumps` holds. Older arrivals are in the feeds' later + * pages. */ const MAX_RECENT_DUMPS = 100; /** - * Cap on the live chat buffer, so a long-lived session can't grow it without - * bound. Older messages remain reachable via the /api/chat history endpoint. + * The most messages `chatMessages` holds. Older ones are in the history, + * which `ChatModal` fetches. */ const MAX_CHAT_MESSAGES = 200; -/** - * How long a sent chat message may go without its echo before it's shown as - * failed, in ms. - */ +/** How long a sent chat message waits for its echo, in ms. */ const CHAT_SEND_TIMEOUT = 10_000; -/** How often the read marker may go out, in ms. */ +/** + * The least time between two read markers, in ms. The server drops markers + * past a rate of one a second. + */ const CHAT_READ_THROTTLE = 1_000; /** - * A failed send whose text turns up in the resynced history within this long of - * being sent did reach the server — only its echo was lost. + * How close in time a message of the history must be to a failed send with + * the same text to count as that send, in ms. */ const CHAT_ECHO_WINDOW = 60_000; -/** An optimistic vote awaiting its acknowledgement. */ +/** An optimistic vote waiting for the server. */ interface PendingVote { - /** The timer that rolls it back. */ timeout: ReturnType<typeof setTimeout>; - /** Undoes it. */ rollback: () => void; } -/** An optimistic like awaiting its acknowledgement. */ +/** An optimistic like waiting for the server. */ interface PendingLike { - /** The timer that rolls it back. */ timeout: ReturnType<typeof setTimeout>; - /** Undoes it. */ rollback: () => void; } /** - * Returns `data` as a server message, or null when it isn't one. The runtime - * check is minimal: the `type` field must be a string, so the cast to the - * discriminated union is safe and TypeScript narrows from there. + * Returns `data` as a server message, or null when it isn't JSON with a string + * `type`. The fields aren't checked: the server is trusted. */ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null { try { @@ -137,12 +124,11 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null { /** * Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext} - * and {@link AvatarOverrideContext}. It connects with the session's token and - * reconnects with a growing backoff; each reconnect bumps `connectionEpoch` so - * pages backfill what they missed. Votes, likes and chat messages apply at once - * and are confirmed or rolled back by the server's answer. A new notification - * or chat message chimes, and the unread count goes in the tab title. A - * `force_logout` signs the user out; a change of session clears the live state. + * and {@link AvatarOverrideContext}. It connects with the session's token, or + * anonymously, and reconnects with a backoff doubling up to 30 s. A new token + * (sign-in, sign-out, another account) clears the live state and reconnects. + * A new notification or chat message chimes, and the unread counts go in the + * tab title. A `force_logout` signs the user out. */ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const { token, user, logout } = useAuth(); @@ -152,9 +138,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { >("connecting"); const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null); - // Tracks the token to detect login/logout below. The actual reset runs after - // all state declarations (see the `prevToken !== token` block) because it - // touches setters that aren't in scope yet here. + // For the reset below, which must follow every state declaration. const [prevToken, setPrevToken] = useState(token); const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]); @@ -190,20 +174,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { >([]); const [chatError, setChatError] = useState<ChatError | null>(null); const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]); - // Live avatar overrides for site-wide Avatar refresh (own narrow context). const [avatarOverrides, setAvatarOverrides] = useState< Record<string, AvatarOverride> >({}); - // Bumped each time the socket reconnects (a `welcome` after the first), so - // pages can backfill events they missed while disconnected. Monotonic — not - // reset on token change. + // Never reset: pages compare it with the value they last saw. const [connectionEpoch, setConnectionEpoch] = useState(0); - // Reset during render when the token changes (login/logout/account switch), - // rather than inside the effect (which would cause a cascading re-render). - // The socket reconnects and `welcome` re-syncs presence/votes/likes/unread, - // but the accumulated realtime state below would otherwise bleed from the - // previous session into the next (e.g. another account's recent dumps). + // A new token means another session: the `welcome` resyncs votes, likes and + // unread counts, but what the last session gathered (another account's + // private dumps, its pending messages) must go. Reset during render, since + // an effect would render the stale state once more. if (prevToken !== token) { setPrevToken(token); setWSStatus("connecting"); @@ -234,15 +214,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { setAvatarOverrides({}); } - // Refs to avoid stale closures in event handlers + // The socket's handlers and the stable callbacks read the current values + // through these refs. Depending on the values instead would reconnect the + // socket, or change the callbacks, on every vote. const voteCountsRef = useRef(voteCounts); const myVotesRef = useRef(myVotes); const likeCountsRef = useRef(likeCounts); const myLikesRef = useRef(myLikes); const userIdRef = useRef(userId); const usernameRef = useRef(user?.username ?? null); - // Stable ref for logout so the effect doesn't reconnect when the function - // reference changes on re-renders. const onForceLogoutRef = useRef(logout); useLayoutEffect(() => { voteCountsRef.current = voteCounts; @@ -255,23 +235,23 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { }); const socketRef = useRef<WebSocket | null>(null); - // Tracks whether the chatbox is open so it can be re-announced to the server - // after a reconnect (the fresh socket starts with the flag cleared). + // A new socket starts with the chat closed on the server's side, so the + // `welcome` handler announces it again. const chatOpenRef = useRef(false); - // The user is only actually reading the chat when the chatbox is open *and* - // the tab is on screen: a chatbox left open in a background tab must not + // A chat left open in a background tab isn't being read: it must not // swallow mention notifications or unread counts. const isViewingChat = useCallback( () => chatOpenRef.current && !document.hidden, [], ); - // Echo timeouts of in-flight chat sends, by nonce. + // By nonce. A send is in flight while it has a timer here. const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>( new Map(), ); const chatErrorSeqRef = useRef(0); - // Read marker bookkeeping: the newest marker sent (or learned from the - // server), and a trailing timer so a burst of arrivals sends one marker. + // The read marker, in ms. `Sent` is the newest the server has, from this tab + // or another. `Queued` is the newest to send, which a trailing timer sends + // once for a burst of messages. const chatReadSentRef = useRef(0); const chatReadQueuedRef = useRef(0); const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); @@ -293,11 +273,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce)); }, []); - // Tracks pending optimistic votes: dumpId → pending rollback handler + // By dump id, and by comment id for likes. One per item: a second click + // before the answer replaces the first's timer, so only the latest change + // can be rolled back. const pendingRef = useRef<Map<string, PendingVote>>( new Map(), ); - // Tracks pending optimistic comment likes: commentId → pending rollback handler const pendingLikesRef = useRef<Map<string, PendingLike>>( new Map(), ); @@ -360,8 +341,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let connectTimeout: ReturnType<typeof setTimeout> | null = null; let everConnected = false; - // Counts `welcome` messages for this token; the first is the initial - // connect, any later one is a reconnect worth a backfill epoch bump. + // For this token. Every `welcome` after the first is a reconnect. let welcomeCount = 0; function connect() { @@ -373,6 +353,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const ws = new WebSocket(url); socketRef.current = ws; + // A connection that hangs may take far longer than this to fail. Closing + // it shows the banner and goes through `onclose` to the next attempt. connectTimeout = setTimeout(() => { if (ws.readyState !== WebSocket.CONNECTING) return; setWSStatus("disconnected"); @@ -398,13 +380,14 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { switch (msg.type) { case "ping": + // The server closes a socket that misses a pong. ws.send( JSON.stringify({ type: "pong" } satisfies ClientToServerMessage), ); break; case "welcome": - backoff = 500; // reset backoff on successful connect + backoff = 500; setOnlineUsers(msg.users); setMyVotes(new Set(msg.myVotes)); setMyLikes(new Set(msg.myCommentLikes)); @@ -418,24 +401,21 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { readAt.getTime(), ); } - // welcome provides authoritative server state — cancel any - // in-flight revert timers, they are now superseded. + // The server's sets replace whatever was pending. The optimistic + // counts stay, though, until a live count replaces them. clearAllPendingVotes(); clearAllPendingLikes(); - // Reconnect (not the first welcome): signal pages to backfill the - // dumps/comments they couldn't receive while disconnected. welcomeCount += 1; if (welcomeCount > 1) { setConnectionEpoch((n) => n + 1); - // With the chatbox closed nothing will resync the live buffer, - // and whatever was edited or deleted meanwhile would win over - // the fresh history it fetches on open — so just drop it. + // With the chat closed, nothing resyncs the live buffer, and its + // stale copies would win over the history fetched on open. if (!chatOpenRef.current) { setChatMessages([]); setDeletedChatIds(new Set()); } } - // A marker that was waiting on the socket goes out now. + // A marker set while offline goes out now. if (chatReadQueuedRef.current > chatReadSentRef.current) { ws.send( JSON.stringify( @@ -447,8 +427,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ); chatReadSentRef.current = chatReadQueuedRef.current; } - // Restore the chat-open flag on the fresh connection so mention - // suppression keeps working across reconnects. + // The server knows the chat is open per socket, and this one is + // new. if (isViewingChat()) { ws.send( JSON.stringify( @@ -469,8 +449,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const { dumpId, voteCount, voterId, action } = msg; setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); setLastVoteEvent({ dumpId, voterId, action }); - // Keep myVotes in sync across tabs: if this vote event belongs to - // the current user (from another tab), update myVotes accordingly. + // The user's own vote, from this tab or another: it settles the + // pending one and keeps every tab's button in step. if (voterId === userIdRef.current) { clearPendingVote(dumpId); setMyVotes((prev) => { @@ -487,8 +467,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const { commentId, likeCount, likerId, action } = msg; setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount })); setLastLikeEvent({ commentId, likerId, action }); - // Keep myLikes in sync across tabs: if this like event belongs to - // the current user (from another tab), update myLikes accordingly. + // As for votes. if (likerId === userIdRef.current) { clearPendingLike(commentId); setMyLikes((prev) => { @@ -502,8 +481,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { } case "dump_created": { - // Also sent when a dump goes from private to public: to everyone - // who couldn't see it, that's an arrival. + // Also sent when a dump goes public, which to everyone else is an + // arrival, and may undo its `dump_deleted` from going private. const dump = deserializeDump(msg.dump); setDeletedDumpIds((prev) => { if (!prev.has(dump.id)) return prev; @@ -523,9 +502,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { case "dump_updated": { const dump = deserializeDump(msg.dump); setLastDumpEvent(dump); - // An edit is not an arrival: refresh a live entry in place, but - // don't pull an old dump to the top of the feeds (going public - // comes as a `dump_created`). + // An edit is not an arrival: it must not pull an old dump to the + // top of the feeds. setRecentDumps((prev) => prev.some((d) => d.id === dump.id) ? prev.map((d) => d.id === dump.id ? dump : d) @@ -535,6 +513,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { } case "dump_deleted": { + // Also sent when a dump goes private. const { dumpId } = msg; setDeletedDumpIds((prev) => new Set([...prev, dumpId])); setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId)); @@ -544,9 +523,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { case "vote_ack": { const { dumpId, action, voteCount } = msg; clearPendingVote(dumpId); - // Reconcile with authoritative count setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); - // Confirm vote state setMyVotes((prev) => { const next = new Set(prev); if (action === "cast") next.add(dumpId); @@ -559,9 +536,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { case "comment_like_ack": { const { commentId, action, likeCount } = msg; clearPendingLike(commentId); - // Reconcile with authoritative count setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount })); - // Confirm like state setMyLikes((prev) => { const next = new Set(prev); if (action === "cast") next.add(commentId); @@ -603,8 +578,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { case "user_updated": { const user = deserializePublicUser(msg.user); setLastUserEvent({ user }); - // Refresh this user's avatar everywhere it's rendered. updatedAt - // (bumped on any profile/avatar write) doubles as the version. setAvatarOverrides((prev) => ({ ...prev, [user.id]: { @@ -655,16 +628,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { if (prev.some((m) => m.id === message.id)) return prev; return [...prev, message].slice(-MAX_CHAT_MESSAGES); }); + // The sender's own echo: its pending copy is now a message. if (msg.nonce) retirePendingChat(msg.nonce); - // Count as unread unless it's the current user's own message, or - // they're looking at it (the chatbox then moves the read marker). + // A message read live isn't counted: the chat moves the marker. if (message.userId !== userIdRef.current) { if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1); const username = usernameRef.current; if (username && mentionsUser(message.body, username)) { - // Someone reading the chat gets no notification for a mention - // (the server skips it), so the chime has to come from here; - // otherwise the notification that follows brings its own. + // The server sends no notification for a mention read live, so + // the chime comes from here. Otherwise the notification that + // follows chimes. if (isViewingChat()) playChime("notify", message.id); } else if (!isViewingChat()) { playChime("chat", message.id); @@ -675,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { case "chat_message_updated": { const message = deserializeChatMessage(msg.message); + // An edit of a message older than the buffer is added to it, so + // it wins over the history's copy. `ChatModal` sorts by time. setChatMessages((prev) => { const idx = prev.findIndex((m) => m.id === message.id); if (idx === -1) return [...prev, message]; @@ -697,6 +672,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { break; case "chat_read_update": { + // Sent to every socket of the user, whichever tab moved it. const readAt = new Date(msg.readAt); chatReadSentRef.current = Math.max( chatReadSentRef.current, @@ -718,9 +694,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { else setChatError({ id: ++chatErrorSeqRef.current, message }); break; } - // When the server identifies the failed optimistic action, revert - // its pending vote/like right away instead of waiting out the ACK - // timeout. Without context, the per-action timer handles cleanup. + // A failed vote or like names its dump or comment, so it is + // rolled back at once. Other errors leave it to the timer. if (msg.dumpId) { const pending = pendingRef.current.get(msg.dumpId); if (pending) { @@ -747,9 +722,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { connectTimeout = null; } if (closed) return; - // Sends still waiting on their echo won't get one on this socket. - // They may have landed, though: the resync after reconnect retires - // any whose text shows up in the history. + // Sends still waiting won't get their echo on this socket. They may + // have landed, though: `reconcileChat` retires those it finds. for (const nonce of chatSendTimersRef.current.keys()) { failPendingChat( nonce, @@ -769,7 +743,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { }; ws.onerror = () => { - // onclose will fire after onerror + // `onclose` follows, and reconnects. }; } @@ -804,11 +778,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ]); const castVote = useCallback((dumpId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. + // Until the socket brings a count, the one on screen is the only one. const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0; const prevVoted = myVotesRef.current.has(dumpId); - if (prevVoted) return; // already voted + if (prevVoted) return; setMyVotes((prev) => { const n = new Set(prev); @@ -817,7 +790,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { }); setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 })); - // Schedule revert if no authoritative confirmation arrives. schedulePendingVote(dumpId, () => { setMyVotes((prev) => { const n = new Set(prev); @@ -834,15 +806,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ), ); } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT + // Nothing is sent while the socket is down, so the timer rolls it back. }, [schedulePendingVote]); const removeVote = useCallback((dumpId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0; const prevVoted = myVotesRef.current.has(dumpId); - if (!prevVoted) return; // not voted + if (!prevVoted) return; setMyVotes((prev) => { const n = new Set(prev); @@ -854,7 +824,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { [dumpId]: Math.max(0, prevCount - 1), })); - // Schedule revert if no authoritative confirmation arrives. schedulePendingVote(dumpId, () => { setMyVotes((prev) => { const n = new Set(prev); @@ -871,16 +840,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ), ); } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingVote]); const castCommentLike = useCallback( (commentId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; const prevLiked = myLikesRef.current.has(commentId); - if (prevLiked) return; // already liked + if (prevLiked) return; setMyLikes((prev) => { const n = new Set(prev); @@ -889,7 +855,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { }); setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 })); - // Schedule revert if no authoritative confirmation arrives. schedulePendingLike(commentId, () => { setMyLikes((prev) => { const n = new Set(prev); @@ -909,18 +874,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ), ); } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingLike], ); const removeCommentLike = useCallback( (commentId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; const prevLiked = myLikesRef.current.has(commentId); - if (!prevLiked) return; // not liked + if (!prevLiked) return; setMyLikes((prev) => { const n = new Set(prev); @@ -932,7 +894,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { [commentId]: Math.max(0, prevCount - 1), })); - // Schedule revert if no authoritative confirmation arrives. schedulePendingLike(commentId, () => { setMyLikes((prev) => { const n = new Set(prev); @@ -952,7 +913,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ), ); } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingLike], ); @@ -968,9 +928,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { setUnreadNotificationCount(0); }, []); - // Sends (or resends) a pending message: it shows as "sending" until its echo - // arrives, or as failed if the socket is down, the server rejects it, or no - // echo comes back in time. + // The pending message leaves the list with its echo (`chat_message`), or + // fails on a refusal (`error`), on a closed socket or after the timeout. const transmitPendingChat = useCallback((pending: PendingChatMessage) => { const socket = socketRef.current; if (socket?.readyState !== WebSocket.OPEN) { @@ -1025,8 +984,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const retryChatMessage = useCallback((nonce: string) => { const pending = pendingChatRef.current.find((p) => p.nonce === nonce); if (!pending) return; - // A fresh nonce: should the first attempt's echo still turn up, it mustn't - // retire the retry. + // A late echo of the first attempt must not retire the retry. const retry: PendingChatMessage = { ...pending, nonce: crypto.randomUUID(), @@ -1076,7 +1034,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const at = chatReadQueuedRef.current; if (at <= chatReadSentRef.current) return; const socket = socketRef.current; - // Offline: stays queued, and the next welcome sends it. + // Offline, it stays queued, and the next `welcome` sends it. if (socket?.readyState !== WebSocket.OPEN) return; socket.send( JSON.stringify( @@ -1096,7 +1054,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { return; } chatReadQueuedRef.current = time; - // The badge goes at once; the server's count follows with the marker. + // The badge goes at once. The server's count follows the marker. setUnreadChatCount(0); if (chatReadTimerRef.current) return; const wait = Math.max( @@ -1113,9 +1071,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { const reconcileChat = useCallback( (messages: ChatMessage[], from: Date, to: Date) => { - // In that stretch the fetched copy is the truth: a live copy of a - // message edited while the socket was down is stale, and the merge in - // the chatbox would let it win. + // A live copy of a message edited while the socket was down is stale, + // and `ChatModal`'s merge would let it win over the fetched one. const byId = new Map(messages.map((m) => [m.id, m])); setChatMessages((prev) => prev @@ -1124,7 +1081,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { ) .map((m) => byId.get(m.id) ?? m) ); - // Failed sends that did land (only the echo was lost) are retired. + // A failed send that did land lost only its echo. Its text and time + // are all there is to recognize it by. const mine = messages.filter((m) => m.userId === userIdRef.current); for (const pending of pendingChatRef.current) { if (pending.status !== "failed") continue; @@ -1149,8 +1107,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { } }, []); - // Tell the server whether the user is reading the chat right now (chatbox - // open and tab visible), so it knows when a mention notification is moot. const sendChatFocus = useCallback(() => { if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send( @@ -1169,7 +1125,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { sendChatFocus(); }, [sendChatFocus]); - // Re-announce chat focus whenever the tab is hidden or shown. + // Hiding the tab stops the user reading an open chat, and showing it again + // resumes. useEffect(() => { const onVisibility = () => { if (chatOpenRef.current) sendChatFocus(); @@ -1178,7 +1135,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element { return () => document.removeEventListener("visibilitychange", onVisibility); }, [sendChatFocus]); - // Unread count in the tab title, so a background tab shows there's news. useEffect(() => { setTitleBadge(unreadNotificationCount + unreadChatCount); }, [unreadNotificationCount, unreadChatCount]); diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 29860cb5..c44d5228 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -1,5 +1,6 @@ /** - * The session, and requests made with it. + * Gives components the session, and the functions that sign in, sign out and + * make requests with its token. * * @module */ @@ -16,16 +17,17 @@ import { storeSession, } from "../../utils/auth/storage.ts"; -/** What {@link useAuth} returns. */ +/** + * Returned by {@link useAuth} to any component that needs the session. Its + * functions are stable until the session's token changes. + */ export interface UseAuthResult { - /** The signed-in user, null when signed out. */ user: SelfUser | null; - /** The session token. */ token: string | null; /** - * The token for file and thumbnail URLs, where it is visible in the query - * string. Sessions from before media tokens fall back to the session token - * until AuthProvider fetches one. + * The token for file and thumbnail URLs, where it shows in the query string. + * A session from before media tokens falls back to the session token until + * AuthProvider fetches one. */ mediaToken: string | null; /** Starts a session from a sign-in or sign-up response, in every tab. */ @@ -36,25 +38,25 @@ export interface UseAuthResult { * must not sign those out. */ logout: () => void; - /** Merges `patch` into the current session, in memory and in storage. */ + /** + * Merges `patch` into the session, in memory and in storage. The profile page + * uses it for a new avatar or email, the password dialog for fresh tokens. + */ updateSession: (patch: Partial<Session>) => void; /** - * `fetch` with the session's token, and a JSON content type unless the body - * is FormData. An expired token, or a 401, ends the session; the former - * answers a synthetic 401 without calling the server. + * Calls `fetch` with the session's token, and a JSON content type unless the + * body is FormData. An expired token, or a 401, ends the session. An expired + * token answers a 401 without calling the server. */ authFetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>; /** - * `authFetch` for multipart uploads that need a progress bar. + * Works like `authFetch` for a multipart upload that shows progress, as the + * dump creation dialog's file upload does. It goes through XHR, since `fetch` + * reports nothing until the whole body is sent. It still answers a + * `Response`, so callers use `expectOk` as usual. * - * `fetch` reports nothing until the whole body is on the wire, which for a 50 - * MB dump on a phone is a long silence — so this goes through XHR, whose - * `upload.progress` events fire as bytes leave. The result is wrapped back - * into a real `Response` so callers keep using `expectOk` unchanged. - * - * `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent - * and we are waiting on the server (the length is unknown for chunked bodies, - * and "100%, still waiting" reads as a stall). + * `onProgress` receives a fraction from 0 to 1, or `null` once the body is + * sent and the server is working, or when the length is unknown. */ authUpload: ( url: string, @@ -64,7 +66,10 @@ export interface UseAuthResult { ) => Promise<Response>; } -/** Returns the session and what acts on it. */ +/** + * Returns the session and the functions that act on it. It must be called + * under `AuthProvider`. + */ export function useAuth(): UseAuthResult { const { authResponse, setAuthResponse } = useContext(AuthContext); @@ -73,9 +78,6 @@ export function useAuth(): UseAuthResult { storeSession(response); }, [setAuthResponse]); - // Ends the session this render holds. A 401 can land after the session has - // moved on to fresh tokens (a password change, here or in another tab), and - // must not sign those out. const sessionToken = authResponse?.token; const logout = useCallback(() => { setAuthResponse((prev) => prev?.token === sessionToken ? null : prev); @@ -99,7 +101,7 @@ export function useAuth(): UseAuthResult { if (token && isTokenExpired(token)) { logout(); - // Return a synthetic 401 so callers handle it consistently + // Callers already handle a 401 from the server; this looks the same. return new Response(null, { status: 401 }); } @@ -109,10 +111,8 @@ export function useAuth(): UseAuthResult { ...init, headers: { ...(init.headers ?? {}), - // Only attach a token when we actually have one — avoids sending a - // `Bearer undefined` header that the server would reject as invalid. ...(token ? { Authorization: `Bearer ${token}` } : {}), - // Let the browser set Content-Type for FormData (it includes the boundary) + // The browser sets FormData's Content-Type, with its boundary. ...(isFormData ? {} : { "Content-Type": "application/json" }), }, }); @@ -141,7 +141,7 @@ export function useAuth(): UseAuthResult { const xhr = new XMLHttpRequest(); xhr.open(method, url); if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`); - // Content-Type is left to the browser so the multipart boundary is set. + // The browser sets the Content-Type, with the multipart boundary. xhr.upload.addEventListener("progress", (e) => { onProgress(e.lengthComputable ? e.loaded / e.total : null); @@ -157,8 +157,8 @@ export function useAuth(): UseAuthResult { }), ); }); - // Mirrors what `fetch` throws on a network failure, so the shared - // `friendlyFetchError` path renders it the same way. + // The error `fetch` throws on a network failure, so that + // `friendlyFetchError` reports it the same way. xhr.addEventListener( "error", () => reject(new TypeError("Failed to fetch")), @@ -184,16 +184,19 @@ export function useAuth(): UseAuthResult { }; } -/** What {@link useRequiredAuth} returns: {@link UseAuthResult} with a user. */ +/** + * Returned by {@link useRequiredAuth}. It is {@link UseAuthResult} with a user + * that can't be null. + */ export interface UseRequiredAuthResult extends UseAuthResult { - /** The signed-in user. */ user: SelfUser; } /** - * Returns the session, for a page behind a sign-in. + * Returns the session, for a page that `RestrictedLoggedIn` guards, such as + * `DumpEdit`, so it needn't check for a user. * - * @throws {Error} When no one is signed in: the route should have guarded it. + * @throws {Error} When no one is signed in, which means the route has no guard. */ export function useRequiredAuth(): UseRequiredAuthResult { const { diff --git a/src/hooks/category/useCategories.ts b/src/hooks/category/useCategories.ts index 2b9a1b67..fbcf5325 100644 --- a/src/hooks/category/useCategories.ts +++ b/src/hooks/category/useCategories.ts @@ -1,5 +1,6 @@ /** - * The categories, from their context. + * Gives components the categories that `CategoriesProvider` loads once for the + * whole app. * * @module */ @@ -10,7 +11,10 @@ import { type CategoriesContextValue, } from "../../contexts/category/CategoriesContext.ts"; -/** Returns every category, and a way to refresh them. */ +/** + * Returns every category and a function that refetches them. Under no + * `CategoriesProvider`, the list is empty. + */ export function useCategories(): CategoriesContextValue { return useContext(CategoriesContext); } diff --git a/src/hooks/chat/useChat.ts b/src/hooks/chat/useChat.ts index a8247469..ef904c1d 100644 --- a/src/hooks/chat/useChat.ts +++ b/src/hooks/chat/useChat.ts @@ -1,5 +1,5 @@ /** - * The chatbox's open state, from its context. + * Reads `ChatContext`, for the buttons that open the chat. * * @module */ @@ -10,7 +10,10 @@ import { type ChatContextValue, } from "../../contexts/chat/ChatContext.ts"; -/** Returns whether the chatbox is open, and a way to open and close it. */ +/** + * Returns the chat modal's open state, and the functions that open and close + * it. Needs `ChatProvider` above, which `App` mounts around every page. + */ export function useChat(): ChatContextValue { return useContext(ChatContext); } diff --git a/src/hooks/dump/useDumpCardLink.ts b/src/hooks/dump/useDumpCardLink.ts index cf39d221..cd20d119 100644 --- a/src/hooks/dump/useDumpCardLink.ts +++ b/src/hooks/dump/useDumpCardLink.ts @@ -1,5 +1,6 @@ /** - * Link behaviour for a clickable feed card. + * 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. * * @module */ @@ -10,46 +11,40 @@ 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 inside the card. */ +/** Checks whether `e` happened over an anchor, which keeps its own target. */ function isOverLink(e: MouseEvent) { return (e.target as Element).closest("a") !== null; } -/** Checks whether `e` is a ctrl/cmd/shift click. */ function wantsNewTab(e: MouseEvent) { return e.ctrlKey || e.metaKey || e.shiftKey; } -/** What {@link useDumpCardLink} returns. */ +/** + * 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 { - /** Navigates in place. */ + /** Opens the dump in this tab, for a handler that would rather not play. */ go: () => void; /** - * For click handlers that do something other than navigate (play): opens the - * dump in a new tab and returns true when the click was a new-tab request. + * 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; - /** The handlers to spread onto the card. */ + /** Spread onto the card element. */ cardProps: { - /** Opens the dump, in a new tab for a ctrl/cmd/shift click. */ onClick: (e: MouseEvent) => void; - /** - * Stops a middle press from starting autoscroll (Windows and Linux - * browsers), which eats the auxclick that follows. - */ onMouseDown: (e: MouseEvent) => void; - /** Opens the dump in a new tab on a middle click. */ onAuxClick: (e: MouseEvent) => void; }; } /** - * Returns link behaviour for the card of `dump`. A feed card is a clickable - * element rather than a link, so it gets none of a link's behaviour for free. - * This gives it back: a middle click (or a ctrl/cmd/shift click) anywhere on - * the card opens the dump in a new tab, even over a play button whose plain - * click starts the media. Anchors inside the card (the title, a source link, - * category chips) keep their own target. Either way the dump is marked visited. + * 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. */ export function useDumpCardLink( dump: Pick<Dump, "id" | "slug">, diff --git a/src/hooks/dump/useDumpListSync.ts b/src/hooks/dump/useDumpListSync.ts index a64960cc..6e0e5263 100644 --- a/src/hooks/dump/useDumpListSync.ts +++ b/src/hooks/dump/useDumpListSync.ts @@ -1,5 +1,6 @@ /** - * Keeping a list of dumps in step with the socket. + * Keeps a page's list of dumps in step with the dump and comment events that + * `WSContext` receives. * * @module */ @@ -8,30 +9,30 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import type { Dump } from "../../../shared/dump.ts"; import { useWS } from "../ws/useWS.ts"; -/** How {@link useDumpListSync} treats the list. */ +/** + * Passed to {@link useDumpListSync} by a profile's list of dumps. The feeds + * pass none. + */ export interface DumpListSyncOptions { - /** Keep private dumps visible (caller is the owner). */ + /** Keeps an edited dump that is now private, for its owner's own list. */ isOwner?: boolean; /** - * Only re-insert dumps created by this user when they become visible again. - * Leave undefined to never re-insert (caller handles it, or no re-insertion needed). + * Prepends an edited dump missing from the list when `ownerId` posted it. + * Without it, a missing dump is never added. */ ownerId?: string; /** - * When true, don't re-add a dump that isn't in the list (idx === -1). - * Use this when the caller handles re-insertion itself (e.g. with a position map). + * Never adds a missing dump, whatever `ownerId`. A list that restores + * dumps at their old position, with `usePositionAwareSync`, sets it. */ skipReinsert?: boolean; } /** - * Keeps the dump list set through `setDumps` in sync with real-time WS events. - * - * - a dump deleted → filtered out - * - a dump updated → updated in place, or removed if now private - * - an updated dump not in the list → prepended if `ownerId` matches and it is - * visible - * - a comment created or deleted → the dump's `commentCount` adjusted + * Applies the socket's dump and comment events to the list that `setDumps` + * updates. A deleted dump is removed, an edited one is replaced in place, and + * a new or deleted comment moves its dump's `commentCount`. It doesn't add new + * dumps: the feeds merge `WSContext`'s `recentDumps` themselves. */ export function useDumpListSync( setDumps: (fn: (prev: Dump[]) => Dump[]) => void, @@ -39,7 +40,7 @@ export function useDumpListSync( ): void { const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS(); - // Keep refs up-to-date so closures in effects are never stale. + // Refs, so that the effects run on new events only, not on new callbacks. const setDumpsRef = useRef(setDumps); const optionsRef = useRef(options); useLayoutEffect(() => { @@ -63,7 +64,6 @@ export function useDumpListSync( const idx = prev.findIndex((d) => d.id === dump.id); if (idx !== -1) { - // Remove if it became private and the viewer can't see private dumps. if (dump.isPrivate && !isOwner) { return prev.filter((d) => d.id !== dump.id); } @@ -72,7 +72,6 @@ export function useDumpListSync( return next; } - // Dump not in list: only re-insert when ownerId is set and matches. if (skipReinsert || !ownerId) return prev; if (dump.userId !== ownerId) return prev; if (dump.isPrivate && !isOwner) return prev; @@ -80,8 +79,8 @@ export function useDumpListSync( }); }, [lastDumpEvent]); - // commentCount counts non-deleted comments, so created → +1, deleted → −1. - // Edits don't change the count. Events only fire for public dumps. + // The server sends comment events for public dumps only, so a private + // dump's count stays as fetched. useEffect(() => { if (!lastCommentEvent) return; const { type, dumpId } = lastCommentEvent; diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts index e84adeae..633f7973 100644 --- a/src/hooks/dump/useUserDumpFeed.ts +++ b/src/hooks/dump/useUserDumpFeed.ts @@ -1,5 +1,6 @@ /** - * A profile subpage's paginated list of dumps. + * Loads a user's dumps or upvoted dumps, a page at a time, for the profile + * subpages that list them in full. * * @module */ @@ -28,7 +29,11 @@ import { useFeedCache } from "../feed/useFeedCache.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; import { useScrollSave } from "../feed/useScrollSave.ts"; -/** The feed's state: loading, failed, or loaded with a page of dumps. */ +/** + * Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails + * when the user doesn't exist, and holds an empty list when only the dumps + * failed to load. + */ export type UserDumpFeedState = | { status: "loading" } | { status: "error"; error: string } @@ -41,33 +46,30 @@ export type UserDumpFeedState = loadingMore: boolean; }; -/** How {@link useUserDumpFeed} reports. */ +/** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */ export interface UseUserDumpFeedOptions { - /** Called with newly appended items whenever a loadMore succeeds. */ + /** Called with the dumps of each page after the first, once appended. */ onItemsAppended?: (items: Dump[]) => void; } -/** What {@link useUserDumpFeed} returns. */ +/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ export interface UseUserDumpFeedResult { - /** The feed's state. */ state: UserDumpFeedState; - /** Replaces the state. */ + /** Lets `UserUpvoted` prepend a dump the user just upvoted. */ setState: React.Dispatch<React.SetStateAction<UserDumpFeedState>>; - /** Updates the loaded dumps, for live sync. */ + /** Does nothing until loaded. Passed to `useDumpListSync`. */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; - /** - * The ref of the element that loads the next page when scrolled into view. - */ + /** From `useInfiniteScroll`, for an element after the last card. */ sentinelRef: RefCallback<HTMLDivElement>; } /** - * Returns the dumps of `username` from `endpoint`, with pagination, and saves - * the feed and its scroll position under `cacheKey` so going back restores - * both. Shared by the profile subpages that display a paginated list of dumps - * (UserDumps, UserUpvoted). `username` is the route param (may be undefined - * during hydration); `endpoint` the path after /api/users/:username, e.g. - * "dumps" or "votes". + * Returns the profile of `username`, from `GET /api/users/:username`, with its + * dumps from `GET /api/users/:username/<endpoint>`, `endpoint` being "dumps" + * or "votes". The dumps are fetched signed in, so the user sees their own + * private ones. The next page loads as the sentinel scrolls into view. The + * list and its scroll position are saved under `cacheKey` as the page is + * left, and going back restores them, refetching only the profile. */ export function useUserDumpFeed( username: string | undefined, diff --git a/src/hooks/editor/useEmojiTrigger.ts b/src/hooks/editor/useEmojiTrigger.ts index dc87d6df..52a7f9c2 100644 --- a/src/hooks/editor/useEmojiTrigger.ts +++ b/src/hooks/editor/useEmojiTrigger.ts @@ -1,6 +1,7 @@ /** - * The editor's emoji picker state: opened by typing `:query` or from the - * button. + * Keeps the state of `TextEditor`'s emoji picker, which opens on a typed + * `:query` or from the editor's button. `TextEditor` renders the picker and + * handles its keys. * * @module */ @@ -9,71 +10,64 @@ import { useCallback, useRef, useState } from "react"; import { insideCode } from "../../utils/editor/text.ts"; /** - * Trigger: ':' not preceded by a letter, digit or another ':', followed by a - * letter then at least one more word char / '+' / '-'. Two characters, not one, - * so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open the - * picker; Unicode letters so French names ("coeur", "cœur") search too. + * Matches a `:query` ending at the caret. The `:` must not follow a letter, a + * digit or another `:`, and the query takes at least two characters, so that + * ":D", "note:" and "12:30" don't open the picker. Letters are Unicode, so a + * French name ("cœur") searches too. */ const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u; /** - * The picker's mode. - * - * `inline`: opened by typing `:query` — focus stays in the textarea, the - * picker's search follows what's typed, and a pick replaces the `:query`. - * `browse`: opened from the button — focus goes to the picker's own search, and - * a pick lands at the caret. + * Returned by {@link useEmojiTrigger} to `TextEditor`, which keys its focus and + * key handling on it. An `inline` picker was opened by a typed `:query`: focus + * stays in the textarea and a pick replaces the query. A `browse` picker was + * opened from the button: focus goes to its search and a pick lands at the + * caret. */ export type EmojiMode = "closed" | "inline" | "browse"; -/** The picker's state. */ interface EmojiState { - /** Its mode. */ mode: EmojiMode; - /** What it searches for. */ query: string; - /** The range of the textarea's value a pick replaces: its start… */ + /** Starts the range of the textarea's value that a pick replaces. */ start: number; - /** …and its end. */ end: number; } -/** The closed picker. */ const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 }; -/** What {@link useEmojiTrigger} returns. */ +/** Returned by {@link useEmojiTrigger} to `TextEditor`. */ export interface UseEmojiTriggerResult { - /** The picker's mode. */ emojiMode: EmojiMode; - /** What the picker searches for. */ emojiQuery: string; /** - * Call on every change (with `open`) and caret move (without): follows an - * inline `:query` as it's typed, and closes once the caret leaves it. Leaves - * a browse-mode picker alone. + * Follows an inline `:query` and closes the picker once the caret leaves it. + * The caller calls it on every change with `open` set, and on every caret + * move without, since only typing may open the picker. It leaves a `browse` + * picker alone. */ syncEmojiTrigger: (value: string, cursor: number, open: boolean) => void; - /** Opens the picker from the button, a pick replacing `[start, end)`. */ + /** Opens a `browse` picker, whose pick will replace `[start, end)`. */ openEmojiBrowser: (start: number, end: number) => void; - /** Sets what the picker searches for. */ + /** Sets the query of the picker's search, in either mode. */ setEmojiQuery: (query: string) => void; - /** Inserts the emoji `native` and closes the picker. */ + /** Inserts the emoji `native` through `replace` and closes the picker. */ selectEmoji: (native: string) => void; - /** Closes the picker. */ closeEmoji: () => void; } /** - * Returns the emoji picker's state for a textarea; a pick goes through - * `replace`, which replaces `[start, end)` of the textarea's value, with a - * space after when `word` is set (see TextEditor). + * Returns the picker's state and the functions that drive it. A pick goes + * through `replace`, `TextEditor`'s function that puts the text over + * `[start, end)` of the textarea's value, followed by a space when `word` is + * set. */ export function useEmojiTrigger( replace: (start: number, end: number, text: string, word: boolean) => void, ): UseEmojiTriggerResult { const [state, setStateRaw] = useState<EmojiState>(CLOSED); - // Mirrored synchronously, so a change and a caret move in the same event - // each see what the other just decided. + // Mirrored synchronously, so that a change and a caret move in the same + // event each see what the other just decided. const stateRef = useRef(state); const setState = useCallback((next: EmojiState) => { stateRef.current = next; @@ -114,8 +108,8 @@ export function useEmojiTrigger( const { mode, start, end } = stateRef.current; if (mode === "closed") return; setState(CLOSED); - // An inline pick finishes a word, so it takes a space after it the way a - // mention does; a browse pick goes in exactly where the caret was. + // An inline pick finishes a word, so it takes a space after it as a + // mention does. A browse pick goes in exactly where the caret was. replace(start, end, native, mode === "inline"); }, [replace, setState]); diff --git a/src/hooks/editor/useMentionAutocomplete.ts b/src/hooks/editor/useMentionAutocomplete.ts index 3775da58..bd131d95 100644 --- a/src/hooks/editor/useMentionAutocomplete.ts +++ b/src/hooks/editor/useMentionAutocomplete.ts @@ -1,6 +1,7 @@ /** - * The editor's @mention autocomplete: the `@query` under the caret, the users - * it matches, and keyboard selection. + * Keeps the state of `TextEditor`'s `@mention` suggestions, which it fetches + * from `GET /api/users/search`. `TextEditor` renders them in a + * `MentionDropdown`. * * @module */ @@ -16,26 +17,16 @@ import { API_URL } from "../../config/api.ts"; import { insideCode } from "../../utils/editor/text.ts"; import type { UserSuggestion } from "../../../shared/user.ts"; -/** The autocomplete's state. */ interface MentionState { - /** Whether the list is shown. */ open: boolean; - /** The name typed after the `@`. */ query: string; - /** - * Where the `@` sits, and the caret after the query: the range a pick - * replaces. - */ + /** Where the `@` sits. A pick replaces `[start, end)`. */ start: number; - /** The caret after the query. */ end: number; - /** The matching users. */ results: UserSuggestion[]; - /** The highlighted match. */ selectedIndex: number; } -/** The closed autocomplete. */ const CLOSED: MentionState = { open: false, query: "", @@ -46,15 +37,14 @@ const CLOSED: MentionState = { }; /** - * The server's mention rule (api/services/notification-service.ts): an `@` not - * preceded by `[`, `(` or a word character, so what's offered is exactly what - * would notify. + * Matches the `@query` ending at the caret by the rule of shared/mention.ts, + * which the server notifies by, so that what is offered would notify. */ const TRIGGER_RE = /(?<![[(\w])@(\w+)$/; /** * Returns the `@query` that ends at `pos` in `value`, or null when there is - * none (or it is inside code). + * none or it is inside code. */ function getMentionQuery( value: string, @@ -67,7 +57,10 @@ function getMentionQuery( return { query: match[1], start: match.index }; } -/** The parts of a keydown the mention list reads. */ +/** + * Passed to {@link UseMentionAutocompleteResult.handleMentionKeyDown} by + * `TextEditor`. It is the part of a React keydown event that the list reads. + */ export type MentionKey = Pick< KeyboardEvent, | "key" @@ -79,41 +72,40 @@ export type MentionKey = Pick< | "stopPropagation" >; -/** What {@link useMentionAutocomplete} returns. */ +/** Returned by {@link useMentionAutocomplete} to `TextEditor`. */ export interface UseMentionAutocompleteResult { - /** Whether the list is shown. */ + /** Set only once a search has found someone. */ mentionOpen: boolean; - /** The matching users. */ mentionResults: UserSuggestion[]; - /** The highlighted match. */ mentionSelectedIndex: number; /** - * Call on every change (with `lookup`) and caret move (without): tracks the - * `@query` under the caret, searching for it after a short pause, and closes - * once the caret leaves it. + * Follows the `@query` under the caret, searches for it 150 ms after the + * last keystroke, and closes the list once the caret leaves it. The caller + * calls it on every change with `lookup` set, and on every caret move + * without, since only typing may search. */ syncMention: (value: string, pos: number, lookup: boolean) => void; - /** Closes the list. */ closeMention: () => void; /** - * Handles arrows, Enter/Tab and Escape while the list is shown; returns - * whether it handled the key. + * Handles the arrows, Enter, Tab and Escape while the list is shown, and + * returns whether it took the key. The caller must then leave the key alone. */ handleMentionKeyDown: (e: MentionKey) => boolean; - /** Inserts `@username` and closes the list. */ + /** Inserts `@username` through `replace` and closes the list. */ handleMentionSelect: (username: string) => void; } /** - * Returns the @mention autocomplete for a textarea; a pick goes through - * `replace`, which replaces `[start, end)` of the textarea's value, with a - * space after when `word` is set (see TextEditor). + * Returns the suggestions' state and the functions that drive them. A pick + * goes through `replace`, `TextEditor`'s function that puts the text over + * `[start, end)` of the textarea's value, followed by a space when `word` is + * set. */ export function useMentionAutocomplete( replace: (start: number, end: number, text: string, word: boolean) => void, ): UseMentionAutocompleteResult { const [state, setStateRaw] = useState<MentionState>(CLOSED); - // Mirrored synchronously so the key handler and a fetch landing late both + // Mirrored synchronously, so that the key handler and a late response both // read what the last keystroke decided, not the last render. const stateRef = useRef(state); const setState = useCallback((next: MentionState) => { @@ -144,8 +136,8 @@ export function useMentionAutocomplete( return; } if (!lookup) { - // A caret move within the same @query keeps the list; one that lands - // on some other @word doesn't open anything. + // A caret move within the same @query keeps the list, and one that + // lands on another @word doesn't open anything. if (mention.start !== s.start || mention.query !== s.query) close(); return; } @@ -172,7 +164,7 @@ export function useMentionAutocomplete( ); const body = await res.json(); const current = stateRef.current; - // Only the lookup for what's typed now may open or close the list. + // Only the search for what is typed now may open or close the list. if (current.query !== mention.query) return; if (body.success && body.data.length > 0) { setState({ @@ -185,7 +177,7 @@ export function useMentionAutocomplete( setState({ ...current, open: false, results: [] }); } } catch { - // Aborted by a newer keystroke, or offline: nothing to offer. + // Aborted by a newer keystroke, or offline, so nothing is offered. } }, 150); }, @@ -209,7 +201,6 @@ export function useMentionAutocomplete( e.preventDefault(); const n = s.results.length; const step = e.key === "ArrowDown" ? 1 : -1; - // Wraps, like any listbox. setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n }); return true; } @@ -223,8 +214,8 @@ export function useMentionAutocomplete( return true; } if (e.key === "Escape") { - // Just the list: whatever Escape means to the editor's owner (cancel - // an edit, close the chat) waits for a second press. + // Closes just the list. Whatever Escape means to the editor's owner + // (cancel an edit, close the chat) waits for a second press. e.preventDefault(); e.stopPropagation(); close(); diff --git a/src/hooks/feed/useDefaultFeedTab.ts b/src/hooks/feed/useDefaultFeedTab.ts index 8869d115..0ca6a0fa 100644 --- a/src/hooks/feed/useDefaultFeedTab.ts +++ b/src/hooks/feed/useDefaultFeedTab.ts @@ -1,5 +1,5 @@ /** - * The feed tab the index opens on, as the user chose it. + * Stores, in this browser, which feed tab the index opens on. * * @module */ @@ -11,13 +11,9 @@ import { type FeedTab, } from "../../config/feedTabs.ts"; -/** The localStorage key of the chosen tab. */ const STORAGE_KEY = "default-feed-tab"; -/** - * Returns the stored tab, or {@link DEFAULT_FEED_TAB} when none valid is - * stored. - */ +/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */ function readStored(): FeedTab { const stored = localStorage.getItem(STORAGE_KEY); return stored && (FEED_TABS as readonly string[]).includes(stored) @@ -26,10 +22,11 @@ function readStored(): FeedTab { } /** - * Returns the user's preference for which feed tab the index opens on, - * persisted to localStorage (defaults to {@link DEFAULT_FEED_TAB}), and a - * setter that persists the choice. Consumers that only need to honour the - * preference (e.g. the index default) can ignore the setter. + * Returns the feed tab the index opens on, and a setter that stores a new + * choice. The profile's settings set it. The index's redirect, its tab bar and + * `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is + * "followed" and nobody is signed in. Each call reads the stored tab once, so + * a change shows in other components only once they remount. */ export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] { const [tab, setTabState] = useState<FeedTab>(readStored); diff --git a/src/hooks/feed/useFading.ts b/src/hooks/feed/useFading.ts index de9977ca..6e8100e2 100644 --- a/src/hooks/feed/useFading.ts +++ b/src/hooks/feed/useFading.ts @@ -1,6 +1,6 @@ /** - * The dismissal animation of items leaving a list: a grace period, then a - * collapse. + * Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn + * by mistake can be cast again before the dump is gone. * * @module */ @@ -8,25 +8,29 @@ import { useCallback, useEffect, useRef, useState } from "react"; /** - * Where an item is in its dismissal: `cooldown` is the 2-second grace period - * (darkened), `dismissing` the collapse (opacity 0 + grid-row collapse). + * The phase of a fading item, which the list maps to a CSS class. `cooldown` + * lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse. */ export type FadeState = "cooldown" | "dismissing"; -/** What {@link useFading} returns. */ +/** + * Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted + * section. The list keeps showing an item while it is in `fading`, then drops + * it. + */ export interface UseFadingResult { - /** The items being dismissed, by id, and where each is; for CSS classes. */ fading: Record<string, FadeState>; - /** Starts dismissing item `id`. */ + /** Does nothing when `id` is already fading. */ startFading: (id: string) => void; - /** Stops dismissing item `id` (e.g. the action was reversed). */ + /** Called when the vote is cast again: the item stays. */ cancelFading: (id: string) => void; - /** Cancels all in-progress animations (e.g. on page navigation). */ + /** Called when the list changes to another user's. */ cancelAll: () => void; } /** - * Returns a 2-second cooldown + 350ms dismissal animation for a set of items. + * Returns the fading state of a list's items and the calls that drive it. The + * timers are cleared on unmount. */ export function useFading(): UseFadingResult { const [fading, setFading] = useState<Record<string, FadeState>>({}); @@ -38,6 +42,8 @@ export function useFading(): UseFadingResult { const startFading = useCallback((id: string) => { if (cancels.current.has(id)) return; + // `kill` is replaced as each timer is set, so it always clears the one + // running; `dead` stops a timer that fired before it was cleared. let dead = false; let kill = () => {}; kill = () => { diff --git a/src/hooks/feed/useFeedCache.ts b/src/hooks/feed/useFeedCache.ts index c25dd834..a7e8af05 100644 --- a/src/hooks/feed/useFeedCache.ts +++ b/src/hooks/feed/useFeedCache.ts @@ -1,6 +1,6 @@ /** - * A feed's pages and scroll position, kept in sessionStorage so going back to - * it restores them. + * Keeps a feed's loaded pages and scroll position in sessionStorage, so that + * going back to a feed page shows it as it was left instead of refetching it. * * @module */ @@ -8,30 +8,32 @@ import { useCallback, useState } from "react"; import { useNavigationType } from "react-router"; -/** How long a saved feed stays good: 10 minutes. */ +/** Past this age, a saved feed is refetched rather than restored. */ const TTL = 10 * 60 * 1000; // 10 minutes -/** A saved feed. */ +/** + * Stored as JSON by {@link useFeedCache}'s `saveState`, and read back by the + * hook when the page is next reached by going back. + */ export interface FeedCacheEntry<T> { - /** Its items, every page loaded so far. */ + /** Every page loaded so far. JSON turns their dates into strings. */ items: T[]; - /** The last page loaded. */ + /** The last page loaded, from which loading more carries on. */ page: number; - /** Whether another page follows. */ hasMore: boolean; - /** The scroll position. */ scrollY: number; - /** When it was saved, in epoch ms. */ + /** In epoch milliseconds. */ savedAt: number; } -/** What {@link useFeedCache} returns. */ +/** Returned by {@link useFeedCache} to a feed page. */ export interface UseFeedCacheResult<T> { /** - * The saved feed, when the page was reached by going back and one is fresh. + * The saved feed, when the page was reached by going back or forward and the + * feed was saved less than 10 minutes ago. It doesn't change after mount. */ cached: Omit<FeedCacheEntry<T>, "savedAt"> | null; - /** Saves the feed. */ + /** Usually called through `useScrollSave`, as the page is left. */ saveState: ( items: T[], page: number, @@ -41,9 +43,11 @@ export interface UseFeedCacheResult<T> { } /** - * Returns the feed saved under `key`, read once on mount and only on a back or - * forward navigation, its items passed through `hydrateItem`; and a way to save - * it. A full or blocked storage is ignored. + * Returns the feed saved under `key` and a way to save it. The feed pages and + * `useUserDumpFeed` use it. `key` must tell apart every list whose items + * differ, the viewer included when they see private items. `hydrateItem` + * restores what JSON lost, such as dates. A full or blocked storage is + * ignored. */ export function useFeedCache<T>( key: string, @@ -51,7 +55,7 @@ export function useFeedCache<T>( ): UseFeedCacheResult<T> { const navType = useNavigationType(); - // useState initializer runs exactly once on mount — correct for a cache read. + // Read once: a new navigation to the page must not pick up what it saves. const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => { if (navType !== "POP") return null; try { @@ -79,7 +83,7 @@ export function useFeedCache<T>( }), ); } catch { - // QuotaExceededError or SecurityError — degrade silently + // Full or blocked storage: the page just refetches next time. } }, [key], diff --git a/src/hooks/feed/useInfiniteScroll.ts b/src/hooks/feed/useInfiniteScroll.ts index 6e7ac009..9122962f 100644 --- a/src/hooks/feed/useInfiniteScroll.ts +++ b/src/hooks/feed/useInfiniteScroll.ts @@ -1,37 +1,34 @@ /** - * Loading a feed's next page as its end scrolls into view. + * Loads a feed's next page when the end of the feed scrolls into view. * * @module */ import { useCallback, useEffect, useRef, useState } from "react"; -/** Longest wait between two loads while the sentinel never left the view. */ +/** The longest wait between two loads while the sentinel stays in view. */ const MAX_RETRY_DELAY_MS = 30_000; /** - * Returns the ref of a sentinel element, and calls `onLoadMore` when it scrolls - * into view while `enabled`. + * Returns a callback ref for the sentinel element placed after a feed's last + * item, and calls `onLoadMore` when the sentinel comes within 200px of the + * view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and + * not already loading". * - * The sentinel is tracked via a state-backed callback ref rather than a plain - * `useRef` so the observer re-attaches whenever the underlying DOM node changes - * — e.g. when the index page swaps the active feed tab and a fresh sentinel - * `<div>` mounts in place of the old one. A static ref would keep observing the - * now-detached node and silently stop loading more. + * The ref is a callback so that the observer follows a new sentinel, as when + * `Index` switches tabs and another feed mounts its own. * - * Each change of `enabled` makes a new observer, which reports the sentinel at - * once if it's still in view. After a load that added items that's what fills a - * tall screen; after one that failed it would ask again straight away, and - * again, as fast as the request can fail. So a sentinel that stays put in view - * waits longer each time — 1s, 2s, 4s… up to 30s — and goes back to asking at - * once when it leaves the view or is pushed down by new items. + * A sentinel still in view after a load asks again, which fills a tall screen, + * but would also retry a failing request as fast as it fails. So while it + * stays in view the waits double, from 1s up to 30s, and they start over once + * it leaves the view or new items push it down. */ export function useInfiniteScroll( onLoadMore: () => void, enabled: boolean, ): (node: HTMLDivElement | null) => void { const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null); - // Loads asked for since the sentinel last left the view or moved. + // Loads asked for since the sentinel last left the view or moved down. const attemptsRef = useRef(0); const lastLoadAtRef = useRef(0); const lastTopRef = useRef<number | null>(null); @@ -58,8 +55,8 @@ export function useInfiniteScroll( lastLoadAtRef.current = Date.now(); onLoadMore(); }; - // Counted from the last load, not from now: the observer is remade on - // every render that changes onLoadMore, which mustn't restart the wait. + // Counted from the last load, not from now: the observer is remade + // whenever `onLoadMore` changes, which mustn't restart the wait. const wait = attempts === 0 ? 0 : lastLoadAtRef.current + Math.min(1000 * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS) - Date.now(); if (wait <= 0) { diff --git a/src/hooks/feed/usePositionAwareSync.ts b/src/hooks/feed/usePositionAwareSync.ts index ca3e9e0d..10c9d42b 100644 --- a/src/hooks/feed/usePositionAwareSync.ts +++ b/src/hooks/feed/usePositionAwareSync.ts @@ -1,5 +1,6 @@ /** - * Putting items that left a list back where they were. + * Puts an item that left a profile's list, by turning private, back at its + * old position when it turns public again. * * @module */ @@ -7,19 +8,17 @@ import { useEffect, useLayoutEffect, useRef } from "react"; /** - * Keeps track of item positions as items leave a list and re-inserts them at - * their original positions when they become visible again. + * Remembers the index of an item that `lastEvent` removes from `items`, and + * puts the item back there when a later event makes it visible again. The + * profile pages use it with `WSContext`'s latest dump or playlist edit. + * `items` is the loaded list, or `[]` before it loads, and `setItems` the + * setter also passed to `useDumpListSync` or `usePlaylistListSync`. + * `isRemoving` tells an event that takes the item out, and `shouldReinsert` + * one that brings it back. * - * `items` is the current list (pass [] when state is not loaded), `setItems` - * its setter (the same one passed to the sync hook), and `lastEvent` the latest - * changed item. `isRemoving` returns true when the item is about to leave the - * list (e.g. it just became private), so its index is saved; `shouldReinsert` - * returns true when it should come back at its original position (e.g. it just - * became public again). - * - * MUST be called BEFORE the corresponding sync hook (useDumpListSync / - * usePlaylistListSync) so the save-position effect fires before the hook's - * removal effect in the same render cycle. + * It must be called before the sync hook, so that its effects run first: it + * saves the index before the sync hook removes the item. The sync hook must + * get `skipReinsert`, or it would add the item a second time. */ export function usePositionAwareSync<T extends { id: string }>( items: T[], @@ -41,7 +40,6 @@ export function usePositionAwareSync<T extends { id: string }>( const removedPositionsRef = useRef<Map<string, number>>(new Map()); - // Save position BEFORE the sync hook removes the item. useEffect(() => { if (!lastEvent || !isRemovingRef.current(lastEvent)) return; const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id); @@ -50,8 +48,6 @@ export function usePositionAwareSync<T extends { id: string }>( } }, [lastEvent]); - // Re-insert at the saved position. Fires before the sync hook's effect for - // the same event; skipReinsert on the sync hook prevents double-insertion. useEffect(() => { if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return; const savedIdx = removedPositionsRef.current.get(lastEvent.id); @@ -61,6 +57,7 @@ export function usePositionAwareSync<T extends { id: string }>( setItemsRef.current((prev) => { if (prev.some((i) => i.id === item.id)) return prev; const next = [...prev]; + // The list may have shrunk since. next.splice(Math.min(savedIdx, next.length), 0, item); return next; }); diff --git a/src/hooks/feed/useScrollSave.ts b/src/hooks/feed/useScrollSave.ts index 5d9b64f6..0fa81cfe 100644 --- a/src/hooks/feed/useScrollSave.ts +++ b/src/hooks/feed/useScrollSave.ts @@ -1,5 +1,5 @@ /** - * Saving a feed as the page is left. + * Saves a feed page's state, with its scroll position, as the page is left. * * @module */ @@ -7,17 +7,18 @@ import { useEffect, useLayoutEffect, useRef } from "react"; /** - * Calls `onSave(scrollY)` when the page is left — the component unmounts (a - * navigation within the app) or the tab goes away (`pagehide`) — as long as - * `enabled` is true (e.g. the page is loaded). + * Calls `onSave(scrollY)` when the component unmounts or the tab is hidden + * (`pagehide`), while `enabled`. Feed pages pass `enabled` once their feed is + * loaded, and an `onSave` that calls `useFeedCache`'s `saveState`. * - * Scrolling itself only notes the position: saving serializes the whole feed - * into sessionStorage, which on every pause in scrolling cost a long task. + * It doesn't save on scroll, since serializing the whole feed there took a long + * task at every pause. */ export function useScrollSave( enabled: boolean, onSave: (scrollY: number) => void, ): void { + // A ref, so a new `onSave` on every render doesn't re-attach the listeners. const onSaveRef = useRef(onSave); useLayoutEffect(() => { onSaveRef.current = onSave; @@ -25,8 +26,8 @@ export function useScrollSave( useEffect(() => { if (!enabled) return; - // Tracked as it happens: by the time the unmount runs, the next page may - // already have scrolled the window back to the top. + // Tracked on scroll: by the time the unmount runs, the next page may have + // scrolled the window back to the top. let y = globalThis.scrollY; const onScroll = () => { y = globalThis.scrollY; diff --git a/src/hooks/player/usePlayRichContent.ts b/src/hooks/player/usePlayRichContent.ts index 229bce89..424f8252 100644 --- a/src/hooks/player/usePlayRichContent.ts +++ b/src/hooks/player/usePlayRichContent.ts @@ -1,5 +1,7 @@ /** - * Playing a link's preview in the player. + * Plays a link's preview in the global player, as the provider's embed or as a + * queue of Bandcamp streams. RichContentCard and JournalCard call it when their + * thumbnail is clicked. * * @module */ @@ -12,27 +14,30 @@ import { import { BANDCAMP_PLAYER } from "../../config/playerMode.ts"; import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts"; -/** The part of a link's preview the player needs. */ +/** + * Passed to `playRichContent` and {@link canPlayRichContent} by RichContentCard + * and JournalCard. It is a dump's `RichContent`, its thumbnail replaced by the + * dump's own when it has one. + */ export interface PlayableRichContent { /** The provider, as in `RichContent.type`. */ type: string; - /** The link. */ + /** The page, which native Bandcamp playback resolves. */ url: string; - /** The title, for the player header. */ title?: string; - /** The provider's embed. */ embedUrl?: string; - /** Header artwork. Callers with a dump-level override should pass that. */ + /** Shown in the player's header, unless Bandcamp has album art of its own. */ thumbnailUrl?: string; - /** Header subtitle — "YouTube", "SoundCloud", the site's own name. */ + /** Shown under the title in the player's header. */ siteName?: string; } -/** What {@link usePlayRichContent} returns. */ +/** Returned by {@link usePlayRichContent} to the cards that play a link. */ export interface UsePlayRichContentResult { /** - * Starts playback of `rc`, played from the dump page `dumpHref`; resolves to - * whether playback actually started. + * Plays `rc`, its title in the player linking to the dump page `dumpHref`. + * Resolves to false when nothing could play, so the caller can open the link + * instead. */ playRichContent: ( rc: PlayableRichContent, @@ -43,18 +48,12 @@ export interface UsePlayRichContentResult { } /** - * Returns a way to start playback for a rich-content item. - * - * Everything except Bandcamp goes straight to the iframe embed, exactly as - * before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native" - * mode the page is resolved to its mp3 streams and played as a queue, which is - * what makes it autoplay and lets albums play through. Any failure — offline, - * preorder-only release, Bandcamp changing its markup — falls back to the - * embed. - * - * Playback may not start: a native-mode page with no stored embedUrl (an artist - * root, a /music index, a preorder) has nothing to fall back to, and the caller - * has to be told so the click isn't swallowed. + * Returns a function that plays a link's preview in its provider's embed. When + * the deployment sets `GERBEUR_BANDCAMP_PLAYER=native`, a Bandcamp page is + * resolved into its mp3 streams instead and played as a queue, which autoplays + * and plays an album through. If that fails (offline, a preorder, Bandcamp + * changing its markup), the embed plays, and a page without one (an artist's + * root, a /music index) plays nothing. */ export function usePlayRichContent(): UsePlayRichContentResult { const { play, playQueue } = useContext(PlayerContext); @@ -107,9 +106,9 @@ export function usePlayRichContent(): UsePlayRichContentResult { } /** - * Checks whether `rc` can be played at all. In native mode a Bandcamp page is - * playable even without a stored embedUrl, since the streams are resolved from - * the page itself. + * Checks whether `rc` can be played, for the cards to offer a play button. In + * native mode a Bandcamp page plays even without an embed, since its streams + * are resolved from the page itself. */ export function canPlayRichContent(rc: PlayableRichContent): boolean { if (rc.embedUrl) return true; diff --git a/src/hooks/player/useYouTubePlaylist.ts b/src/hooks/player/useYouTubePlaylist.ts index a5c5914d..beded3fb 100644 --- a/src/hooks/player/useYouTubePlaylist.ts +++ b/src/hooks/player/useYouTubePlaylist.ts @@ -1,15 +1,10 @@ /** - * Tracklist and transport for a YouTube playlist embed. - * - * A Bandcamp album becomes a real queue of streams, but a YouTube playlist - * stays inside the iframe: YouTube holds the list and advances through it. So - * the two halves come from two places — the titles and lengths from the server - * (the embed exposes neither), the current position and the controls from the - * IFrame Player API attached to the iframe we already render. - * - * Both halves are optional. Without the server's titles there is no list; with - * a list but no API (script blocked, embedding restricted) the rows are shown - * but inert, since nothing can be told to play. + * Gives GlobalPlayer the tracklist and transport of a YouTube playlist embed, + * whose list stays inside the iframe. The titles and lengths come from the + * server, since the embed exposes neither; the order, the position and the + * controls come from the IFrame Player API. Without the API (script blocked, + * embedding restricted) the rows are inert; without the server's list they + * have no titles. * * @module */ @@ -41,7 +36,9 @@ interface YTPlayer { /** A player state change. */ interface YTStateEvent { - /** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */ + /** + * YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. + */ data: number; } @@ -62,13 +59,11 @@ interface YTNamespace { const PAUSED = 2; /** - * How soon after the tab is hidden a pause is undone. An embed that has - * `enablejsapi` on pauses itself when the page goes to the background — - * something a plain embed does not do, and nothing on our side asked for: it - * breaks listening while working in another tab. A pause landing in the moment - * the tab is hidden is therefore undone. The window is narrow and the retries - * few on purpose, so a pause that comes later (a media key, a phone call, the - * user coming back and hitting space) stands. + * How soon after the tab is hidden a pause is undone. An embed with + * `enablejsapi` pauses itself when the page goes to the background, which a + * plain embed doesn't, and that stops the music while one works in another + * tab. The window is short and the retries few, so that a later pause (a media + * key, a phone call) stands. */ const RESUME_WINDOW_MS = 4000; /** How many pauses are undone per hiding of the tab. */ @@ -82,10 +77,8 @@ declare global { } } -/** The IFrame API script. */ const API_SRC = "https://www.youtube.com/iframe_api"; -/** The IFrame API, once asked for. */ let apiPromise: Promise<YTNamespace> | null = null; /** Loads YouTube's IFrame API once per document, whoever asks first. */ @@ -114,35 +107,36 @@ function loadPlayerApi(): Promise<YTNamespace> { return apiPromise; } -/** A row of the tracklist. */ +/** + * A row of the tracklist GlobalPlayer shows. Its title and length come from + * the server's list, and are missing for a video that list lacks. + */ export interface PlaylistRow { - /** The video's id. */ videoId: string; - /** Its title, when the server's list has it. */ title?: string; - /** Its length in seconds, when the server's list has it. */ + /** In seconds. */ duration?: number; } -/** What {@link useYouTubePlaylist} returns. */ +/** + * Returned by {@link useYouTubePlaylist} to GlobalPlayer, which shows the + * tracklist and its transport when there is more than one row. + */ export interface UseYouTubePlaylistResult { - /** The tracklist. */ rows: PlaylistRow[]; - /** Position in `rows`, or -1 while the player hasn't reported one. */ + /** The playing row, or -1 while the player hasn't reported one. */ index: number; - /** Whether the embed can be driven — rows are clickable only then. */ + /** Whether the IFrame API drives the embed. Rows are clickable only then. */ ready: boolean; - /** Plays the row at `index`. */ playAt(index: number): void; - /** Plays the next video. */ next(): void; - /** Plays the previous video. */ previous(): void; } /** * Returns the tracklist and transport of the playlist `embedUrl` plays in the - * iframe `iframeRef`, when it plays one. + * iframe `iframeRef`, or no rows when it plays none. The iframe's URL must + * carry `enablejsapi` (see `withJsApi`) for the API to drive it. */ export function useYouTubePlaylist( embedUrl: string | null, diff --git a/src/hooks/playlist/usePlaylistListSync.ts b/src/hooks/playlist/usePlaylistListSync.ts index ee867821..5bc1f30b 100644 --- a/src/hooks/playlist/usePlaylistListSync.ts +++ b/src/hooks/playlist/usePlaylistListSync.ts @@ -1,5 +1,6 @@ /** - * Keeping a list of playlists in step with the socket. + * Keeps a page's list of playlists in step with the socket's `playlist_*` + * messages. The user's profile and playlists pages call it. * * @module */ @@ -8,36 +9,34 @@ import { useEffect, useLayoutEffect, useRef } from "react"; import type { Playlist } from "../../../shared/playlist.ts"; import { useWS } from "../ws/useWS.ts"; -/** How {@link usePlaylistListSync} treats the list. */ +/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */ export interface PlaylistListSyncOptions { - /** Keep private playlists visible (caller is the owner). */ + /** Keeps private playlists in the list, for their owner. */ isOwner?: boolean; /** - * Only act on created/updated events for playlists owned by this user. - * Leave undefined to act on any playlist already in the list. + * Adds only this user's playlists, when one is created or comes back into + * view. Without it, anyone's are added. */ ownerId?: string; /** - * Set true for "followed" lists: never prepend newly created playlists - * (followed membership is managed separately), but still update/remove. + * Adds no playlist, for a list of followed playlists, since the socket + * doesn't say what the user follows. Playlists already listed still change + * and go. */ noNewEntries?: boolean; /** - * When true, don't re-add a playlist that isn't in the list (idx === -1). - * Use this when the caller handles re-insertion itself (e.g. with a position - * map, like the dumps/votes pattern in UserPublicProfile). + * Leaves out a playlist that comes back into view, for a caller that puts it + * back where it was with `usePositionAwareSync`. */ skipReinsert?: boolean; } /** - * Keeps the playlist list set through `setPlaylists` in sync with real-time WS - * events. - * - * - a playlist created → prepended (unless `noNewEntries`) - * - a playlist updated → updated in place, or removed if now private - * - a playlist deleted → removed - * - a playlist's dumps changed → its `dumpCount` updated + * Applies the socket's playlist events to the list set through + * `setPlaylists`. A created playlist goes first, an updated one changes in + * place or leaves when it turns private (unless `isOwner`), a deleted one + * leaves, and a + * playlist's `dumpCount` follows its dumps. */ export function usePlaylistListSync( setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void, @@ -69,17 +68,14 @@ export function usePlaylistListSync( if (ev.type === "updated" && ev.playlist) { const idx = prev.findIndex((p) => p.id === ev.playlist!.id); if (idx === -1) { - // Playlist was removed (went private). Re-add if it's now public and - // visible to this list. noNewEntries lists (followed) skip this since - // we can't verify the follow relationship is still active. + // Left the list when it turned private, and may be back. A followed + // list can't tell whether the user still follows it. if (noNewEntries) return prev; if (ownerId && ev.playlist.userId !== ownerId) return prev; if (!ev.playlist.isPublic && !isOwner) return prev; - // skipReinsert: caller handles re-insertion at the correct position. if (optionsRef.current?.skipReinsert) return prev; return [ev.playlist, ...prev]; } - // Remove if it became private and the viewer can't see private playlists if (!ev.playlist.isPublic && !isOwner) { return prev.filter((p) => p.id !== ev.playlist!.id); } diff --git a/src/hooks/shared/useAnchoredFloat.ts b/src/hooks/shared/useAnchoredFloat.ts index 1844c114..000adca7 100644 --- a/src/hooks/shared/useAnchoredFloat.ts +++ b/src/hooks/shared/useAnchoredFloat.ts @@ -1,5 +1,6 @@ /** - * Placing a floating panel against its anchor, clear of clipping ancestors. + * Places a floating panel against its anchor with `position: fixed`, for + * `TextEditor`'s emoji picker and mention list. * * @module */ @@ -11,38 +12,36 @@ import { useState, } from "react"; -/** Space between the anchor and the panel. */ +/** The space between the anchor and the panel, in pixels. */ const GAP = 4; -/** Space kept clear of the viewport edges. */ +/** The space kept clear of the viewport's edges, in pixels. */ const MARGIN = 8; -/** How {@link useAnchoredFloat} sizes the panel. */ +/** + * Passed to {@link useAnchoredFloat} by the component that owns the panel, in + * pixels. The heights let the hook size the panel's scrolling part to the room + * left. + */ export interface AnchoredFloatOptions { /** - * Preferred width in px, or "anchor" to match the anchor element's own width. - * Shrunk when the viewport is narrower either way. + * A width, or "anchor" for the anchor's own. Either shrinks to fit a + * narrower viewport. */ width: number | "anchor"; - /** Height of the panel's non-scrolling chrome (search row, borders…). */ + /** The height of the panel's parts that don't scroll, borders included. */ chromeHeight: number; - /** The least height of the panel's scrolling part. */ minContentHeight: number; - /** The most height of the panel's scrolling part. */ maxContentHeight: number; } /** - * Returns the style that positions a floating panel against `anchorRef` with - * `position: fixed`, so no ancestor's `overflow: hidden` / `overflow: auto` can - * clip it — cards, modal bodies and scrollers all cut off an absolutely - * positioned popup. + * Returns the style that places a panel above or below `anchorRef`, whichever + * has more room, kept inside the viewport and following scrolls and resizes. + * The panel must be portaled out of any clipping ancestor, and its scrolling + * part must take its height from the `--float-max-h` property of the style. * - * Flips to whichever side of the anchor has the most room, clamps horizontally - * to the viewport, and publishes the room left for the scrolling part as the - * `--float-max-h` custom property. - * - * Returns `null` while not `open`; the first value lands in a layout effect, so - * the panel is never painted at the wrong spot. + * It returns null while `open` is false, and also on the first render after + * opening, until the panel is measured. The caller hides the panel meanwhile. */ export function useAnchoredFloat( anchorRef: RefObject<HTMLElement | null>, @@ -62,9 +61,9 @@ export function useAnchoredFloat( const vw = globalThis.innerWidth; const vh = globalThis.innerHeight; - // Clamp the anchor's edges into the viewport first: an auto-growing - // textarea can be taller than the screen, and an off-screen edge would - // otherwise put the panel out of reach. + // The anchor's edges are clamped into the viewport first: an + // auto-growing textarea can be taller than the screen, and an off-screen + // edge would put the panel out of reach. const anchorTop = Math.min(Math.max(r.top, MARGIN), vh - MARGIN); const anchorBottom = Math.min(Math.max(r.bottom, MARGIN), vh - MARGIN); const roomAbove = anchorTop - GAP - MARGIN; @@ -81,8 +80,8 @@ export function useAnchoredFloat( ), ); - // Anchoring by `bottom` when above means the panel's own height never - // enters the computation, so it can't jump as the list loads. + // Placing it by `bottom` when above keeps the panel's own height out of + // the computation, so it can't jump as the list loads. const next: CSSProperties & Record<string, string | number> = { position: "fixed", width: w, @@ -103,7 +102,7 @@ export function useAnchoredFloat( }; place(); - // `true` catches scrolling in any ancestor scroller, not just the window. + // The capture phase catches a scroll in any ancestor, not just the window. globalThis.addEventListener("scroll", place, true); globalThis.addEventListener("resize", place); // The anchor itself moves when an auto-resizing textarea grows. @@ -123,7 +122,7 @@ export function useAnchoredFloat( maxContentHeight, ]); - // Gated on `open` rather than cleared on close: the stale value is never - // painted, since the layout effect re-places the panel before the next paint. + // Gated on `open` rather than cleared on close: on reopening, the stale + // value is never painted, since the layout effect re-places the panel first. return open ? style : null; } diff --git a/src/hooks/shared/useDocumentTitle.ts b/src/hooks/shared/useDocumentTitle.ts index ff264c0b..056ad599 100644 --- a/src/hooks/shared/useDocumentTitle.ts +++ b/src/hooks/shared/useDocumentTitle.ts @@ -1,5 +1,7 @@ /** - * The tab's title: the page's, the site's name, and an unread badge. + * Sets the browser tab's title from the page's title, the site's name and the + * unread count. Every page calls {@link useDocumentTitle}, and `WSProvider` + * calls {@link setTitleBadge}. * * @module */ @@ -7,10 +9,9 @@ import { useEffect } from "react"; /** - * Returns the site's name. The server injects the configured site name into - * both <title> and a `site-name` meta tag (see api/lib/static.ts / - * api/middleware/og.ts). In the Vite dev server the placeholder isn't - * substituted, so it falls back to "gerbeur". + * Returns the site's name, from the `site-name` meta tag that + * api/lib/index-html.ts fills in. The Vite dev server leaves the placeholder, + * so it falls back to "gerbeur". */ function readSiteName(): string { const meta = document.querySelector('meta[name="site-name"]') @@ -19,12 +20,12 @@ function readSiteName(): string { return "gerbeur"; } -/** The site's name, read once at startup. */ +/** The site's name, read once at startup, as the header shows it. */ export const SITE_NAME: string = readSiteName(); /** - * Returns the site's emoji. The server injects the configured emoji into a - * `site-emoji` meta tag; it falls back to the default truck. + * Returns the site's emoji, from the `site-emoji` meta tag that the server + * fills in, or the default truck when it is left unfilled. */ function readSiteEmoji(): string { const meta = document.querySelector('meta[name="site-emoji"]') @@ -33,35 +34,25 @@ function readSiteEmoji(): string { return "🚚"; } -/** The site's emoji, read once at startup. */ +/** The site's emoji, read once at startup, as the header shows it. */ export const SITE_EMOJI: string = readSiteEmoji(); -/** - * The page's title and the site's name. The page title and the unread badge are - * set from different places (each page, and the live-updates provider), so both - * are kept here and composed. `null` means no page has set one yet: keep the - * server-rendered title. - */ +// The page and `WSProvider` set these apart, so both are kept here and +// composed. A null `baseTitle` keeps the title the server rendered. let baseTitle: string | null = null; -/** The unread count. */ let badge = 0; -/** Sets the tab's title from {@link baseTitle} and {@link badge}. */ function applyTitle() { const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, ""); document.title = badge > 0 ? `(${badge > 99 ? "99+" : badge}) ${base}` : base; } /** - * Keeps document.title in sync with the current page during client-side - * navigation. Without this the tab title only ever reflects the server-rendered - * <title> from the initial page load and goes stale on every SPA navigation. - * - * - Pass a non-empty `title` to show `<title> · <site name>`. - * - Pass `null` to show just the site name (home / generic pages). - * - Pass `undefined` while the page-specific title is still loading: the title - * is left untouched, so a server-rendered OG title survives until data - * arrives (avoids a flash to the bare site name on direct loads). + * Sets the tab's title to `title · site name` for the page that calls it, since + * a client-side navigation leaves the server's `<title>` in place. A page + * passes null for the bare site name, and undefined while its data loads, + * which keeps the title the server rendered for that URL rather than flashing + * the site name. */ export function useDocumentTitle(title: string | null | undefined): void { useEffect(() => { @@ -72,7 +63,8 @@ export function useDocumentTitle(title: string | null | undefined): void { } /** - * Prefixes the tab title with the unread `count`, e.g. "(3) Chat · gerbeur". + * Prefixes the tab's title with the unread `count`, as in "(3) Chat · gerbeur", + * or removes the prefix at 0. Past 99 it shows "99+". */ export function setTitleBadge(count: number): void { if (count === badge) return; diff --git a/src/hooks/shared/useHeightTransition.ts b/src/hooks/shared/useHeightTransition.ts index c1d4ca25..b9a19e38 100644 --- a/src/hooks/shared/useHeightTransition.ts +++ b/src/hooks/shared/useHeightTransition.ts @@ -1,30 +1,29 @@ /** - * Easing an element's height when its content swaps. + * Eases an element's height when its content swaps, for the pages and cards + * that open an edit form or a reply box in place. * * @module */ import { useCallback, useLayoutEffect, useRef } from "react"; -/** The duration when `--dur-reveal` isn't set, in ms. */ +/** The duration when `--dur-reveal` isn't set, in milliseconds. */ const FALLBACK_MS = 250; /** - * Eases an element's height whenever `watch` changes, so an interaction that - * swaps part of its content (a view for an edit form, a reply box opening…) - * moves the surrounding layout smoothly instead of jumping. - * - * Returns a callback ref for the element whose height should transition. Only - * the box is animated; give the content that appears the `animate-in` class so - * it fades in as the box grows. Duration follows `--dur-reveal`. + * Returns a callback ref for the element whose height eases whenever `watch` + * changes, usually the flag that swaps its content. Only the box is animated, + * so the content that appears should take the `animate-in` class to fade in. + * The duration follows `--dur-reveal`, and nothing moves under + * `prefers-reduced-motion`. */ export function useHeightTransition<T extends HTMLElement>( watch: unknown, ): (el: T | null) => (() => void) | undefined { const elRef = useRef<T | null>(null); - // Last rendered height, kept current through a ResizeObserver so that by - // the time the swap commits we still know where the box *was* — including - // mid-animation, when a second toggle should start from the current frame. + // Kept current by a ResizeObserver, so that when the swap commits the box's + // previous height is still known, even mid-animation, where a second toggle + // must start from the current frame. const lastHeight = useRef<number | null>(null); const lastWatch = useRef(watch); diff --git a/src/hooks/shared/useRoomBeside.ts b/src/hooks/shared/useRoomBeside.ts index b5d721d4..e3dfa0ae 100644 --- a/src/hooks/shared/useRoomBeside.ts +++ b/src/hooks/shared/useRoomBeside.ts @@ -1,31 +1,33 @@ /** - * Measuring the width an element has beside another in a shared row. + * Measures the width an element has beside another in a shared row, for the + * presence avatars in the feed's header (`PresenceRow`). * * @module */ import { useLayoutEffect, useState } from "react"; -/** Where {@link useRoomBeside} measures. */ +/** + * Passed to {@link useRoomBeside} by `PresenceRow`, from its own props. Without + * both selectors, the hook measures nothing and returns null. + */ export interface RoomBesideOptions { - /** An ancestor selector: what bounds the row. */ + /** Selects the ancestor that bounds the row. */ within?: string; - /** A selector inside it: what comes first. */ + /** Selects, inside that ancestor, the element that takes its room first. */ beside?: string; } /** - * Returns how much width `el` has to itself in a row it shares with something - * that takes precedence — in the feed's header, how much the tabs leave the - * presence avatars. `within` is what bounds the row, `beside` what comes first, - * measured at its content's natural width because how wide the tabs run depends - * on the theme's type and on the locale. The row's gap between the two is taken - * off too. + * Returns the width `el` has to itself in a row it shares with an element that + * comes first, such as the tabs that leave room for the presence avatars. The + * first element counts at its content's natural width, since that depends on + * the theme's fonts and the locale, and the row's gap is taken off too. It + * returns null until measured. * - * Takes the element itself (from a callback ref) rather than a ref object, so - * the measuring starts whenever it mounts. Its own width doesn't enter into it, - * so it can size itself to the answer without feeding back into the next one. - * Null until measured. + * It takes the element itself, from a callback ref, so that the measuring + * starts whenever it mounts. The element's own width doesn't enter into it, so + * it can size itself to the answer without feedback. */ export function useRoomBeside( el: HTMLElement | null, @@ -47,9 +49,9 @@ export function useRoomBeside( setRoom(Math.floor(width - gap - (kept ? contentWidth(kept) : 0))); } - // The container for its width; the tabs themselves, since they change - // width without their box changing (a web font arriving, a tab added at - // login) — re-bound when they come and go. + // The tabs are observed too, since their width changes without their + // box's (a web font arriving, a tab added at sign-in), and re-observed as + // they come and go. const resize = new ResizeObserver(measure); const mutation = new MutationObserver(observe); function observe() { @@ -74,10 +76,10 @@ export function useRoomBeside( } /** - * Returns how wide the content of `el` runs, from its first child to its last - * plus its padding — neither its box, which a flex row may squeeze (the tabs - * then scroll) or stretch (filling the row under the header), nor its - * scrollWidth, which is at least the box. + * Returns how wide the content of `el` runs, from its first child to its last, + * plus its padding. Its box won't do, since a flex row may squeeze it (the tabs + * then scroll) or stretch it, and neither will its scrollWidth, which is at + * least the box. */ function contentWidth(el: HTMLElement): number { const first = el.firstElementChild, last = el.lastElementChild; diff --git a/src/hooks/shared/useScrollLock.ts b/src/hooks/shared/useScrollLock.ts index fdf3344f..043f04e8 100644 --- a/src/hooks/shared/useScrollLock.ts +++ b/src/hooks/shared/useScrollLock.ts @@ -1,28 +1,25 @@ /** - * Freezing the page's scroll under a modal. + * Freezes the page's scroll under `Modal` and `ConfirmModal`. * * @module */ import { useEffect } from "react"; -/** How many components hold the lock. */ +// Counted, so that a confirmation over a modal doesn't release the page when +// it closes first. let locks = 0; /** - * Freezes the page's scroll while the calling component is mounted — for - * modals, whose backdrop shouldn't let the feed slide underneath. - * - * Hiding the viewport's overflow drops its scrollbar, which would widen the - * page and shift everything behind the backdrop. If a bar is showing, its - * gutter is held for the duration of the lock instead, so the layout stays put; - * no gutter is reserved the rest of the time. Locks are counted so a confirm - * stacked on a modal doesn't release the page when it closes first. + * Freezes the page's scroll while the calling component is mounted, so that + * the page doesn't slide under a dialog's backdrop. Nested locks are counted. */ export function useScrollLock(): void { useEffect(() => { const root = document.documentElement; if (locks++ === 0) { + // Hiding the overflow drops the scrollbar, which would widen the page + // and shift it. A showing bar's gutter is held for the lock instead. if (globalThis.innerWidth > root.clientWidth) { root.style.scrollbarGutter = "stable"; } diff --git a/src/hooks/shared/useSoundsEnabled.ts b/src/hooks/shared/useSoundsEnabled.ts index 4ab11be7..79000f4e 100644 --- a/src/hooks/shared/useSoundsEnabled.ts +++ b/src/hooks/shared/useSoundsEnabled.ts @@ -1,5 +1,6 @@ /** - * The sounds setting. + * Gives the profile page's settings the toggle for notification and chat + * sounds. * * @module */ @@ -11,9 +12,10 @@ import { } from "../../utils/shared/sound.ts"; /** - * Returns the user's preference for notification and chat sounds, persisted to - * localStorage (on by default), and its setter. The chime itself reads the - * stored value at play time, so this hook only drives the settings toggle. + * Returns whether sounds are on in this browser, and a setter that stores the + * choice. `playChime` reads the stored value each time it plays, so the choice + * applies at once without this hook, and another mounted copy of it isn't + * updated. */ export function useSoundsEnabled(): [boolean, (enabled: boolean) => void] { const [enabled, setEnabledState] = useState(readSoundsEnabled); diff --git a/src/hooks/shared/useTabParam.ts b/src/hooks/shared/useTabParam.ts index e420fe83..f0e189ab 100644 --- a/src/hooks/shared/useTabParam.ts +++ b/src/hooks/shared/useTabParam.ts @@ -1,5 +1,5 @@ /** - * The active tab, kept in the URL. + * Keeps a page's active tab in its URL, for the profile and search pages. * * @module */ @@ -7,20 +7,16 @@ import { useCallback } from "react"; import { useLocation, useNavigate } from "react-router"; -/** What separates the base path from the tab. */ +/** Separates the page's path from the tab, as App.tsx's routes expect. */ const SEP = "/~/"; /** - * Returns the active tab, one of `validTabs` (falling back to `defaultTab`), - * and a setter that navigates to the matching path (replacing history). - * - * The tab is reflected in the URL path using a `/~/` delimiter, e.g. - * `/users/vincent/~/playlists` or `/~/new`. The segment before `/~/` is the - * base path and the segment after is the tab. When the base is non-empty the - * default tab collapses to the clean base path (`/users/vincent`); at the root - * the base is empty, so the default stays explicit (`/~/hot`). Generic and - * path-agnostic: the base is derived from the current location, so the same - * hook works for any route. + * Returns the tab named after `/~/` in the current path, or `defaultTab` when + * there is none or it isn't in `validTabs`, and a setter that navigates to a + * tab in place of the current history entry. The page's route must take both + * `<path>` and `<path>/~/:tab`, as `/users/:username/~/:profileTab` does. The + * default tab's URL is the bare path, except at the root, where it stays + * explicit. */ export function useTabParam<T extends string>( validTabs: Iterable<T>, @@ -34,7 +30,8 @@ export function useTabParam<T extends string>( const sepAt = path.indexOf(SEP); let base = sepAt === -1 ? path : path.slice(0, sepAt); - if (base.endsWith("/")) base = base.slice(0, -1); // root "/" → "" + // The root "/" becomes "". + if (base.endsWith("/")) base = base.slice(0, -1); const raw = sepAt === -1 ? null @@ -44,9 +41,9 @@ export function useTabParam<T extends string>( const setTab = useCallback( (t: T) => { const target = t === defaultTab && base ? base : `${base}${SEP}${t}`; - // Preserve any unrelated query string (e.g. the search page's `?q=`), - // and the entry's state along with it — this is the same entry, and the - // header's search reads where to go back to from there. + // Keeps the query string (the search page's `?q=`) and the entry's + // state, since this is the same entry and `AppHeader`'s search reads + // where to go back to from it. navigate(`${target}${location.search}`, { replace: true, state: location.state, diff --git a/src/hooks/social/useFollows.ts b/src/hooks/social/useFollows.ts index b763ef22..27b01c53 100644 --- a/src/hooks/social/useFollows.ts +++ b/src/hooks/social/useFollows.ts @@ -1,5 +1,5 @@ /** - * The signed-in user's follows, from their context. + * Reads `FollowContext`, for the Follow buttons. * * @module */ @@ -10,7 +10,11 @@ import { type FollowContextValue, } from "../../contexts/social/FollowContext.ts"; -/** Returns whom and what the signed-in user follows, and a way to change it. */ +/** + * Returns the users and playlists the signed-in user follows, and the + * functions that follow and unfollow them. Needs `FollowProvider` above, which + * `App` mounts around every page. + */ export function useFollows(): FollowContextValue { return useContext(FollowContext); } diff --git a/src/hooks/theme/useTheme.ts b/src/hooks/theme/useTheme.ts index d286f411..98f7a458 100644 --- a/src/hooks/theme/useTheme.ts +++ b/src/hooks/theme/useTheme.ts @@ -1,5 +1,5 @@ /** - * The theme, from its context. + * Gives components the theme that `ThemeProvider` holds. * * @module */ @@ -10,7 +10,10 @@ import { type ThemeContextValue, } from "../../contexts/theme/ThemeContext.ts"; -/** Returns the style and color scheme, and a way to change them. */ +/** + * Returns the style and color scheme, and their setters. The profile page's + * settings are the only place that changes them. + */ export function useTheme(): ThemeContextValue { return useContext(ThemeContext); } diff --git a/src/hooks/ws/useWS.ts b/src/hooks/ws/useWS.ts index 41136e17..b8e9e404 100644 --- a/src/hooks/ws/useWS.ts +++ b/src/hooks/ws/useWS.ts @@ -1,5 +1,7 @@ /** - * The socket's live state, from its three contexts. + * Reads the live state that `WSProvider` keeps from the WebSocket. Each hook + * reads one of its three contexts, so that a component re-renders only with + * the part it uses. Outside `WSProvider`, they return inert defaults. * * @module */ @@ -15,18 +17,24 @@ import { } from "../../contexts/ws/WSContext.ts"; /** - * Returns the votes, likes, live dumps, playlists, comments and notifications. + * Returns the connection's status, the live votes, likes, dumps, playlists, + * comments and notifications, and the functions that vote and like. A + * component using it re-renders on every one of those events. */ export function useWS(): WSContextValue { return useContext(WSContext); } -/** Returns the chat room. Re-renders with every message and typing change. */ +/** + * Returns the chat's messages, typing and read state. A component using it + * re-renders with every message and typing change, so a component that needs + * only the unread count still pays for them. + */ export function useWSChat(): WSChatContextValue { return useContext(WSChatContext); } -/** Returns who's online. */ +/** Returns who is online, for the presence row. */ export function useWSPresence(): WSPresenceContextValue { return useContext(WSPresenceContext); } diff --git a/src/i18n.ts b/src/i18n.ts index 7f1b1635..7a0515aa 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,13 +1,14 @@ /** - * The interface's language: the locales, the one to use, and loading its - * messages into Lingui. + * Picks the interface's language and loads its messages into Lingui's global + * `i18n`, which the components' `t` and `Trans` read. `main.tsx` calls it once + * before rendering. * * @module */ import { i18n } from "@lingui/core"; -/** The locales the interface is translated to. */ +/** The locales the interface is translated to, one `.po` file each. */ export const SUPPORTED: readonly ["en", "fr"] = ["en", "fr"] as const; /** One of the {@link SUPPORTED} locales. */ export type Locale = (typeof SUPPORTED)[number]; @@ -24,8 +25,9 @@ function detectLocale(): Locale { } /** - * Loads the messages of `locale` (the detected one by default), activates it - * and stores it. + * Loads the messages of `locale`, the detected one by default, then activates + * it and stores it for the next visit. There is no language switch in the + * interface yet, so the stored one comes from the browser's language. */ export async function loadCatalog( locale: Locale = detectLocale(), diff --git a/src/main.tsx b/src/main.tsx index ec489cc0..8292af8b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,11 @@ /** - * The frontend's entry point: the stylesheets, the language, and the app - * mounted on `#root`. + * Starts the frontend. It loads the stylesheets and the interface's language, + * then mounts `App` on index.html's `#root`. * * A deploy renames every chunk, so a tab opened before it fails to load the - * lazy ones it hasn't fetched yet. It reloads to pick up the new build, but - * only once a minute, so a chunk that's really missing shows its error instead - * of reloading forever. + * lazy chunks it hasn't fetched yet. It then reloads to pick up the new build, + * but at most once a minute, so a chunk that's really missing shows its error + * instead of reloading forever. * * @module */ @@ -28,7 +28,7 @@ import "./themes/nyt.css"; import "./themes/kanagawa.css"; import "./themes/melange.css"; -// After a deploy, reload once a minute at most (see the module doc). +// Without sessionStorage, the reload can't be limited, so it doesn't happen. globalThis.addEventListener("vite:preloadError", (event) => { const RELOADED_AT = "chunk-reload-at"; try { @@ -42,6 +42,7 @@ globalThis.addEventListener("vite:preloadError", (event) => { location.reload(); }); +// Before the first render, or it would show untranslated message ids. await loadCatalog(); createRoot(document.getElementById("root")!).render( diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 0655dc5d..f2d22700 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -1,5 +1,6 @@ /** - * A dump's page. + * The page of a dump, with its preview, comments, related dumps and the + * playlists holding it. * * @module */ @@ -46,17 +47,19 @@ import { Markdown } from "../components/editor/Markdown.tsx"; import { CommentThread } from "../components/comment/CommentThread.tsx"; import { Tooltip } from "../components/shared/Tooltip.tsx"; -/** The page's state: loading, failed, or loaded with the dump. */ type DumpState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dump: Dump }; /** - * Renders `/dumps/:selectedDump`, for anyone who may see the dump: its preview, - * title (editable in place by its poster), note, poster, categories and votes, - * the playlists holding it, related dumps and the comment thread, kept live by - * the socket. + * Renders `/dumps/:selectedDump`, for anyone who may see the dump: a private + * dump answers 404 to anyone but its poster. The dump comes from + * `GET /api/dumps/:dumpId`, or from the navigation's state when `DumpEdit` or + * a title edit here just saved it. Its comments, related dumps and playlists + * come from their own endpoints, and `WSContext`'s events keep them current. + * The poster and moderators can edit the title in place, and get a link to + * `DumpEdit`. */ export function Dump(): React.JSX.Element { const { selectedDump } = useParams(); @@ -146,8 +149,7 @@ export function Dump(): React.JSX.Element { useEffect(() => { if (!lastDumpEvent) return; - // Subscribing to the WS-pushed dump-update stream — setState here mirrors - // external state into local state, no synchronous alternative. + // Mirrors the socket's edits, which only an effect can observe. // eslint-disable-next-line react-hooks/set-state-in-effect setDumpState((prev) => { if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) { @@ -157,7 +159,6 @@ export function Dump(): React.JSX.Element { }); }, [lastDumpEvent]); - // Fetch comments when dump loads useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); @@ -172,11 +173,11 @@ export function Dump(): React.JSX.Element { }) .catch(() => {}); return () => controller.abort(); - // connectionEpoch: re-fetch on reconnect to backfill comments missed while - // the socket was down. + // connectionEpoch refetches on reconnect, for the comments missed while the + // socket was down. }, [selectedDump, authFetch, connectionEpoch]); - // Fetch related dumps (backlinks) when dump loads + // The dumps that link to this one, or that it links to. useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); @@ -193,8 +194,7 @@ export function Dump(): React.JSX.Element { return () => controller.abort(); }, [selectedDump, authFetch]); - // Fetch the collections this dump appears in (public ones, plus the - // viewer's own private ones — the server does the filtering) + // The server leaves out the private playlists of others. useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); @@ -211,12 +211,12 @@ export function Dump(): React.JSX.Element { }) .catch(() => {}); return () => controller.abort(); - // lastPlaylistEvent: adding/removing this dump, flipping a collection's - // visibility or deleting it all change the list — playlist events are rare - // enough to just refetch on any of them. + // Any playlist event may change the list, and they are rare enough to + // refetch on each. }, [selectedDump, authFetch, lastPlaylistEvent]); - // Scroll to and highlight a comment when navigating to #comment-{id} + // A notification links to `#comment-<id>`. Rerun when the comments arrive, + // since the element doesn't exist before. useEffect(() => { if (!location.hash.startsWith("#comment-")) return; const id = location.hash.slice(1); @@ -231,15 +231,14 @@ export function Dump(): React.JSX.Element { return () => clearTimeout(timer); }, [comments, location.hash]); - // React to WS comment events const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null; useEffect(() => { if ( !lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId ) return; - // Subscribing to the WS-pushed comment-event stream — setState here - // mirrors external state into local state, no synchronous alternative. + // A comment of this user's arrives both from the socket and from + // CommentThread's callbacks below, hence the duplicate checks. if (lastCommentEvent.type === "created" && lastCommentEvent.comment) { // eslint-disable-next-line react-hooks/set-state-in-effect setComments((prev) => { @@ -338,8 +337,8 @@ export function Dump(): React.JSX.Element { } const updatedDump = deserializeDump(apiResponse.data); setTitleEditing(false); - // The page stays mounted across the slug change below, so it has to be - // told about the new title itself. + // The page stays mounted across the slug change below, and would keep + // the old title. setDumpState({ status: "loaded", dump: updatedDump }); if (updatedDump.slug !== dump.slug) { navigate(dumpUrl(updatedDump), { @@ -357,7 +356,6 @@ export function Dump(): React.JSX.Element { return ( <PageShell> <div className="dump-detail"> - {/* Post header */} <div className="dump-post-header"> <div className="dump-header-block"> <div className="dump-header-left"> @@ -506,7 +504,6 @@ export function Dump(): React.JSX.Element { )} </div> - {/* Main content */} <div className="dump-rich-content"> {dump.kind === "file" ? <FilePreview dump={dump} global /> @@ -532,7 +529,6 @@ export function Dump(): React.JSX.Element { )} </div> - {/* Actions */} <div className="dump-actions"> {canEdit && ( <Link to={`${dumpUrl(dump)}/edit`}> @@ -544,7 +540,6 @@ export function Dump(): React.JSX.Element { </Link> </div> - {/* Related (backlinks) */} {relatedDumps.length > 0 && ( <section className="related-section"> <h2 className="related-section-title"> @@ -566,7 +561,6 @@ export function Dump(): React.JSX.Element { </section> )} - {/* In collections (playlists containing this dump) */} {collections.length > 0 && ( <section className="related-section collections-section"> <h2 className="related-section-title"> @@ -580,7 +574,6 @@ export function Dump(): React.JSX.Element { </section> )} - {/* Comments */} <CommentThread dumpId={dump.id} comments={comments} diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 3f8062a9..90c68a06 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -1,5 +1,5 @@ /** - * The dump editor. + * The page where a dump's poster, or a moderator, edits or deletes it. * * @module */ @@ -44,16 +44,18 @@ import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; import { VisibilityToggle } from "../components/form/SegmentedField.tsx"; -/** The page's state: loading, failed, or loaded with the dump. */ type DumpEditState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dump: Dump }; /** - * Renders `/dumps/:selectedDump/edit`, for a signed-in user: the dump's title, - * link or file, note, visibility and thumbnail to edit, its preview to refresh, - * and a delete button. + * Renders `/dumps/:selectedDump/edit`, for a signed-in user. The page itself + * doesn't check that the user may edit the dump: `Dump` links here only for + * its poster and moderators, and the API refuses anyone else's changes. The + * dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh + * and the deletion apply at once, and the form's other fields on "Save", which + * goes back to the dump's page. A deletion goes to the index. */ export function DumpEdit(): React.JSX.Element { const { selectedDump } = useParams(); @@ -171,6 +173,8 @@ export function DumpEdit(): React.JSX.Element { setDeleteError(friendlyFetchError(err)); return; } + // Meant for Index, to hide the dump before `dump_deleted` arrives, but the + // redirect from `/` doesn't pass the state on. navigate("/", { state: { deletedDumpId: state.dump.id } }); }; @@ -275,6 +279,8 @@ export function DumpEdit(): React.JSX.Element { onThumbnailChange={handleThumbnailChange} onThumbnailReset={handleThumbnailReset} onRequestDelete={() => setConfirmDelete(true)} + // Passed along so the dump's page shows the saved dump without + // refetching it. onSaved={(updated) => navigate(dumpUrl(updated), { state: { dump: updated } })} /> @@ -291,7 +297,8 @@ export function DumpEdit(): React.JSX.Element { ); } -/** The props of {@link DumpEditForm}. */ +// The thumbnail and deletion are handled by DumpEdit, which applies them at +// once; the form only reports them. interface DumpEditFormProps { dump: Dump; preview: React.ReactNode; @@ -305,7 +312,6 @@ interface DumpEditFormProps { onSaved: (dump: Dump) => void; } -/** The form's fields. */ interface EditValues { title: string; url: string; @@ -314,7 +320,11 @@ interface EditValues { newFile: File | null; } -/** Renders the dump's edit form. */ +/** + * Renders the edit form of `dump`, which saves through + * `PUT /api/dumps/:dumpId`, or `PUT /api/dumps/:dumpId/file` when a new file + * is attached. `DumpEdit` keys it by dump, so the fields reset for another. + */ function DumpEditForm({ dump, preview, @@ -348,7 +358,7 @@ function DumpEditForm({ }, }); - // Metadata-extracted title for URL dumps; the source for "Reset to default". + // The title scraped from the link, which "Reset to default" puts back. const metaTitle = dump.richContent?.title?.trim() ?? ""; const currentTitle = form.watch("title"); @@ -404,8 +414,8 @@ function DumpEditForm({ <FormProvider {...form}> <form className="form dump-edit-form" onSubmit={onSubmit}> { - /* Left: the dumped thing itself — what it looks like and where it - comes from. Right: how it's presented and shared. */ + /* The source panel holds what was dumped, the details panel how it + is presented and who sees it. */ } <div className="dump-edit-panel dump-edit-panel--source"> {preview} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 242bc27c..c669a8cf 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,5 +1,6 @@ /** - * The feed page. + * Renders the home page, which shows every dump, or one category's, in the + * "hot", "new", "journal" and "followed" tabs. * * @module */ @@ -45,7 +46,6 @@ import { JournalFeed } from "../components/feed/JournalFeed.tsx"; import { FollowedFeed } from "../components/feed/FollowedFeed.tsx"; import { NotFound } from "./NotFound.tsx"; -/** The feed's state: loading, failed, or loaded with a page of dumps. */ type DumpsState = | { status: "loading" } | { status: "error"; error: string } @@ -57,19 +57,23 @@ type DumpsState = loadingMore: boolean; }; -/** The props of {@link Index}. */ +/** + * Passed to {@link Index} by the router, from the `:categorySlug` of the URL. + */ export interface IndexProps { - /** The category the feed is scoped to; every dump when undefined. */ + /** Without it, the feed holds every dump. An unknown slug renders 404. */ categorySlug?: string; } /** - * Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone: the shared - * feed page, its tabs (hot, new, journal, followed for a signed-in user), who's - * online, and new dumps as they arrive. `categorySlug` scopes it to a single - * category; when absent it shows every dump. The router keys this component by - * scope, so each category gets its own mount/fetch while switching feed tabs - * reuses it. + * Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the + * "followed" tab for a signed-in user only. The feed comes from + * `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time, + * and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps + * posted since, the live vote counts, and who's online. The router keys the + * page by category, so switching tabs keeps the feed and switching categories + * refetches it. It also opens the dump creation modal on a link shared from + * Android (`?share_url=`). */ export function Index( { categorySlug }: IndexProps, @@ -89,7 +93,6 @@ export function Index( connectionEpoch, } = useWS(); - // Resolve the scope to a real category (for its id, name, and 404 handling). const { categories, isLoaded: categoriesLoaded } = useCategories(); const category = categorySlug ? categories.find((c) => c.slug === categorySlug) @@ -100,23 +103,19 @@ export function Index( categorySlug ? (category ? category.name : undefined) : null, ); - // ── Active feed tab (from the URL) ── - const { feedTab: rawTab } = useParams(); const [preferredTab] = useDefaultFeedTab(); const defaultTab: FeedTab = preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; - // "followed" needs a session: a guest there (a shared link, or signed out - // on it) gets the default feed rather than an empty page. + // A guest on "followed", from a shared link or after signing out, gets the + // default tab rather than an empty page. const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") && (rawTab !== "followed" || !!user) ? (rawTab as FeedTab) : defaultTab; - // ── Main feed state ── - const feedUrl = useCallback( (page: number) => categorySlug @@ -144,7 +143,9 @@ export function Index( ); const mainFetchDone = useRef(false); - // Web Share Target: Android share sheet navigates to /?share_url=... + // The Web Share Target: Android's share sheet opens `/?share_url=…`, which + // the redirect from `/` keeps. The query is dropped from the URL at once, so + // a reload doesn't share the link a second time. const searchParams = new URLSearchParams(location.search); const shareUrl = searchParams.get("share_url") ?? searchParams.get("share_text") ?? ""; @@ -154,8 +155,6 @@ export function Index( globalThis.history.replaceState({}, "", location.pathname); }, [shareUrl, location.pathname]); - // ── Main feed fetch ── - useEffect(() => { if (mainFetchDone.current || cached) return; mainFetchDone.current = true; @@ -187,8 +186,6 @@ export function Index( }; }, [cached, token, feedUrl]); - // ── WS sync for main feed ── - const setDumpsItems = useCallback( (fn: (prev: Dump[]) => Dump[]) => setDumpsState((s) => @@ -198,11 +195,9 @@ export function Index( ); useDumpListSync(setDumpsItems); - // ── Reconnect backfill ── - - // New dumps that arrived while the socket was down weren't delivered. On - // reconnect, re-fetch page 1 and merge anything not already present on top, - // preserving loaded pages and scroll position. + // Dumps posted while the socket was down never arrived. On reconnect, the + // first page is fetched again and whatever is new goes on top, keeping the + // pages loaded and the scroll position. const backfillEpochRef = useRef(connectionEpoch); useEffect(() => { if (connectionEpoch === backfillEpochRef.current) return; @@ -228,8 +223,6 @@ export function Index( return () => controller.abort(); }, [connectionEpoch, feedUrl, token]); - // ── Load more ── - const loadMore = useCallback(() => { if ( dumpsState.status !== "loaded" || !dumpsState.hasMore || @@ -264,8 +257,6 @@ export function Index( ); }, [dumpsState, token, feedUrl]); - // ── Scroll save / restore ── - const sentinelRef = useInfiniteScroll( loadMore, dumpsState.status === "loaded" && dumpsState.hasMore && @@ -289,8 +280,6 @@ export function Index( } }, [dumpsState.status, cached]); - // ── Derived values ── - const loading = dumpsState.status === "loading"; const error = dumpsState.status === "error" ? dumpsState.error : null; const dumps = useMemo( @@ -300,7 +289,7 @@ export function Index( const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore; const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false; - // New dumps arriving over the WS only belong in this feed if they're in scope. + // A category's feed takes only the new dumps filed under it. const categoryId = category?.id; const inScope = useCallback( (d: Dump) => @@ -311,6 +300,8 @@ export function Index( ); const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]); + // Dumps deleted since loading are dropped. `justDeletedId` never matches, + // though: DumpEdit navigates to `/`, whose redirect drops the state. const combined = useMemo( () => [...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps] @@ -332,8 +323,6 @@ export function Index( removeVote, }; - // ── Render ── - if (categoryNotFound) return <NotFound />; return ( diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index dc4ca3b3..d3dfbbe9 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,5 +1,6 @@ /** - * The page for a route that doesn't exist. + * Renders the 404 page, for any path `App`'s route table doesn't match and for + * a category slug `Index` doesn't know. * * @module */ @@ -10,7 +11,7 @@ import { Trans } from "@lingui/react/macro"; import { PageShell } from "../components/layout/PageShell.tsx"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; -/** Renders any unknown route: a 404 with a link home. */ +/** Renders the 404 page, with a link home. Anyone may see it. */ export function NotFound(): React.JSX.Element { useDocumentTitle(t`Page not found`); return ( diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index abd6f612..ed263920 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -1,5 +1,7 @@ /** - * The notifications page. + * Renders the notifications page, `/notifications`, for a signed-in user. It + * reads them from `GET /api/notifications` and adds the ones `WSContext` + * receives while it is open. * * @module */ @@ -33,12 +35,9 @@ import { deserializeNotification } from "../utils/shared/deserialize.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { expectOk, friendlyFetchError } from "../utils/shared/api.ts"; -/** - * The page's state: loading, failed, or loaded with a page of notifications. - */ type State = - // `arrived`: sent over the socket before the list came back — the list may - // predate them, and the read-all that follows the load covers them anyway. + // `arrived` holds what the socket sent before the list came back, which the + // list may predate. | { status: "loading"; arrived: Notification[] } | { status: "error"; error: string } | { @@ -49,7 +48,6 @@ type State = loadingMore: boolean; }; -/** The icon a notification shows. */ type NotifIconKind = | "upvote" | "follow" @@ -59,7 +57,6 @@ type NotifIconKind = | "comment" | "like"; -/** Returns the icon for a notification of `type`. */ function notifIconKind(type: Notification["type"]): NotifIconKind { switch (type) { case "dump_upvoted": @@ -81,28 +78,24 @@ function notifIconKind(type: Notification["type"]): NotifIconKind { } } -/** Renders the upvote glyph. */ const UpvoteSvg = () => ( <svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor"> <polygon points="5,1 9,9 1,9" /> </svg> ); -/** Renders the follow glyph. */ const FollowSvg = () => ( <svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor"> <polygon points="2,1 9,5 2,9" /> </svg> ); -/** Renders the like glyph. */ const HeartSvg = () => ( <svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor"> <path d="M5 9 C2 6.5 0 5 0 2.8 C0 1.2 1.2 0 2.6 0 C3.6 0 4.5 0.6 5 1.5 C5.5 0.6 6.4 0 7.4 0 C8.8 0 10 1.2 10 2.8 C10 5 8 6.5 5 9 Z" /> </svg> ); -/** Renders the icon of a notification of `type`. */ function NotifIcon({ type }: { type: Notification["type"] }) { const kind = notifIconKind(type); const glyphs: Record<NotifIconKind, React.ReactNode> = { @@ -121,7 +114,10 @@ function NotifIcon({ type }: { type: Notification["type"] }) { ); } -/** Returns where notification `n` leads. */ +/** + * Returns where notification `n` leads. A notification about a comment links + * to its `#comment-<id>` anchor on the dump's page. + */ function notificationLink(n: Notification): string { const data = n.data as NotificationData; switch (n.type) { @@ -149,14 +145,14 @@ function notificationLink(n: Notification): string { return `/dumps/${d.dumpId}#comment-${d.contextId}`; } if (d.contextType === "dump") return `/dumps/${d.contextId}`; - // Search only: the chat opens over whatever page you're on. + // A query string alone keeps this page, and `ChatProvider` opens the + // chat over it. if (d.contextType === "chat") return `?chat=open`; return `/playlists/${d.contextId}`; } } } -/** Returns the sentence notification `n` shows. */ function notificationContent(n: Notification): React.ReactNode { const data = n.data as NotificationData; switch (n.type) { @@ -248,7 +244,10 @@ function notificationContent(n: Notification): React.ReactNode { } } -/** Returns how long ago `date` was within a week, else its day. */ +/** + * Returns how long ago `date` was when it was within a week, else its day and + * month. + */ function timeAgo(date: Date): string { const abs = Math.abs(Date.now() - date.getTime()) / 1000; if (abs < 7 * 86400) return relativeTime(date); @@ -260,7 +259,10 @@ function startOfDay(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } -/** Returns `items` grouped under Today, Yesterday and Earlier. */ +/** + * Returns `items` grouped under Today, Yesterday and Earlier, in that order, + * without the empty groups. The labels are keys, which the page translates. + */ function groupByDate( items: Notification[], ): { label: string; items: Notification[] }[] { @@ -284,9 +286,9 @@ function groupByDate( } /** - * Renders `/notifications`, for a signed-in user: their notifications by day, - * new ones as they arrive, loading more on demand. Opening the page marks them - * all read. + * Renders `/notifications`, for a signed-in user. Opening the page marks every + * notification read on the server, and so does each that arrives while it is + * open. The unread ones stay highlighted until the page is left. */ export function Notifications(): React.JSX.Element { useDocumentTitle(t`Notifications`); @@ -326,20 +328,18 @@ export function Notifications(): React.JSX.Element { } return; } - // Marking them read is a separate matter: if it fails, the list that - // loaded is still worth showing, and they stay unread for next time. + // If this fails, the list is still worth showing, and they stay unread + // for next time. try { const res = await authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST", }); if (cancelled || !res.ok) return; - // Only the bell clears. The list keeps showing which ones were new - // when the page opened: flipping them to read here, a moment after - // they appear, would take the dots and the "new" count away before - // anyone could see them. + // Only the bell clears. Marking the list read too would take its dots + // and "new" count away a moment after they appear. clearUnreadNotifications(); } catch { - // Offline: see above. + // Offline: they stay unread for next time. } })(); return () => { @@ -347,6 +347,8 @@ export function Notifications(): React.JSX.Element { }; }, [authFetch, clearUnreadNotifications]); + // Each new `lastNotification` is added once, during render rather than in an + // effect, so it shows in the same frame. const [prevLastNotification, setPrevLastNotification] = useState( lastNotification, ); @@ -363,7 +365,7 @@ export function Notifications(): React.JSX.Element { } // One that arrives while the page is open is being seen: it stays - // highlighted here, but the bell shouldn't count it. + // highlighted here, but the bell must not count it. const loaded = state.status === "loaded"; useEffect(() => { if (!lastNotification || !loaded) return; diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 7d717941..10614cc8 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,5 +1,6 @@ /** - * A playlist's page. + * Renders a playlist's page, where its owner and moderators also edit it and + * reorder its dumps. * * @module */ @@ -53,7 +54,6 @@ import { FormError } from "../components/form/FormError.tsx"; import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; -/** The page's state: loading, failed, or loaded with the playlist. */ type LoadState = | { status: "loading" } | { status: "error"; error: string } @@ -61,9 +61,11 @@ type LoadState = /** * Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see - * the playlist: its cover, title, description and owner, a follow button, and - * its dumps, kept live by the socket. Its owner or a moderator can edit or - * delete it, drag its dumps into a new order, and remove one (with an undo). + * the playlist: a private one only to its owner. The playlist and the dumps + * the viewer may see in it come from `GET /api/playlists/:playlistId`, and the + * socket keeps them live. Its owner, or a user with `playlist:moderate`, can + * edit or delete it, drag its dumps into a new order, and remove one, with a + * short window to undo. */ export function PlaylistDetail(): React.JSX.Element { const { playlistId } = useParams<{ playlistId: string }>(); @@ -80,7 +82,7 @@ export function PlaylistDetail(): React.JSX.Element { } = useWS(); const [state, setState] = useState<LoadState>({ status: "loading" }); - // Stable UUID for WS comparisons — avoids re-running effects on every state change + // The socket names playlists by id, while the URL may hold the slug. const playlistUUID = state.status === "loaded" ? state.playlist.id : null; useDocumentTitle( @@ -91,43 +93,42 @@ export function PlaylistDetail(): React.JSX.Element { : undefined, ); - // activeDumpIds: which dumps are currently in the playlist (the canonical set) + // The dumps in the playlist. `state.playlist.dumps` also keeps the removed + // ones until their fade ends. const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set()); - // fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list + // A removed dump shows faded for 2 seconds, while it can be undone, then + // slides out ("dismissing"), as on the upvoted page. const [fading, setFading] = useState< Record<string, "cooldown" | "dismissing"> >({}); const cancels = useRef<Map<string, () => void>>(new Map()); - // While an undo-remove is in flight (POST re-add + PUT reorder), holds the - // desired dump order so intermediate WS dumps_updated events don't cause glitches. + // An undo re-adds the dump, which the server puts first, then restores its + // place. This holds that place's order until both requests are done, so the + // socket's echo of the first doesn't flash the dump at the top. const pendingUndoOrderRef = useRef<string[] | null>(null); - // Debounce timer for the reorder setState in dumps_updated so that rapid - // consecutive events (POST re-add followed immediately by PUT reorder) are - // coalesced — only the final order is applied, preventing the glitch on - // other clients who don't have pendingUndoOrderRef. + // Other clients see the same two echoes without that order, so a new order + // from the socket is applied only once none follows for 80 ms. const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>( null, ); - // dragSrcRef: mutable ref so handleDragOver always sees the current source index - // without stale closure issues (state would only update on next render). + // A ref, since dragover fires faster than state renders. const dragSrcRef = useRef<number | null>(null); const [dragOverIndex, setDragOverIndex] = useState<number | null>(null); const [editOpen, setEditOpen] = useState(false); const headerRef = useHeightTransition<HTMLDivElement>(editOpen); const [confirmDelete, setConfirmDelete] = useState(false); - // A delete, removal or reorder the server refused. + // A delete or a removal the server refused. A refused reorder just reloads. const [actionError, setActionError] = useState<string | null>(null); - // Mirrors activeDumpIds for use in effects without adding it as a dep. - // Updated on every render via useLayoutEffect so it's always current. + // For the socket effects, which must not re-run when it changes. const activeDumpIdsRef = useRef(activeDumpIds); - // knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again) + // Every dump seen in the playlist, so that one hidden when it turned private + // comes back when it turns public. const knownDumpIdsRef = useRef<Set<string>>(new Set()); - // Authoritative dump order from the server (fetchPlaylist + dumps_updated events). - // Used to re-insert dumps at their correct position after private→public transitions. + // The server's last order, where such a dump comes back. const dumpOrderRef = useRef<string[]>([]); useLayoutEffect(() => { @@ -146,9 +147,8 @@ export function PlaylistDetail(): React.JSX.Element { fetchAbortRef.current?.abort(); const controller = new AbortController(); fetchAbortRef.current = controller; - // Deferred to a microtask so callers invoking this directly from an - // effect body don't trip the synchronous-setState-in-effect check — - // same timing for the user, just not in the same tick. + // In a microtask, since the linter forbids a synchronous setState in the + // effect that calls this. The user sees no difference. queueMicrotask(() => setState({ status: "loading" })); fetch(`${API_URL}/api/playlists/${playlistId}`, { signal: controller.signal, @@ -195,10 +195,10 @@ export function PlaylistDetail(): React.JSX.Element { return () => fetchAbortRef.current?.abort(); }, [fetchPlaylist]); - // Start the cooldown→dismissing→gone sequence for a dump being removed. - // After the sequence completes, the dump is removed from state.playlist.dumps. + // Fades out dump `id`, then drops it from `state.playlist.dumps`. Its entry + // in `cancels` stops the fade at whichever step it is. function startFade(id: string) { - if (cancels.current.has(id)) return; // already fading + if (cancels.current.has(id)) return; let dead = false; let kill = () => {}; @@ -220,7 +220,6 @@ export function PlaylistDetail(): React.JSX.Element { const t2 = setTimeout(() => { if (dead) return; - // Remove from the playlist.dumps array now that animation is done setState((prev) => { if (prev.status !== "loaded") return prev; return { @@ -260,21 +259,17 @@ export function PlaylistDetail(): React.JSX.Element { cancels.current.set(id, () => kill()); } - // WS: playlist metadata updated or deleted + // The socket's changes to this playlist: its dumps, its details, its end. useEffect(() => { if (!lastPlaylistEvent || !playlistUUID) return; const ev = lastPlaylistEvent; - // Compare against the resolved UUID, not the URL param (which may be a slug) if (ev.playlistId !== playlistUUID) return; if (ev.type === "dumps_updated" && ev.dumpIds) { const newIds = new Set(ev.dumpIds); for (const id of newIds) knownDumpIdsRef.current.add(id); - // Use the ref so we always diff against the current activeDumpIds, - // including changes from deletedDumpIds / lastDumpEvent effects. const prev = activeDumpIdsRef.current; - // Removed: were active, not in new set → fade out for (const id of prev) { if (!newIds.has(id)) { setActiveDumpIds((s) => { @@ -286,14 +281,12 @@ export function PlaylistDetail(): React.JSX.Element { } } - // Newly added IDs: cancel any fade, mark active, fetch dump data individually. - // We never call fetchPlaylist here — that would reset state to "loading", cycle - // playlistUUID, and re-trigger this effect in a loop. + // An added dump is fetched alone. fetchPlaylist would go back through + // "loading", which unsets playlistUUID and runs this effect again. for (const id of newIds) { if (!prev.has(id)) { cancels.current.get(id)?.(); setActiveDumpIds((s) => new Set([...s, id])); - // Capture ev.dumpIds so we can insert the new dump at its correct position. const orderedIds = ev.dumpIds!; fetch(`${API_URL}/api/dumps/${id}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -305,7 +298,6 @@ export function PlaylistDetail(): React.JSX.Element { setState((s) => { if (s.status !== "loaded") return s; if (s.playlist.dumps.some((d) => d.id === dump.id)) return s; - // Insert at the correct server-ordered position. const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d])); dumpMap.set(dump.id, dump); return { @@ -326,11 +318,7 @@ export function PlaylistDetail(): React.JSX.Element { } } - // Debounce the reorder setState so rapid consecutive dumps_updated events - // (e.g. POST re-add followed immediately by PUT reorder during an undo) - // coalesce into a single update — only the final order is applied. - // On the owner's client, pendingUndoOrderRef also suppresses the wrong - // intermediate order; this debounce protects other clients on the WS. + // Debounced: see dumpReorderTimerRef. if (dumpReorderTimerRef.current) { clearTimeout(dumpReorderTimerRef.current); } @@ -345,12 +333,11 @@ export function PlaylistDetail(): React.JSX.Element { .filter((id) => dumpMap.has(id)) .map((id) => dumpMap.get(id)!); let ai = 0; - // Replace each active slot with the next server-ordered active dump; - // fading dumps keep their current slot unchanged. + // The fading dumps keep their slots, the others take the new order, + // and dumps whose fetch landed late go last. const merged = s.playlist.dumps.map((d) => newIds.has(d.id) ? orderedActive[ai++] : d ); - // Append any newly added dumps not yet in the array. while (ai < orderedActive.length) merged.push(orderedActive[ai++]); return { ...s, playlist: { ...s.playlist, dumps: merged } }; }); @@ -375,11 +362,10 @@ export function PlaylistDetail(): React.JSX.Element { } }, [lastPlaylistEvent, playlistUUID, navigate, token]); - // Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist) + // A deleted dump leaves at once, without the removal's fade. useEffect(() => { if (deletedDumpIds.size === 0) return; - // Subscribing to the WS-pushed deleted-dump-ids set — setState here - // mirrors external state into local state, no synchronous alternative. + // It mirrors the socket's state, which only an effect can do. // eslint-disable-next-line react-hooks/set-state-in-effect setState((prev) => { if (prev.status !== "loaded") return prev; @@ -396,7 +382,8 @@ export function PlaylistDetail(): React.JSX.Element { }); }, [deletedDumpIds]); - // Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public) + // An edited dump changes in place, and one of the playlist's dumps that + // turned public again comes back at its place in the server's order. useEffect(() => { if (!lastDumpEvent) return; const dump = lastDumpEvent; @@ -404,13 +391,10 @@ export function PlaylistDetail(): React.JSX.Element { if (prev.status !== "loaded") return prev; const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id); if (idx !== -1) { - // Update in-place const dumps = [...prev.playlist.dumps]; dumps[idx] = dump; return { ...prev, playlist: { ...prev.playlist, dumps } }; } - // Re-add if this dump belongs to the playlist and is now public, - // inserting at its correct server-ordered position. if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) { const order = dumpOrderRef.current; const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d])); @@ -427,7 +411,6 @@ export function PlaylistDetail(): React.JSX.Element { } return prev; }); - // Restore to activeDumpIds if re-added if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) { setActiveDumpIds((prev) => { if (prev.has(dump.id)) return prev; @@ -444,16 +427,13 @@ export function PlaylistDetail(): React.JSX.Element { e.preventDefault(); const src = dragSrcRef.current; if (src === null || src === index) return; - // Only swap once the pointer has crossed the card's midpoint. - // Without this, entering a card immediately re-triggers the swap in the - // opposite direction (the two items keep bouncing back and forth). + // Swapping as soon as the pointer enters a card would swap it straight + // back, so the pointer must cross the card's middle first. const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const mid = rect.top + rect.height / 2; - if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet - if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet - // Update visual order in state. Use activeDumpIdsRef so the updater never - // reads a stale closure — activeDumpIds can't change mid-drag but this is - // the correct pattern for setState updaters. + if (src < index && e.clientY < mid) return; + if (src > index && e.clientY > mid) return; + // The drag indices count only the dumps not fading, which go last. setState((prev) => { if (prev.status !== "loaded") return prev; const ids = activeDumpIdsRef.current; @@ -467,7 +447,6 @@ export function PlaylistDetail(): React.JSX.Element { playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] }, }; }); - // Update the ref and highlight index outside the updater (no side effects inside updaters). dragSrcRef.current = index; setDragOverIndex(index); }; @@ -502,13 +481,12 @@ export function PlaylistDetail(): React.JSX.Element { const handleRemoveDump = (dumpId: string) => { if (!playlistId) return; - // Fire-and-forget the API call; animate immediately + // Removed on the server at once; the undo adds it back. authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { method: "DELETE", }).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); }).catch(() => { - // Refused or offline: cancel the fade and restore the item cancels.current.get(dumpId)?.(); setActiveDumpIds((prev) => new Set([...prev, dumpId])); setActionError(t`The dump couldn't be removed. Try again.`); @@ -525,17 +503,14 @@ export function PlaylistDetail(): React.JSX.Element { if (!playlistId || state.status !== "loaded") return; cancels.current.get(dumpId)?.(); setActiveDumpIds((prev) => new Set([...prev, dumpId])); - // Capture the desired order now (dump is still in playlist.dumps at its - // original position; activeDumpIds hasn't been updated yet in this closure). + // A fading dump keeps its slot in `state.playlist.dumps`, so that order is + // the one to restore. const restoredIds = new Set([...activeDumpIds, dumpId]); const desiredOrder = state.playlist.dumps .filter((d) => restoredIds.has(d.id)) .map((d) => d.id); - // Hold the desired order so the WS handler ignores the intermediate - // dumps_updated event from the POST (which puts the dump at the top). pendingUndoOrderRef.current = desiredOrder; - // Re-add server-side since DELETE already fired, then immediately restore - // the original position (addDumpToPlaylist would otherwise put it at top). + // The server adds the dump first, so the order is restored after. authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, { method: "POST", }) @@ -585,7 +560,8 @@ export function PlaylistDetail(): React.JSX.Element { const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE", }); - if (!res.ok) await expectOk(res); // throws the server's message + // expectOk throws the server's message. + if (!res.ok) await expectOk(res); } catch (err) { setActionError(friendlyFetchError(err)); return; @@ -622,10 +598,9 @@ export function PlaylistDetail(): React.JSX.Element { const { playlist } = state; const isOwner = !!user && user.id === playlist.userId; - // Owners edit their own playlists; moderators may edit any (playlist:moderate). const canEdit = isOwner || can(user, "playlist:moderate"); - // Active dumps in playlist order; fading dumps appended so they stay visible + // The fading dumps stay on screen, but can't be dragged. const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id)); const visibleDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id) || d.id in fading @@ -814,15 +789,16 @@ export function PlaylistDetail(): React.JSX.Element { ); } -/** The props of {@link PlaylistEditForm}. */ +/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */ interface PlaylistEditFormProps { playlist: PlaylistWithDumps; onCancel: () => void; + /** Opens the page's confirmation, which deletes the playlist. */ onRequestDelete: () => void; + /** Called with the playlist as the server now has it. */ onSaved: (playlist: Playlist) => void; } -/** The form's fields. */ interface EditValues { title: string; description: string; @@ -831,7 +807,10 @@ interface EditValues { } /** - * Renders the playlist's edit form: title, description, visibility and cover. + * Renders the form that edits the playlist, in place of the page's header. It + * saves the details with `PUT /api/playlists/:playlistId`, sending only the + * title and description that changed, then a new cover, if one was picked, + * with `POST /api/playlists/:playlistId/image`. */ function PlaylistEditForm( { playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps, diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index af7ba050..d9993432 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -1,5 +1,6 @@ /** - * The password reset page. + * Renders the page a password reset email links to, where the user sets a new + * password. * * @module */ @@ -20,15 +21,14 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; -/** The form's fields. */ interface Values { newPassword: string; confirm: string; } /** - * Renders `/reset-password?token=`, for anyone holding a reset link: the form - * that sets a new password. + * Renders `/reset-password?token=`, for anyone holding a reset link. It doesn't + * sign the user in: a success links to `/login`. */ export function ResetPassword(): React.JSX.Element { useDocumentTitle(t`Reset password`); diff --git a/src/pages/RestrictedGuest.tsx b/src/pages/RestrictedGuest.tsx index 9d5d2f20..8e31e8ff 100644 --- a/src/pages/RestrictedGuest.tsx +++ b/src/pages/RestrictedGuest.tsx @@ -1,5 +1,6 @@ /** - * The guard of the routes for the signed out. + * Guards `App`'s routes that only make sense signed out, `/login` and + * `/register`. * * @module */ @@ -8,14 +9,13 @@ import { Navigate } from "react-router"; import { useAuth } from "../hooks/auth/useAuth.ts"; -/** The props of {@link RestrictedGuest}. */ +/** Passed to {@link RestrictedGuest} by `App`, around a route's page. */ export interface RestrictedGuestProps { - /** The page. */ children: React.ReactNode; } /** - * Renders `children` when no one is signed in, and redirects home otherwise. + * Renders `children` when no one is signed in, and redirects to `/` otherwise. */ export function RestrictedGuest( { children }: RestrictedGuestProps, diff --git a/src/pages/RestrictedLoggedIn.tsx b/src/pages/RestrictedLoggedIn.tsx index 8b90871f..682570eb 100644 --- a/src/pages/RestrictedLoggedIn.tsx +++ b/src/pages/RestrictedLoggedIn.tsx @@ -1,5 +1,6 @@ /** - * The guard of the routes for the signed in. + * Guards `App`'s routes that need a session, the dump editor and + * `/notifications`. * * @module */ @@ -8,15 +9,15 @@ import { Navigate } from "react-router"; import { useAuth } from "../hooks/auth/useAuth.ts"; -/** The props of {@link RestrictedLoggedIn}. */ +/** Passed to {@link RestrictedLoggedIn} by `App`, around a route's page. */ export interface RestrictedLoggedInProps { - /** The page. */ children: React.ReactNode; } /** - * Renders `children` when someone is signed in, and redirects to the login page - * otherwise. + * Renders `children` when someone is signed in, and redirects to `/login` + * otherwise. The page it came from is not remembered, so signing in lands on + * `/`. */ export function RestrictedLoggedIn( { children }: RestrictedLoggedInProps, diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx index 51418551..21ff827c 100644 --- a/src/pages/Search.tsx +++ b/src/pages/Search.tsx @@ -1,5 +1,6 @@ /** - * The search results page. + * The page that shows the results of a search, in a tab for dumps, one for + * users and one for playlists. * * @module */ @@ -30,12 +31,10 @@ import type { Wire } from "../../shared/api.ts"; import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts"; import { friendlyFetchError } from "../utils/shared/api.ts"; -/** The result tabs. */ const SEARCH_TABS = ["dumps", "users", "playlists"] as const; -/** One of the {@link SEARCH_TABS}. */ type Tab = (typeof SEARCH_TABS)[number]; -/** The page's state: idle, loading, failed, or loaded with the results. */ +// Only the dumps are paged: the API answers at most 20 users and playlists. type SearchState = | { status: "idle" } | { status: "loading" } @@ -55,8 +54,10 @@ type SearchState = }; /** - * Renders `/search?q=` and `/search/~/:searchTab`, for anyone: the dumps (a - * page at a time), users and playlists matching the query, one tab each. + * Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come + * from `GET /api/search`, signed in when the viewer is, so they find their own + * private dumps and playlists too. The dumps load a page at a time as the + * "dumps" tab scrolls. A new `q` aborts the search under way. */ export function Search(): React.JSX.Element { useDocumentTitle(t`Search`); @@ -71,9 +72,8 @@ export function Search(): React.JSX.Element { const abortRef = useRef<AbortController | null>(null); const fetchSearch = useCallback(async (query: string, page: number) => { - // Deferred to a microtask so callers invoking this directly from an - // effect body don't trip the synchronous-setState-in-effect check — - // same timing for the user, just not in the same tick. + // Deferred: the set-state-in-effect lint rule forbids setting state + // synchronously in the effect that calls this. if (!query.trim()) { queueMicrotask(() => setState({ status: "idle" })); return; @@ -163,9 +163,7 @@ export function Search(): React.JSX.Element { }, [token]); useEffect(() => { - // fetchSearch is async; calling it directly is flagged as a synchronous - // setState regardless of its own internal deferral, so defer the call - // itself by a microtask too — same timing for the user. + // The lint rule flags the call itself, whatever fetchSearch defers inside. queueMicrotask(() => fetchSearch(q, 1)); return () => abortRef.current?.abort(); }, [q, fetchSearch]); diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index 274f58f4..fa52112b 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -1,5 +1,6 @@ /** - * A user's full list of dumps. + * The page listing all of a user's dumps, which the "dumps" section of their + * profile links to. * * @module */ @@ -22,8 +23,10 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; /** - * Renders `/users/:username/dumps`, for anyone: every dump of the user the - * viewer may see, a page at a time, kept live by the socket. + * Renders `/users/:username/dumps`, for anyone. The dumps come from + * `useUserDumpFeed`, the private ones for their owner only, and the socket's + * events keep them current. On one's own list, a button opens the dump + * creation modal. */ export function UserDumps(): React.JSX.Element { const { username } = useParams(); diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index e1b0340f..63719c3e 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -1,5 +1,5 @@ /** - * The sign-in page. + * Renders the sign-in page, which also requests password reset links. * * @module */ @@ -24,20 +24,18 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; -/** The sign-in form's fields. */ interface LoginValues { username: string; password: string; } -/** The reset request form's fields. */ interface ResetValues { email: string; } /** - * Renders `/login`, for the signed out: the sign-in form, and a way to ask for - * a password reset link. + * Renders `/login`, for the signed out. A sign-in lands on `/`, and "Forgot + * password?" unfolds the reset request form below. */ export function UserLogin(): React.JSX.Element { useDocumentTitle(t`Log in`); @@ -111,16 +109,16 @@ export function UserLogin(): React.JSX.Element { } /** - * Renders the form that emails a password reset link. The answer is the same - * whether or not the address is known. + * Renders the form that asks the server to email a password reset link, which + * leads to `/reset-password`. */ function ResetRequestPanel() { const [sent, setSent] = useState(false); const form = useApiForm<ResetValues>({ defaultValues: { email: "" } }); const onSubmit = form.submit(async ({ email }) => { - // The answer is the same whether or not the address is known; only a - // server that failed to take the request is worth reporting. + // The server answers 200 whether or not the address is known, so as not + // to reveal which ones are. An error here means it failed. await expectOk( await fetch(`${API_URL}/api/users/request-password-reset`, { method: "POST", diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index cfa577aa..fe90e08f 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -1,5 +1,6 @@ /** - * A user's full lists of playlists. + * Renders the page that lists all of a user's playlists, the ones they made and + * the ones they follow. * * @module */ @@ -41,7 +42,7 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; -/** A paginated list of playlists. */ +/** One of the page's two lists, as far as it is loaded. */ interface PlaylistFeed { items: Playlist[]; hasMore: boolean; @@ -49,7 +50,6 @@ interface PlaylistFeed { loadingMore: boolean; } -/** The page's state: loading, failed, or loaded with both lists. */ type State = | { status: "loading" } | { status: "error"; error: string } @@ -60,15 +60,17 @@ type State = followed: PlaylistFeed; }; -/** Returns the first page of a list. */ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed { return { items, hasMore, page: 1, loadingMore: false }; } /** - * Renders `/users/:username/playlists`, for anyone: the playlists the user made - * (the private ones only to them) and those they follow, a page at a time, kept - * live by the socket; the owner can make and delete playlists. + * Renders `/users/:username/playlists`, for anyone. It lists the playlists the + * user made (the private ones only to them) and the public ones they follow, + * from `GET /api/users/:username/playlists` and `…/followed-playlists`, a page + * at a time, and the socket keeps both lists live. The user can make and + * delete playlists here. Going back to the page restores both lists and the + * scroll position from sessionStorage. */ export function UserPlaylists(): React.JSX.Element { const { username } = useParams(); @@ -112,6 +114,8 @@ export function UserPlaylists(): React.JSX.Element { const lastPlaylistItem = lastPlaylistEvent?.type === "updated" ? (lastPlaylistEvent.playlist ?? null) : null; + // Before usePlaylistListSync: it saves a playlist's place before the sync + // takes it out, and puts it back there when it turns public again. usePositionAwareSync( createdItems, setCreated, @@ -142,6 +146,8 @@ export function UserPlaylists(): React.JSX.Element { ? { Authorization: `Bearer ${token}` } : {}; + // Back on the page, the lists come from the cache and only the user is + // fetched again. if (cachedCreated && cachedFollowed) { fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 6ecc623d..44f6bd2a 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -1,5 +1,6 @@ /** - * A user's profile. + * Renders a user's profile page, which is also where the signed-in user edits + * their own profile and settings. * * @module */ @@ -77,10 +78,9 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; /** - * Renders the own-profile action row: "Invite someone" then `children` (log - * out). The invite button toggles a panel below the row holding a fresh - * single-use link; the panel eases in like the inline editors and takes the - * row's width. + * Renders the "Invite someone" button, then `children`, the log out button. + * Each opening of the panel creates a new single-use invite link, for + * `/register`. */ function ProfileOwnActions({ children }: { children: React.ReactNode }) { const { authFetch } = useAuth(); @@ -124,7 +124,8 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) { setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { - // Clipboard denied (e.g. insecure context): the field is selectable. + // The clipboard is denied outside a secure context. The link can still + // be selected by hand. } } @@ -179,7 +180,11 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) { ); } -/** A paginated list. */ +/** + * A list of the profile, of which `page` pages are loaded. The profile holds + * the first page only, since each has its own page for the rest, but the + * followed users. + */ interface PaginatedList<T> { items: T[]; hasMore: boolean; @@ -187,12 +192,14 @@ interface PaginatedList<T> { loadingMore: boolean; } -/** Returns the first page of a list. */ function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> { return { items, hasMore, page: 1, loadingMore: false }; } -/** The profile's state: loading, failed, or loaded with its lists. */ +/** + * The profile's user and its first page of dumps, upvoted dumps and playlists, + * fetched together. + */ type ProfileState = | { status: "loading" } | { status: "error"; error: string } @@ -204,7 +211,7 @@ type ProfileState = playlists: PaginatedList<Playlist>; }; -/** The followed tab's state, null until it is opened. */ +/** Null until the "followed" tab is first opened, which fetches it. */ type FollowedState = | null | { status: "loading" } @@ -215,16 +222,18 @@ type FollowedState = playlists: PaginatedList<Playlist>; }; -/** The invitees tab's state, null until it is opened. */ +/** Null until the "invitees" tab is first opened, which fetches it. */ type InviteTreeState = | null | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; entries: Invitee[] }; -/** An invitee, with those they invited. */ type InviteTreeNode = Invitee & { children: InviteTreeNode[] }; -/** The profile's tabs. */ +/** + * The profile's tabs, the `:profileTab` of `/users/:username/~/:profileTab`. + * "dumps" is the default, at `/users/:username`. + */ const PROFILE_TABS = [ "dumps", "playlists", @@ -233,10 +242,12 @@ const PROFILE_TABS = [ "settings", "manage", ] as const; -/** One of the {@link PROFILE_TABS}. */ type ProfileTab = (typeof PROFILE_TABS)[number]; -/** Returns the invitees of `parentId` among `entries`, each with theirs. */ +/** + * Returns the invitees of `parentId` among `entries`, each with theirs. The + * server answers the whole tree as a flat list. + */ function buildInviteTree( entries: Invitee[], parentId: string, @@ -247,13 +258,13 @@ function buildInviteTree( } /** - * Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone: - * the user's avatar, name, role and description, and their dumps, playlists, - * follows and invitees, one tab each. On their own profile a user can change - * their avatar, description and email, invite someone, and set the theme, - * sounds, default feed tab and password. The manage tab changes another user's - * role, with `user:manage`, or on one's own profile manages the categories, - * with `category:manage`. + * Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone. + * Its tabs show the user's dumps and upvotes, playlists, follows and invitees, + * and the lists follow the socket's events. On their own profile, users edit + * their avatar, description and email, create invites, and get a "settings" + * tab for their password and this browser's preferences. The "manage" tab + * shows another user's role to those who hold `user:manage`, and one's own + * categories to those who hold `category:manage`. */ export function UserPublicProfile(): React.JSX.Element { const { username } = useParams(); @@ -301,20 +312,17 @@ export function UserPublicProfile(): React.JSX.Element { const profileUserId = state.status === "loaded" ? state.user.id : null; const isOwnProfile = me?.id === profileUserId; - // Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe). - // `settings` is own-profile only; `manage` requires the user:manage permission - // on someone else's profile. Invalid tabs fall back to "dumps". const canManageUsers = can(me, "user:manage"); const canManageCategories = can(me, "category:manage"); - // The "manage" tab is an admin/moderation area. On someone else's profile it - // holds per-user tools (role management); on your own it holds global tools - // (category management, with more to come). + // "manage" holds tools for another user on their profile, and site-wide + // tools on one's own. const showManageTab = isOwnProfile ? canManageCategories : canManageUsers; const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => { if (t === "settings") return isOwnProfile; if (t === "manage") return showManageTab; return true; }); + // A tab not available here, as while the profile loads, shows "dumps". const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps"); const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => { @@ -338,8 +346,8 @@ export function UserPublicProfile(): React.JSX.Element { skipReinsert: true, }); - // Dump IDs removed due to vote withdrawal — must not be re-inserted on - // a future dump_updated event (that would only be for private→public transitions). + // A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade + // out. If the dump then turns private and public again, it must not return. const withdrawnVoteIdsRef = useRef<Set<string>>(new Set()); const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => { @@ -383,12 +391,10 @@ export function UserPublicProfile(): React.JSX.Element { skipReinsert: true, }); - // Update profile user when they edit their own profile useEffect(() => { if (!lastUserEvent) return; const { user } = lastUserEvent; - // Subscribing to the WS-pushed user-update stream — setState here - // mirrors external state into local state, no synchronous alternative. + // Mirrors a socket event into state, which can only happen in an effect. // eslint-disable-next-line react-hooks/set-state-in-effect setState((s) => { if (s.status !== "loaded" || s.user.id !== user.id) return s; @@ -423,8 +429,8 @@ export function UserPublicProfile(): React.JSX.Element { setInviteTreeState(null); } - // Refs can't be written during render — reset in an effect instead, which - // still runs (and clears the ref) before the vote-diffing effect below. + // A ref can't be written during render. This effect runs before the one + // below that diffs the votes, so it still resets in time. useEffect(() => { prevMyVotesRef.current = null; }, [username]); @@ -436,7 +442,8 @@ export function UserPublicProfile(): React.JSX.Element { const allCached = cachedDumps && cachedVotes && cachedPlaylists; if (allCached) { - // Only fetch the user object (lightweight, always fresh) + // The lists come from this tab's session cache, as on Back. The user is + // always fetched afresh. fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }) .then((r) => r.json()) .then((body) => { @@ -548,7 +555,8 @@ export function UserPublicProfile(): React.JSX.Element { return () => controller.abort(); }, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); - // Own profile: prepend dumps newly voted by the user to the preview list + // On one's own profile, a vote on one's own dump shows in the upvoted list + // at once, from the optimistic `myVotes`, before the socket's vote event. useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; if (prevMyVotesRef.current === null) { @@ -571,15 +579,15 @@ export function UserPublicProfile(): React.JSX.Element { prevMyVotesRef.current = new Set(myVotes); }, [myVotes, me, profileUserId]); - // Real-time upvoted list sync via WS vote events + // Votes by the profile's user, from any browser, update the upvoted list. useEffect(() => { if (!lastVoteEvent || !profileUserId) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { - // Keep dump in state.votes.items as a ghost — UpvotedDumpList drives - // its own votedIds + fading state and will animate the removal. + // The dump stays in the list: UpvotedDumpList fades it out, then hides + // it. withdrawnVoteIdsRef.current.add(dumpId); } else { withdrawnVoteIdsRef.current.delete(dumpId); @@ -592,12 +600,11 @@ export function UserPublicProfile(): React.JSX.Element { if (s.status !== "loaded") return s; const idx = s.votes.items.findIndex((d) => d.id === dumpId); if (idx !== -1) { - // Ghost re-voted: update in-place. + // A withdrawn vote cast again, before its dump was reloaded. const items = [...s.votes.items]; items[idx] = dump; return { ...s, votes: { ...s.votes, items } }; } - // First-time vote: prepend. return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] }, @@ -608,7 +615,7 @@ export function UserPublicProfile(): React.JSX.Element { } }, [lastVoteEvent, profileUserId]); - // Save scroll position + loaded state to sessionStorage on scroll + // Saves the lists and the scroll position, so that Back restores both. useEffect(() => { if (state.status !== "loaded") return; let timer: ReturnType<typeof setTimeout>; @@ -634,8 +641,8 @@ export function UserPublicProfile(): React.JSX.Element { }; }, [state, saveDumps, saveVotes, savePlaylists]); - // Keep the playlists cache current whenever the list changes (e.g. via WS), - // so a page refresh restores the up-to-date list rather than a stale snapshot. + // The playlists also change from the socket without a scroll, and the cache + // must not restore a stale list. const playlistFeed = state.status === "loaded" ? state.playlists : null; useEffect(() => { if (!playlistFeed) return; @@ -647,13 +654,13 @@ export function UserPublicProfile(): React.JSX.Element { ); }, [playlistFeed, savePlaylists]); - // Lazy-load followed users + playlists when the Followed tab is first opened useEffect(() => { if ( tab !== "followed" || followedState !== null || state.status !== "loaded" ) { return; } + // Deferred, since the lint forbids a setState in an effect's body. queueMicrotask(() => setFollowedState({ status: "loading" })); const controller = new AbortController(); Promise.all([ @@ -691,12 +698,11 @@ export function UserPublicProfile(): React.JSX.Element { setFollowedState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); - // followedState intentionally omitted: it's a read-once guard, not a trigger. - // Including it would abort the in-flight fetch when state changes to "loading". + // followedState only guards the first fetch. As a dependency, its change + // to "loading" would abort that fetch. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); - // Lazy-load invite tree when the Invitees tab is first opened useEffect(() => { if ( tab !== "invitees" || inviteTreeState !== null || @@ -722,12 +728,11 @@ export function UserPublicProfile(): React.JSX.Element { setInviteTreeState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); - // inviteTreeState intentionally omitted — same read-once guard pattern. + // inviteTreeState only guards the first fetch, as followedState above. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); - // There's no page of its own for who someone follows, so the list grows - // here. + // The followed users have no page of their own, so the list grows here. const loadMoreFollowedUsers = () => { if ( followedState?.status !== "loaded" || followedState.users.loadingMore @@ -755,7 +760,7 @@ export function UserPublicProfile(): React.JSX.Element { .catch(() => setUsers((u) => ({ ...u, loadingMore: false }))); }; - // Restore scroll position after cache restoration + // Before paint, so the restored page doesn't flash at the top. const scrollRestored = useRef(false); useLayoutEffect(() => { if (cachedDumps?.scrollY == null || scrollRestored.current) return; @@ -972,8 +977,8 @@ export function UserPublicProfile(): React.JSX.Element { onClick={isOwnProfile ? () => setDescEditing(true) : undefined} - // Reachable from the keyboard too; not a button, so the - // markdown inside keeps its own structure. + // Focusable rather than a button, so the Markdown inside + // keeps its own structure and links. tabIndex={isOwnProfile ? 0 : undefined} title={isOwnProfile ? t`Edit description` : undefined} onKeyDown={isOwnProfile @@ -1451,9 +1456,10 @@ export function UserPublicProfile(): React.JSX.Element { ); } -// ── Plain dump list ────────────────────────────────────────────────────────── - -/** Renders a list of dump cards. */ +/** + * Renders the first page of the user's dumps, with a link to all of them. The + * owner also gets a "New dump" button. + */ function DumpList( { title, @@ -1528,9 +1534,10 @@ function DumpList( ); } -// ── Upvoted list: fades items out when votes are removed ───────────────────── - -/** Renders the upvoted list, fading items out when their votes are removed. */ +/** + * Renders the first page of the dumps the user upvoted. A dump whose vote is + * withdrawn fades out, and comes back if the vote is cast again meanwhile. + */ function UpvotedDumpList( { title, @@ -1559,26 +1566,22 @@ function UpvotedDumpList( const { myVotes: wsMyVotes, lastVoteEvent } = useWS(); const { fading, startFading, cancelFading } = useFading(); - // votedIds is managed locally so setVotedIds + startFading/cancelFading can - // be called in the same effect body — guaranteeing a single render where the - // dump is always in visibleDumps (with or without fading class). This prevents - // the DOM node from being unmounted/remounted, which would break CSS transitions. + // Held here rather than derived, so that a dump leaves `votedIds` and starts + // fading in the same render. In between, the card would unmount and its + // transition break. const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)) ); const prevMyVotesRef = useRef<Set<string> | null>(null); - // Own profile: sync votedIds with myVotes; start/cancel fading in same batch. - // setVotedIds and startFading/cancelFading must be called together synchronously - // in the same effect to guarantee a single render where the DOM node isn't - // unmounted — converting to render-phase isn't possible because startFading/ - // cancelFading are themselves setState calls that can't run during render. + // On one's own profile, votes follow `myVotes`, which changes at once on a + // vote here. This must be an effect, since startFading and cancelFading set + // state too. useEffect(() => { if (!profileUserId || !isOwnProfile) return; if (prevMyVotesRef.current === null) { - // Only a baseline: the list as loaded stands. myVotes may still be the - // empty set from before the socket's welcome, and taking it as the - // truth would hide every dump. + // Only a baseline. myVotes may still be empty, from before the socket's + // welcome, and would hide every dump. prevMyVotesRef.current = new Set(wsMyVotes); return; } @@ -1594,16 +1597,13 @@ function UpvotedDumpList( prevMyVotesRef.current = new Set(wsMyVotes); }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]); - // Non-own profile: sync votedIds with WS vote events for the profile user. - // Same constraint as above: setVotedIds and startFading/cancelFading must - // fire together so the DOM node stays mounted throughout the transition. + // On another's profile, votes follow the socket's vote events. useEffect(() => { if (!lastVoteEvent || !profileUserId || isOwnProfile) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { - // setVotedIds and startFading must fire together to avoid a render with - // stale votedIds between the two updates. + // Both updates must land in one render, as above. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds((prev) => { const n = new Set(prev); @@ -1666,9 +1666,7 @@ function UpvotedDumpList( ); } -// ── Invite tree ────────────────────────────────────────────────────────────── - -/** Renders the invite tree. */ +/** Renders the invite tree, each invitee nested under who invited them. */ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { return ( <ul className="invite-tree"> @@ -1693,9 +1691,7 @@ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) { ); } -// ── Followed user card ─────────────────────────────────────────────────────── - -/** Renders a followed user. */ +/** Renders a followed user, with a button to follow or unfollow them. */ function FollowedUserCard({ user }: { user: PublicUser }) { return ( <li className="followed-user-card"> @@ -1717,7 +1713,6 @@ function FollowedUserCard({ user }: { user: PublicUser }) { ); } -/** The roles, with their labels. */ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [ { value: "user", label: <Trans>User</Trans> }, { value: "moderator", label: <Trans>Moderator</Trans> }, @@ -1725,8 +1720,8 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [ ]; /** - * Renders the role badge shown next to the username. The default "user" role is - * implicit, so only elevated roles get a chip to avoid labelling every profile. + * Renders the role badge next to the username, for moderators and admins + * only. */ function RoleChip({ role }: { role: Role }) { if (role === "user") return null; @@ -1738,7 +1733,6 @@ function RoleChip({ role }: { role: Role }) { ); } -/** The props of {@link RoleSwitcher}. */ interface RoleSwitcherProps { userId: string; currentRole: Role; @@ -1746,8 +1740,8 @@ interface RoleSwitcherProps { } /** - * Renders the segmented role control (matches the dump-visibility toggle - * style), gated behind the user:manage permission by its caller. + * Renders the control that sets another user's role, which its caller shows + * only to those who hold `user:manage`. The server checks it again. */ function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) { const { authFetch } = useAuth(); @@ -1801,14 +1795,16 @@ function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) { ); } -/** The props of {@link EmailEditor}. */ interface EmailEditorProps { initialEmail: string; onCancel: () => void; onSaved: (email: string) => void; } -/** Renders the inline editor of the user's email address. */ +/** + * Renders the inline editor of the signed-in user's email, which only they + * see. + */ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) { const { authFetch } = useAuth(); const form = useApiForm<{ email: string }>({ @@ -1866,14 +1862,16 @@ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) { ); } -/** The props of {@link DescriptionEditor}. */ interface DescriptionEditorProps { initialDescription: string; onCancel: () => void; onSaved: (description: string | undefined) => void; } -/** Renders the inline editor of the user's description. */ +/** + * Renders the inline editor of the signed-in user's description, in Markdown. + * Other browsers see the change through the socket's `user_updated` event. + */ function DescriptionEditor( { initialDescription, onCancel, onSaved }: DescriptionEditorProps, ) { diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index 6184e954..961130a6 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -1,5 +1,6 @@ /** - * The sign-up page. + * Renders the sign-up page, which only works from an invite link. Members copy + * those links from their own profile page. * * @module */ @@ -25,13 +26,11 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx"; import { TextField } from "../components/form/TextField.tsx"; import { useApiForm } from "../hooks/form/useApiForm.ts"; -/** The invite's state: being checked, invalid, or valid. */ type TokenState = | { status: "checking" } | { status: "invalid" } | { status: "valid" }; -/** The form's fields. */ interface Values { username: string; email: string; @@ -39,8 +38,8 @@ interface Values { } /** - * Renders `/register?token=`, for the signed out holding an invite: the invite - * is checked, then the sign-up form, which signs the new user in. + * Renders `/register?token=`, for the signed out. It checks the invite before + * showing the form, and signs the new user in on success. */ export function UserRegister(): React.JSX.Element { useDocumentTitle(t`Register`); @@ -58,6 +57,8 @@ export function UserRegister(): React.JSX.Element { defaultValues: { username: "", email: "", password: "" }, }); + // Reset during render, so the form never shows for a new token while the + // previous one's result is still in state. if (prevToken !== token) { setPrevToken(token); setTokenState(token ? { status: "checking" } : { status: "invalid" }); diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 762f46e8..ea2ea381 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -1,5 +1,6 @@ /** - * A user's full list of upvoted dumps. + * The page listing all the dumps a user upvoted, which the "upvoted" section of + * their profile links to. * * @module */ @@ -24,9 +25,10 @@ import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; /** - * Renders `/users/:username/upvoted`, for anyone: every dump the user upvoted - * that the viewer may see, a page at a time; on one's own list a withdrawn vote - * fades its dump out. + * Renders `/users/:username/upvoted`, for anyone. The dumps come from + * `useUserDumpFeed`. The socket's vote events keep the list current: a dump + * the user upvotes is fetched and prepended, and one whose vote they withdraw + * fades out (`useFading`) unless they vote again within 2 seconds. */ export function UserUpvoted(): React.JSX.Element { const { username } = useParams(); @@ -35,6 +37,8 @@ export function UserUpvoted(): React.JSX.Element { useDocumentTitle(username ?? null); + // The dumps still upvoted. A loaded dump outside it shows only while it + // fades. const [votedIds, setVotedIds] = useState<Set<string>>(new Set()); const { fading, startFading, cancelFading, cancelAll } = useFading(); const prevMyVotesRef = useRef<Set<string> | null>(null); @@ -90,7 +94,7 @@ export function UserUpvoted(): React.JSX.Element { return; } const prev = prevMyVotesRef.current; - // Votes cast or removed since — from this tab or another — show or fade. + // Votes cast or withdrawn since, in any of the user's tabs. setVotedIds((ids) => { const next = new Set(ids); for (const id of prev) if (!myVotes.has(id)) next.delete(id); @@ -108,8 +112,8 @@ export function UserUpvoted(): React.JSX.Element { if (voterId !== profileUserId) return; if (action === "remove") { - // setVotedIds and startFading must fire together to avoid a render with - // stale votedIds between the two updates. + // Both updates must land in the same render, or the card unmounts + // before its fade starts. // eslint-disable-next-line react-hooks/set-state-in-effect setVotedIds((prev) => { const n = new Set(prev); diff --git a/src/utils/auth/permissions.ts b/src/utils/auth/permissions.ts index 06fd29fa..44c795f1 100644 --- a/src/utils/auth/permissions.ts +++ b/src/utils/auth/permissions.ts @@ -1,5 +1,6 @@ /** - * What the signed-in user may do, as the UI asks it. + * Checks the signed-in user's permissions, for the pages and components that + * hide what the server would refuse. * * @module */ @@ -7,7 +8,10 @@ import { hasPermission, type Permission } from "../../../shared/permissions.ts"; import type { PublicUser } from "../../../shared/user.ts"; -/** Checks whether `user` has `permission`; nobody signed in has none. */ +/** + * Checks whether `user` has `permission`. A null `user`, for the signed out, + * has none. The server checks again, so this only decides what to show. + */ export function can( user: Pick<PublicUser, "role"> | null | undefined, permission: Permission, diff --git a/src/utils/auth/storage.ts b/src/utils/auth/storage.ts index 7ba2b773..7d2a5c5c 100644 --- a/src/utils/auth/storage.ts +++ b/src/utils/auth/storage.ts @@ -1,6 +1,6 @@ /** - * The stored session. The session is shared by every tab through one - * localStorage entry. + * Reads and writes the session in localStorage, where every tab shares one + * entry. `AuthProvider` and `useAuth` are its only users. * * @module */ @@ -9,19 +9,23 @@ import type { AuthResponse } from "../../../shared/user.ts"; import { deserializeAuthResponse } from "../shared/deserialize.ts"; /** - * The signed-in session as the app holds it. Sessions stored before media - * tokens existed have none; AuthProvider fetches one for them. + * Held by `AuthProvider` and stored here. It is the {@link AuthResponse} of a + * sign-in or sign-up, except that one stored before media tokens existed has + * none until `AuthProvider` fetches it. */ export type Session = Omit<AuthResponse, "mediaToken"> & { mediaToken?: string; }; -/** The localStorage key of the session. */ +/** + * The localStorage key of the session. `AuthProvider` listens for changes to it + * from other tabs. + */ export const AUTH_STORAGE_KEY = "authResponse"; /** - * Checks whether `token`, a JWT, has expired; an unreadable one counts as - * expired. + * Checks whether `token`, a JWT, has expired, without asking the server. An + * unreadable one counts as expired. */ export function isTokenExpired(token: string): boolean { try { @@ -33,8 +37,8 @@ export function isTokenExpired(token: string): boolean { } /** - * Returns the stored session, or null — discarding one that can no longer be - * used. + * Returns the stored session, or null. A session that is unreadable, expired or + * from before roles existed is removed from storage. */ export function readStoredSession(): Session | null { const stored = localStorage.getItem(AUTH_STORAGE_KEY); @@ -51,9 +55,8 @@ export function readStoredSession(): Session | null { localStorage.removeItem(AUTH_STORAGE_KEY); return null; } - // Sessions persisted before roles existed have no `role`; the server also - // rejects their tokens, but discard them here so the UI never renders with - // a roleless user. + // The server rejects these tokens too, but the UI must never render a user + // without a role. if (!parsed.user.role) { localStorage.removeItem(AUTH_STORAGE_KEY); return null; @@ -67,9 +70,9 @@ export function storeSession(session: Session): void { } /** - * Forgets the stored session, but only if it is still the one holding `token`. - * Another tab may already have replaced it — a password change there hands out - * fresh tokens — and a 401 for the old token here must not wipe those. + * Forgets the stored session, but only if it still holds `token`. Another tab + * may have replaced it with fresh tokens from a password change, and a 401 for + * the old token here must not wipe those. */ export function forgetStoredSession(token: string | undefined): void { const stored = localStorage.getItem(AUTH_STORAGE_KEY); diff --git a/src/utils/chat/draft.ts b/src/utils/chat/draft.ts index ef3f7c33..90e568e7 100644 --- a/src/utils/chat/draft.ts +++ b/src/utils/chat/draft.ts @@ -1,29 +1,29 @@ /** - * The chat's unsent message, kept per user in sessionStorage. The draft and - * reply target outlive the chat — closing it, or reloading the tab, keeps a - * half-written message. + * Keeps the chat composer's unsent message in sessionStorage, per user, so + * closing the chat modal or reloading the tab doesn't lose it. `ChatModal` + * reads it on open and writes it on every change. * * @module */ import type { ChatReplyTarget } from "../../contexts/ws/WSContext.ts"; -/** An unsent chat message. */ +/** + * Returned by {@link readDraft} to `ChatModal`, which passes it back to + * {@link writeDraft}. + */ export interface StoredDraft { - /** The message as typed so far. */ draft: string; - /** The message it replies to, if any. */ replyTarget: ChatReplyTarget | null; } -/** Returns the sessionStorage key of the draft of `userId`. */ function draftKey(userId: string): string { return `chat-draft:${userId}`; } /** - * Returns the stored draft of `userId`, empty when there is none or no one is - * signed in. + * Returns the stored draft of `userId`, or an empty one when there is none, + * `userId` is undefined, or storage is blocked. */ export function readDraft(userId: string | undefined): StoredDraft { if (userId) { @@ -40,7 +40,10 @@ export function readDraft(userId: string | undefined): StoredDraft { return { draft: "", replyTarget: null }; } -/** Stores `value` as the draft of `userId`, or removes it when it is empty. */ +/** + * Stores `value` as the draft of `userId`, or removes it when it has neither + * text nor a reply target. Does nothing when `userId` is undefined. + */ export function writeDraft( userId: string | undefined, value: StoredDraft, diff --git a/src/utils/chat/message.ts b/src/utils/chat/message.ts index 2ba46dc8..1d4639e6 100644 --- a/src/utils/chat/message.ts +++ b/src/utils/chat/message.ts @@ -1,6 +1,6 @@ /** - * Rules for a chat message: when it can be sent, how it is shown, and its - * one-line preview. + * Decides when a chat message can be sent and how its text is shown, for the + * chat modal's composer, edit box and message rows. * * @module */ @@ -11,14 +11,14 @@ import { isEmojiOnly } from "../editor/text.ts"; import { VALIDATION } from "../../../shared/validation.ts"; /** - * The length from which the character counter shows — only once a message is - * getting near the limit. + * The length from which the composer and the edit box show their character + * counter, near `VALIDATION.CHAT_MESSAGE_MAX`. */ export const COUNT_FROM = 1800; /** - * Checks whether `draft` can be sent, by what the server holds a message to: - * its length once trimmed. + * Checks whether `draft` can be sent or saved. Like the server, it measures + * the trimmed text. */ export function sendable(draft: string): boolean { const body = draft.trim(); @@ -26,8 +26,8 @@ export function sendable(draft: string): boolean { } /** - * Returns the class of a message's text: a message of nothing but a few emoji - * is shown large, as chats do. + * Returns the class for the text of a sent or pending message. A message of + * nothing but a few emoji gets the one that shows it large. */ export function messageTextClass(body: string): string { return isEmojiOnly(body) @@ -35,7 +35,10 @@ export function messageTextClass(body: string): string { : "chat-message-text"; } -/** Returns `body` as one line of plain text, for reply previews. */ +/** + * Returns `body` as one line of plain text, for the reply bar above the + * composer and the reply line above a message. + */ export function snippet(body: string): string { return markdownToPlainText(body, t`image`); } diff --git a/src/utils/chat/text.ts b/src/utils/chat/text.ts index dfbb92e1..63249bf6 100644 --- a/src/utils/chat/text.ts +++ b/src/utils/chat/text.ts @@ -1,5 +1,6 @@ /** - * Text helpers for the live chat. + * Reads the Markdown of chat messages, to find mentions and to flatten it into + * reply previews. * * @module */ @@ -7,8 +8,9 @@ import { MENTION_RE } from "../../../shared/mention.ts"; /** - * Checks whether `body` @-mentions `username` (case-insensitively), by the same - * rule the server uses to send mention notifications. + * Checks whether `body` @mentions `username`, ignoring case, with the pattern + * the server uses for mention notifications. `WSProvider` uses it to pick the + * chime, and `ChatModal` to highlight the message. */ export function mentionsUser(body: string, username: string): boolean { const target = username.toLowerCase(); @@ -19,14 +21,15 @@ export function mentionsUser(body: string, username: string): boolean { } /** - * Returns the chat markdown `md` flattened to one line of plain text, for the - * reply previews: images become their alt text or `imageLabel`, links their - * text, and markup characters go. Also copes with a body the server cut short - * mid-link, which would otherwise show a dangling half-URL. + * Returns the Markdown `md` as one line of plain text. Images become their alt + * text, or `imageLabel` without one, and links their text. It takes a + * `replyToBody`, which the server cuts short, possibly inside a link: the + * unfinished link then shows as its text, without half a URL. `snippet` calls + * it. */ export function markdownToPlainText(md: string, imageLabel: string): string { return md - // A truncated image or link at the very end: drop the unfinished target. + // An image or link cut short at the end loses its unfinished target. .replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel) .replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2") .replace(/!\[[^\]]*$/, "") diff --git a/src/utils/chat/time.ts b/src/utils/chat/time.ts index ef4152f5..dd23b5f1 100644 --- a/src/utils/chat/time.ts +++ b/src/utils/chat/time.ts @@ -1,5 +1,6 @@ /** - * Timestamps in the chat: day separators and the grouped-message gutter. + * Formats the chat modal's dates, in the interface's locale, for its day + * separators and the times beside grouped messages. * * @module */ @@ -15,8 +16,8 @@ export function isSameDay(a: Date, b: Date): boolean { } /** - * Returns the label of the day separator for `date`: "Today" or "Yesterday" for - * the two most recent days, otherwise a localized full date. + * Returns the label of the day separator for `date`. It is "Today", + * "Yesterday", or the full date, with the year only when it isn't this one. */ export function dayLabel(date: Date): string { const now = new Date(); @@ -33,9 +34,9 @@ export function dayLabel(date: Date): string { } /** - * Returns the hours and minutes of `date`, for the narrow gutter beside a - * grouped message — the day period ("PM") is dropped to fit; its tooltip has - * the full timestamp. + * Returns the hours and minutes of `date`, without the day period ("PM") so it + * fits the narrow space beside a grouped message. Its tooltip shows the full + * time. */ export function clockTime(date: Date): string { return new Intl.DateTimeFormat(i18n.locale, { diff --git a/src/utils/dump/draft.ts b/src/utils/dump/draft.ts index 776e0389..d769aec1 100644 --- a/src/utils/dump/draft.ts +++ b/src/utils/dump/draft.ts @@ -1,58 +1,36 @@ /** - * Persistence for the half-written dump in the create modal. + * Keeps the half-written dump of the dump creation modal in localStorage, so + * that closing the modal by mistake, with Escape or a click outside, loses + * nothing but an attached file, which can't be stored. * - * The modal is dismissable by Escape, a backdrop click and the ✕, and it holds - * a URL, a title, a rich-text "why", categories and a visibility choice — so a - * stray keystroke used to destroy a few minutes of writing. Everything except - * the attached file (a `File` cannot survive a reload) is mirrored here on each - * change and restored the next time the modal opens; a successful post clears - * it. - * - * Deliberately a single draft rather than one per tab: someone who closed the - * modal by accident wants the thing they were just writing, wherever they - * reopen it. + * There is one draft, not one per tab: whoever closed the modal by mistake + * wants back what they were just writing, wherever they reopen it. * * @module */ -/** - * Persistence for the half-written dump in the create modal. - * - * The modal is dismissable by Escape, a backdrop click and the ✕, and it holds - * a URL, a title, a rich-text "why", categories and a visibility choice — so a - * stray keystroke used to destroy a few minutes of writing. Everything except - * the attached file (a `File` cannot survive a reload) is mirrored here on each - * change and restored the next time the modal opens; a successful post clears - * it. - * - * Deliberately a single draft rather than one per tab: someone who closed the - * modal by accident wants the thing they were just writing, wherever they - * reopen it. - */ - -/** The localStorage key of the draft. */ const STORAGE_KEY = "gerbeur.dumpDraft"; -/** A half-written dump. */ +/** + * Saved by `DumpCreateModal` on every change, and restored when it next opens + * without a shared link. Posting the dump clears it. + */ export interface DumpDraft { - /** The link, as typed. */ + /** As typed, not yet normalized. */ url: string; - /** The title. */ title: string; /** - * Whether `title` was typed by the poster, rather than taken from the - * preview. + * Whether the poster typed `title`. When not, the modal takes the title + * from the link's preview again. */ titleFromUser: boolean; - /** The "why", in Markdown. */ + /** In Markdown. */ comment: string; - /** Whether the dump will be public. */ isPublic: boolean; - /** The ids of the chosen categories. */ categoryIds: string[]; } -/** A draft with nothing in it. */ +/** The modal's starting values, when no draft is restored. */ export const EMPTY_DUMP_DRAFT: DumpDraft = { url: "", title: "", @@ -62,20 +40,26 @@ export const EMPTY_DUMP_DRAFT: DumpDraft = { categoryIds: [], }; -/** Checks whether `draft` holds nothing worth restoring or warning about. */ +/** + * Checks whether `draft` holds nothing worth restoring. A title taken from the + * preview doesn't count, since the preview gives it back. + */ export function isDumpDraftEmpty(draft: DumpDraft): boolean { return !draft.url.trim() && !draft.comment.trim() && !(draft.titleFromUser && draft.title.trim()) && draft.categoryIds.length === 0 && draft.isPublic; } -/** Returns the stored draft, or null when there is none worth restoring. */ +/** + * Returns the stored draft, or null when there is none worth restoring or + * storage is blocked. A field that doesn't parse takes its empty value. + */ export function loadDumpDraft(): DumpDraft | null { let raw: string | null; try { raw = localStorage.getItem(STORAGE_KEY); } catch { - return null; // Storage disabled (private mode, blocked cookies) — no drafts. + return null; // Storage blocked, as in some private windows. } if (!raw) return null; @@ -108,12 +92,14 @@ export function saveDumpDraft(draft: DumpDraft): void { if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY); else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); } catch { - // Storage full or unavailable — the draft is a convenience, never a - // precondition for posting, so a failure here is silent by design. + // Storage full or blocked: see the doc above. } } -/** Removes the stored draft. */ +/** + * Removes the stored draft, once the dump is posted or the poster chose to + * start afresh. + */ export function clearDumpDraft(): void { try { localStorage.removeItem(STORAGE_KEY); diff --git a/src/utils/editor/skinTone.ts b/src/utils/editor/skinTone.ts index bf38b810..f6950925 100644 --- a/src/utils/editor/skinTone.ts +++ b/src/utils/editor/skinTone.ts @@ -1,15 +1,19 @@ /** - * The emoji picker's skin tone, remembered across pickers and reloads. + * Stores the emoji picker's skin tone in localStorage, so that it outlasts the + * picker and the page. `EmojiPickerPopup` reads and writes it. * * @module */ import type { SkinTone } from "frimousse"; -/** The localStorage key of the skin tone. */ +/** Names the skin tone in localStorage. `EmojiPickerPopup` writes under it. */ export const SKIN_TONE_KEY = "emoji-skin-tone"; -/** Returns the stored skin tone, `none` when there is none. */ +/** + * Returns the stored skin tone, or `none` when there is none or storage is + * blocked. The stored value is not checked against frimousse's tones. + */ export function readSkinTone(): SkinTone { try { return (localStorage.getItem(SKIN_TONE_KEY) as SkinTone | null) ?? "none"; diff --git a/src/utils/editor/text.ts b/src/utils/editor/text.ts index b7405fe2..f1e9d4a6 100644 --- a/src/utils/editor/text.ts +++ b/src/utils/editor/text.ts @@ -1,14 +1,15 @@ /** - * Text helpers shared by the editor's autocompletes (mentions, emoji) and the - * chat's emoji-only messages. + * Reads a text as the editor's autocompletes and the chat see it: whether the + * caret is inside code, and whether a message is only emoji. * * @module */ /** - * Checks whether the caret at the end of `textBefore` sits inside markdown code - * — an open ``` fence, or an unclosed `span` on the current line — where `@bob` - * and `:smile` are literal text, not something to complete. + * Checks whether the caret at the end of `textBefore` sits inside Markdown + * code, an open ``` fence or an unclosed `span` on the current line. The + * mention and emoji autocompletes stay closed there, since `@bob` and `:smile` + * are literal text in code. */ export function insideCode(textBefore: string): boolean { const fences = textBefore.match(/^\s{0,3}```/gm)?.length ?? 0; @@ -18,20 +19,24 @@ export function insideCode(textBefore: string): boolean { return (line.match(/`/g)?.length ?? 0) % 2 === 1; } -/** Matches text made of emoji, their modifiers and whitespace only. */ +/** + * Matches text made only of emoji, their modifiers and whitespace. Keycap + * characters (`#`, `*`, digits) pass here, so "123" needs the check after it. + */ const EMOJI_ONLY_RE = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\u{1F3FB}-\u{1F3FF}#*0-9]|\u200D|\uFE0F|\u20E3|\s)+$/u; -/** Matches one pictographic emoji or flag letter. */ const PICTOGRAPH_RE = /\p{Extended_Pictographic}|\p{Regional_Indicator}/u; /** - * Checks whether `text` is nothing but a few emoji (at most `max`), for the - * jumbo rendering chats give a message like "🎉" or "👍👍". + * Checks whether `text` is nothing but `max` emoji at most, which the chat + * shows large, as in "🎉" or "👍👍". Emoji are counted as graphemes, so a + * family or a flag counts once. */ export function isEmojiOnly(text: string, max = 3): boolean { const trimmed = text.trim(); if (!trimmed || !EMOJI_ONLY_RE.test(trimmed)) return false; - if (!PICTOGRAPH_RE.test(trimmed)) return false; // "123", "#" + // Keycap characters alone ("123", "#"). + if (!PICTOGRAPH_RE.test(trimmed)) return false; const segments = [ ...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment( trimmed.replace(/\s+/g, ""), diff --git a/src/utils/feed/hotScore.ts b/src/utils/feed/hotScore.ts index cee63ddc..ac046fb4 100644 --- a/src/utils/feed/hotScore.ts +++ b/src/utils/feed/hotScore.ts @@ -1,12 +1,17 @@ /** - * The ranking of the hot feed. + * Ranks dumps for the "hot" and "journal" tabs of the index page. * * @module */ import type { Dump } from "../../../shared/dump.ts"; -/** Returns how hot `dump` is: its votes, decaying with its age in hours. */ +/** + * Returns the score that `DumpFeed` and `composeJournal` sort by, highest + * first. It grows with the votes and decays with the age in hours, so a new + * dump without votes can outrank an old popular one. The server doesn't rank, + * so only the loaded dumps are ranked. + */ export function hotScore(dump: Dump): number { const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000; return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5); diff --git a/src/utils/feed/journalLayout.ts b/src/utils/feed/journalLayout.ts index bc3238ac..f0f32901 100644 --- a/src/utils/feed/journalLayout.ts +++ b/src/utils/feed/journalLayout.ts @@ -1,6 +1,7 @@ /** - * The journal feed's layout: dumps ranked by hotness and laid out as an - * editorial mosaic, cut into blocks. + * Lays out the "journal" tab of the index page: it ranks the loaded dumps by + * {@link hotScore}, gives each a tile size in a 4-column mosaic, and cuts the + * mosaic into blocks. `JournalFeed` renders the blocks. * * @module */ @@ -9,43 +10,35 @@ import type { Dump } from "../../../shared/dump.ts"; import { hotScore } from "./hotScore.ts"; /** - * Grid footprint of a journal entry in the editorial mosaic. - * - * - `feature` — 2×2 hero - * - `wide` — 2×1 banner - * - `tall` — 1×2 portrait - * - `square` — 1×1 unit - * - * The footprint only describes the grid span; how a card is *rendered* (image / - * quote / text) is derived separately from its content. + * The grid span of a tile, which `JournalCard` turns into a CSS class. A + * `feature` spans 2×2 cells, a `wide` 2×1, a `tall` 1×2 and a `square` 1×1. + * Whether the tile shows an image, a quote or text depends on the dump alone. */ export type JournalShape = "feature" | "wide" | "tall" | "square"; -/** A dump placed in the mosaic. */ +/** Returned by {@link composeJournal} within a {@link JournalBlock}. */ export interface JournalEntry { - /** The dump. */ dump: Dump; - /** The footprint it takes. */ shape: JournalShape; } /** - * A run of entries rendered as one grid, separated from the next by a break. + * Returned by {@link composeJournal}. `JournalFeed` renders each block as its + * own grid, with a rule between two blocks. */ export interface JournalBlock { - /** Stable list key — the id of the dump the block opens on. */ + /** The id of the block's first dump, stable as more pages load. */ key: string; - /** The block's entries, in reading order. */ + /** In reading order. */ entries: JournalEntry[]; } -/** Columns the mosaic is composed against — the grid's widest breakpoint. */ +// The grid's column count at its widest breakpoint. const COLUMNS = 4; -/** Cards a sub-block gathers before a break comes due. */ +// The number of tiles a block gathers before a break comes due. const BLOCK_CARDS = 12; -/** The columns and rows each shape spans. */ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = { feature: { cols: 2, rows: 2 }, wide: { cols: 2, rows: 1 }, @@ -54,8 +47,9 @@ const FOOTPRINT: Record<JournalShape, { cols: number; rows: number }> = { }; /** - * Checks whether `dump` can carry a real preview image (file image or rich - * thumbnail). + * Checks whether `dump` has an image to show: an uploaded thumbnail, an image + * or video file, or a link preview's thumbnail. Only such dumps get the large + * tiles, and `JournalCard` shows them as image tiles. */ export function hasThumbnail(dump: Dump): boolean { if (dump.kind === "file") { @@ -69,21 +63,18 @@ export function hasThumbnail(dump: Dump): boolean { } /** - * Checks whether the comment of `dump` is substantial enough to feature as a - * pull-quote. + * Checks whether `dump` has a non-blank note, which `JournalCard` shows as a + * pull-quote when the dump has no image. */ export function hasQuote(dump: Dump): boolean { return !!dump.comment && dump.comment.trim().length > 0; } /** - * Returns the occupancy of a mosaic `columns` wide, walked in reading order. - * - * `next()` returns the first cell no card has claimed yet, along with the run - * of free cells to its right — the width the next card is allowed to take. - * Because every card is clamped to that run (see `fit`), the cursor never has - * to skip a cell it can't fill, so the tiling is exact: no gaps, and the order - * cards are emitted in is the order they're read in. + * Returns the cells of a mosaic `columns` wide, filled in reading order. + * `next()` returns the first free cell and how many free cells follow it in + * its row. Since `fit` keeps every tile within that run, the tiling leaves no + * gaps, and the tiles are read in the order they are placed. */ function createMosaic(columns: number) { const rows: boolean[][] = []; @@ -108,14 +99,10 @@ function createMosaic(columns: number) { } /** - * Returns the footprint the entry at `index` would *like*, before it's clamped - * to the grid. - * - * Variety is composed, not tiered: a repeating 11-step rhythm spreads tall and - * wide accents through the feed instead of bucketing every card by score. The - * rhythm is content-aware — only dumps with a thumbnail are allowed to claim - * the image-forward `tall`/`wide`/`feature` footprints; everything else stays a - * compact `square` so quote and text cards never stretch awkwardly. + * Returns the shape the dump at `index` would take before `fit` clamps it. An + * 11-step rhythm spreads large tiles through the feed, rather than sizing them + * by score. Only a dump with an image takes a large tile, bar an occasional + * `wide` quote, so that quote and text tiles don't stretch. */ function desiredShape(dump: Dump, index: number): JournalShape { const thumb = hasThumbnail(dump); @@ -127,19 +114,15 @@ function desiredShape(dump: Dump, index: number): JournalShape { if (beat === 0 || beat === 5) return "wide"; return "square"; } - // No thumbnail: an occasional wide quote keeps the rhythm from flattening. if (beat === 3) return "wide"; return "square"; } /** - * Shrinks `shape` until it fits the `free` cells actually on offer, with - * `remaining` cards left to place. - * - * Two cuts. Horizontally, a 2-column card that meets a 1-column gap gives up a - * column rather than wrapping — wrapping is what leaves a hole behind. And a - * card within a row of the end drops its second row: nothing is left to fill - * the cells beside a portrait that hangs off the bottom of the feed. + * Shrinks `shape` to fit the `free` cells of its row, with `remaining` tiles + * left to place. A 2-column tile facing one free cell loses a column, since + * wrapping would leave a hole. A tile near the end loses its second row, since + * no tiles would be left to fill the cells beside it. */ function fit( shape: JournalShape, @@ -154,11 +137,9 @@ function fit( } /** - * Returns `sorted` with a picture in the lead. A section front leads with a - * picture: when the hottest dump is a bare note, the first piece of art from - * just behind it is promoted rather than opening the page on an empty banner — - * the top few are within noise of each other on score anyway, and which one - * leads is an editorial call, not a ranking one. + * Returns `sorted` led by a dump with an image. When the hottest has none, the + * first one with an image among the next three moves to the front, rather than + * opening the page on an empty banner. Their scores are close anyway. */ function leadWithArt(sorted: Dump[]): Dump[] { if (sorted.length < 2 || hasThumbnail(sorted[0])) return sorted; @@ -168,31 +149,15 @@ function leadWithArt(sorted: Dump[]): Dump[] { } /** - * Sorts `dumps` by hotness, then lays them out as an editorial mosaic: one hero - * feature followed by an asymmetric mix of banners, portraits, squares and - * pull-quotes, composed so the four columns close on every row — and cut into - * sub-blocks so the feed breathes instead of running on as one slab. + * Sorts `dumps` by {@link hotScore} and lays them out as a mosaic whose rows + * all close, cut into blocks. `JournalFeed` calls it on every change of the + * feed. * - * A break can only fall where a row both starts clean and isn't reached into - * from above, which the exact tiling makes cheap to test: every row closes - * flush, so the only boundaries ruled out are the ones a portrait or a hero - * straddles. Past that, two things set the rhythm. - * - * It comes due on a count of *cards*, not of rows. A block is a set of stories, - * and a hero is one story however many cells it takes, so a stretch thick with - * big footprints reaches the count over more rows than a stretch of squares - * does. Block heights vary with the size mix rather than beating out a fixed - * measure — and because the mix is itself content-driven (only dumps with art - * may claim the big footprints), so is the rhythm. - * - * And the break *makes* its own section front rather than waiting for one: the - * card that opens a new block is promoted to a hero. Waiting was the mistake — - * a banner almost never lands at the start of a row on its own, so a rule - * preferring one would almost never fire. Promotion always fits: the break only - * falls on a row with all four cells free. - * - * The whole thing stays a pure function of the ranking, so no break shuffles - * under the reader when infinite scroll appends the next page. + * A block ends once it holds 12 tiles, at the next row that starts empty and + * that no tall tile reaches into. The tile opening the next block is made a + * `feature`, or a `wide` without an image, which always fits a row that + * starts empty. The layout depends only on the ranking, so when the next page + * appends dumps that rank lower, only the last block changes. */ export function composeJournal(dumps: Dump[]): JournalBlock[] { const ordered = leadWithArt( @@ -206,10 +171,8 @@ export function composeJournal(dumps: Dump[]): JournalBlock[] { ordered.forEach((dump, index) => { const slot = mosaic.next(); const remaining = ordered.length - index - 1; - // The tail guard: cutting within a few cards of the end leaves a rule - // with a fragment under it, which reads as a mistake rather than as a - // division. Only the bottom of the feed can be affected, and that's the - // part infinite scroll is about to extend anyway. + // No break within 6 tiles of the end, which would leave a stub of a block + // under the rule. Infinite scroll extends the end anyway. const breaking = entries.length >= BLOCK_CARDS && remaining >= BLOCK_CARDS / 2 && slot.c === 0 && diff --git a/src/utils/player/bandcamp.ts b/src/utils/player/bandcamp.ts index 36eb1aad..2e5e7868 100644 --- a/src/utils/player/bandcamp.ts +++ b/src/utils/player/bandcamp.ts @@ -1,5 +1,7 @@ /** - * Turning a Bandcamp page into a queue for the site's own player. + * Turns a Bandcamp page into a queue of streams for the site's own player. + * `usePlayRichContent` calls it when a Bandcamp link is played in native mode, + * and `streamSources.ts` again when the streams' signed URLs expire. * * @module */ @@ -8,23 +10,25 @@ import { API_URL } from "../../config/api.ts"; import type { PlayerItem } from "../../contexts/player/PlayerContext.ts"; import type { Tralbum, TralbumTrack } from "../../../shared/player.ts"; -/** What the queue's items carry besides the tracks. */ +/** + * Passed to {@link resolveBandcampQueue} by its callers and copied onto every + * item of the queue, since the server's tracklist knows nothing of the dump. + */ export interface BandcampContext { - /** The page of the dump the release was posted in. */ dumpHref?: string; - /** Kept on every item so playback can fall back to the iframe later. */ + /** Lets PlayerProvider fall back to Bandcamp's iframe if the streams fail. */ embedUrl?: string; /** Used only if the page carries no album art of its own. */ fallbackArtworkUrl?: string; } /** - * Resolves the Bandcamp page `pageUrl` into a playable queue, its items - * carrying `ctx`. `force` skips the server's cache, for re-resolving after a - * signature expires. + * Resolves the Bandcamp page `pageUrl` into one `stream` item per streamable + * track, through `GET /api/bandcamp/tracks`, each carrying `ctx`. `force` + * bypasses the server's cache, for re-resolving once the signed URLs expire. * - * @throws {Error} On any failure, no streamable track included — callers treat - * that as "fall back to the embed". + * @throws {Error} When the request fails or the page has no streamable track. + * Callers then fall back to the embed. */ export async function resolveBandcampQueue( pageUrl: string, @@ -50,7 +54,7 @@ export async function resolveBandcampQueue( kind: "stream", streamUrl: t.streamUrl, type: "bandcamp", - // The row names the track; the artist rides along on the subtitle line. + // The header and the tracklist name the track; the artist goes below. title: t.title, subtitle: album.artist, duration: t.duration, diff --git a/src/utils/player/duration.ts b/src/utils/player/duration.ts index 078beef4..c36d90d2 100644 --- a/src/utils/player/duration.ts +++ b/src/utils/player/duration.ts @@ -1,11 +1,12 @@ /** - * Track lengths for display. + * Formats track lengths for the media player and the global player's tracklist. * * @module */ /** - * Returns `s` seconds as m:ss. Shared by the media player and the player queue. + * Returns `s` seconds as m:ss, or 0:00 when `s` isn't finite, as a media + * element's duration is until its metadata loads. */ export function fmt(s: number): string { if (!isFinite(s)) return "0:00"; diff --git a/src/utils/player/streamSources.ts b/src/utils/player/streamSources.ts index 095b86df..b3f684fc 100644 --- a/src/utils/player/streamSources.ts +++ b/src/utils/player/streamSources.ts @@ -1,5 +1,7 @@ /** - * Keeping a stream item's signed URL fresh. + * Keeps stream items' signed URLs fresh. PlayerProvider calls it when a stream + * fails or a restored session's stream is stale, without knowing which service + * the stream comes from. * * @module */ @@ -8,10 +10,9 @@ import type { PlayerItem } from "../../contexts/player/PlayerContext.ts"; import { resolveBandcampQueue } from "./bandcamp.ts"; /** - * Re-resolves the queue stream `item` belongs to, for when its signed URL - * expires. Keeping the dispatch here is what lets PlayerProvider handle expiry - * without knowing that Bandcamp exists. The promise rejects when the stream's - * type has no resolver, or the resolver fails. + * Re-resolves the whole queue that stream `item` came from, bypassing the + * server's cache. The promise rejects when the stream's type has no resolver + * here, or when its resolver fails. */ export function resolveStreamQueue( item: Extract<PlayerItem, { kind: "stream" }>, @@ -32,14 +33,15 @@ export function resolveStreamQueue( } /** - * How old a stream's signed URL may get. Signed URLs live 24h; re-resolve - * before that to avoid a stall mid-play. + * How old a stream's signed URL may get before {@link isStreamStale} says so. + * Bandcamp's live about 24 hours, and this leaves room for a long listen. */ export const STREAM_STALE_MS = 20 * 60 * 60 * 1000; /** * Checks whether the signed URL of `item` is older than {@link - * STREAM_STALE_MS}. + * STREAM_STALE_MS}. PlayerProvider refreshes a restored session's stream when + * it is, so that the first press of play doesn't stall. */ export function isStreamStale( item: Extract<PlayerItem, { kind: "stream" }>, diff --git a/src/utils/player/volume.ts b/src/utils/player/volume.ts index a80356c7..5a93ece4 100644 --- a/src/utils/player/volume.ts +++ b/src/utils/player/volume.ts @@ -1,18 +1,15 @@ /** - * The player's volume, remembered across tracks and reloads. Each queue advance - * remounts MediaPlayer (the element is keyed on the source), which would - * otherwise snap the volume back to 100% on every track. It is held outside the - * component and mirrored to localStorage so it also survives a reload. + * Remembers the media player's volume across tracks and reloads. GlobalPlayer + * keys MediaPlayer on its source, so every track mounts a new media element at + * full volume; MediaPlayer reads the last volume from here on mount and writes + * back every change, which localStorage keeps across reloads. * * @module */ -/** The localStorage key of the volume. */ const VOLUME_KEY = "player-volume"; -/** The last volume, 0 to 1. */ let lastVolume = 1; -/** Whether the last player was muted. */ let lastMuted = false; try { const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null"); @@ -21,21 +18,24 @@ try { lastMuted = stored.muted === true; } } catch { - // Malformed or unavailable storage — the defaults above stand. + // Unreadable or blocked storage leaves the defaults. } -/** Remembers `volume` and `muted` for the next player. */ +/** + * Remembers `volume` and `muted` for the next media element, and in + * localStorage when it is available. + */ export function rememberVolume(volume: number, muted: boolean) { lastVolume = volume; lastMuted = muted; try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted })); } catch { - // Private mode or blocked storage — volume just won't persist. + // Blocked storage: the volume lasts until the page is reloaded. } } -/** Returns the volume and mute state the last player left behind. */ +/** Returns the volume and mute state last remembered, or full and unmuted. */ export function storedVolume(): { volume: number; muted: boolean } { return { volume: lastVolume, muted: lastMuted }; } diff --git a/src/utils/player/waveform.ts b/src/utils/player/waveform.ts index 4822c208..da2f39f4 100644 --- a/src/utils/player/waveform.ts +++ b/src/utils/player/waveform.ts @@ -1,10 +1,12 @@ /** - * The waveform's geometry, and the peaks it draws, decoded from the audio. + * Decodes an audio file into the peaks of its waveform, and sets the waveform's + * geometry. MediaPlayer's `Waveform` and the dump page's audio preview + * (`FilePreview`) both draw with it. * * @module */ -/** How many bars the waveform draws. */ +/** How many bars the waveform draws, one per peak. */ export const NUM_BARS = 150; /** A bar's width, in viewBox units. */ export const BAR_W = 3; @@ -13,16 +15,17 @@ export const BAR_GAP = 1; /** The waveform's height, in viewBox units. */ export const WAVEFORM_H = 48; /** The waveform's width, in viewBox units. */ -export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP); // 600 +export const VIEWBOX_W = NUM_BARS * (BAR_W + BAR_GAP); /** - * The peaks per URL, at module level: survives StrictMode double-effect and - * re-renders. + * Kept at module level, so that remounts and StrictMode's double effects don't + * fetch and decode a file again. */ const peaksCache = new Map<string, Float32Array>(); /** - * Returns `n` peaks of the audio at `url`, fetched and decoded once per URL. + * Returns `n` peaks of the audio at `url`, each the loudest sample of its slice + * of the first channel. Each URL is fetched and decoded once per page load. * * @throws {Error} When the fetch or the decoding fails. */ @@ -32,10 +35,8 @@ export async function extractPeaks( ): Promise<Float32Array> { if (peaksCache.has(url)) return peaksCache.get(url)!; - // /api/files is public (no auth middleware). Plain fetch avoids the CORS - // preflight that credentials/Authorization would trigger — the server's - // oakCors config doesn't set Allow-Credentials so credentialed fetches - // are blocked, while <audio> bypasses CORS entirely. + // No credentials: the server's CORS doesn't allow them, and the file URL + // carries the viewer's media token already. <audio> needs no CORS, this does. const res = await fetch(url); if (!res.ok) throw new Error(`fetch failed: ${res.status}`); const buf = await res.arrayBuffer(); diff --git a/src/utils/player/youtube.ts b/src/utils/player/youtube.ts index 0ad5d664..9c821b43 100644 --- a/src/utils/player/youtube.ts +++ b/src/utils/player/youtube.ts @@ -1,6 +1,6 @@ /** - * YouTube playlists in the player: the embed's list, its IFrame API parameters, - * and the tracklist. + * Reads, prepares and lists YouTube playlist embeds, for GlobalPlayer and + * useYouTubePlaylist to show and drive a playlist's tracklist. * * @module */ @@ -9,10 +9,9 @@ import { API_URL } from "../../config/api.ts"; import type { YouTubePlaylistTrack } from "../../../shared/player.ts"; /** - * Returns the playlist `embedUrl` plays, if any. - * - * Covers both embed shapes the provider produces: `/embed/videoseries?list=…` - * for a bare playlist, and `/embed/<id>?list=…` for a video opened in one. + * Returns the id of the playlist `embedUrl` plays, or null. It reads both + * shapes the server's YouTube embeds take, `/embed/videoseries?list=…` for a + * bare playlist and `/embed/<id>?list=…` for a video opened in one. */ export function embedListId(embedUrl: string): string | null { try { @@ -23,12 +22,10 @@ export function embedListId(embedUrl: string): string | null { } /** - * Returns `url` with the parameters the IFrame Player API needs to talk to the - * embed. - * - * `enablejsapi` has to be in the src the iframe was created with — it cannot be - * turned on afterwards — so it is added for every playlist embed, whether or - * not the API script ends up loading. + * Returns `url` with the parameters the IFrame Player API needs to drive the + * embed. `enablejsapi` can't be turned on after the iframe loads, so + * GlobalPlayer adds it to every playlist embed, whether or not the API script + * loads. */ export function withJsApi(url: string): string { try { @@ -42,10 +39,11 @@ export function withJsApi(url: string): string { } /** - * Returns the titles and lengths of the tracks of playlist `listId`. + * Returns the titles and lengths of the videos of playlist `listId`, from + * `GET /api/youtube/playlist`, since the embed exposes neither. * - * @throws {Error} When the fetch fails or finds no track; callers fall back to - * no list. + * @throws {Error} When the request fails or finds no video. useYouTubePlaylist + * then shows the rows without titles, if the player API lists them. */ export async function fetchPlaylistTracks( listId: string, diff --git a/src/utils/shared/api.ts b/src/utils/shared/api.ts index b0de79e6..062cb7b5 100644 --- a/src/utils/shared/api.ts +++ b/src/utils/shared/api.ts @@ -1,5 +1,6 @@ /** - * Reading the API's answers: the envelope, and the errors a request can end in. + * Reads the API's JSON envelope, and turns the errors a request can end in into + * messages. Every component and hook that calls the API goes through it. * * @module */ @@ -7,11 +8,10 @@ import type { APIResponse } from "../../../shared/api.ts"; /** - * Checks that `raw` is an API envelope and returns it as one. The check is - * minimal — the `success` discriminant — so the single internal `as` cast is - * safe. + * Returns `raw` as an API envelope. Only the `success` field is checked, so + * the payload's type is the caller's word. * - * @throws {Error} When `raw` isn't an envelope. + * @throws {Error} When `raw` has no `success` field. */ export function parseAPIResponse<T>(raw: unknown): APIResponse<T> { if (raw !== null && typeof raw === "object" && "success" in raw) { @@ -21,8 +21,9 @@ export function parseAPIResponse<T>(raw: unknown): APIResponse<T> { } /** - * Returns a human-readable message for `err`, an error caught around a fetch. A - * `TypeError` means the request never left the client (no network, CORS, …). + * Returns the message to show for `err`, an error caught around a request. A + * `TypeError` means the request never reached the server (no network, CORS), + * and gets a message saying so. The messages are not translated. */ export function friendlyFetchError(err: unknown): string { if (err instanceof TypeError) { @@ -32,12 +33,11 @@ export function friendlyFetchError(err: unknown): string { } /** - * Reads `res` through the API envelope and returns the payload. A thrown error - * here (or a network `TypeError` from the `fetch` itself) is caught by - * `useApiForm`'s `submit` and rendered as the form's root error. + * Returns the payload of `res`. In a form, the error it throws reaches + * `useApiForm`'s `submit`, which shows it as the form's error. * - * @throws {Error} The server's message, when the response says `success: - * false`; also when the body isn't an envelope. + * @throws {Error} With the server's message when the response says + * `success: false`, and when the body isn't JSON or isn't an envelope. */ export async function expectOk<T>(res: Response): Promise<T> { const body = parseAPIResponse<T>(await res.json()); diff --git a/src/utils/shared/charCount.ts b/src/utils/shared/charCount.ts index 5702b214..3731b406 100644 --- a/src/utils/shared/charCount.ts +++ b/src/utils/shared/charCount.ts @@ -1,12 +1,14 @@ /** - * The character counter's states, for fields with a length limit. + * Gives the character counters of the text fields and `TextEditor` their + * warning states. * * @module */ /** - * Returns the counter's modifier class for `len` characters out of `max`: warn - * from 85%, danger at the limit, none below. + * Returns the counter's modifier class for `len` characters out of `max`, with + * a leading space so that it appends to `text-editor-count`. It warns from 85% + * of `max` and turns to danger at `max`. */ export function charCountClass(len: number, max: number): string { if (len >= max) return " text-editor-count--danger"; @@ -15,8 +17,8 @@ export function charCountClass(len: number, max: number): string { } /** - * Checks whether `value` is past `max` — the counter's danger state past its - * edge, and what the server would refuse. + * Checks whether `value` is longer than `max`, which the server would refuse. + * Only the tests call it. */ export function overLimit(value: string, max: number): boolean { return value.length > max; diff --git a/src/utils/shared/deserialize.ts b/src/utils/shared/deserialize.ts index ca52b580..d79c71c8 100644 --- a/src/utils/shared/deserialize.ts +++ b/src/utils/shared/deserialize.ts @@ -1,7 +1,8 @@ /** - * Turning what the API, the socket and localStorage hand back — JSON, where - * dates are ISO strings (see {@link Wire}) — back into the shared types, with - * Dates. + * Turns the JSON that the API, the socket and the browser's storage hand back, + * where dates are ISO strings ({@link Wire}), into the shared types with + * `Date`s. Every reader of those sources calls these on what it receives. The + * shape is not checked. * * @module */ @@ -24,7 +25,6 @@ import type { SelfUser, } from "../../../shared/user.ts"; -/** Returns `raw` with `createdAt` and `updatedAt` as Dates. */ function dates<T extends { createdAt: string; updatedAt?: string }>(raw: T) { return { ...raw, @@ -38,7 +38,10 @@ export function deserializeDump(raw: Wire<Dump>): Dump { return dates(raw); } -/** Returns `raw`, a dump from JSON of unchecked type, as a {@link Dump}. */ +/** + * Returns `raw` as a {@link Dump}, for `useFeedCache`, which reads it back + * untyped from sessionStorage. + */ export function hydrateDump(raw: unknown): Dump { return deserializeDump(raw as Wire<Dump>); } @@ -91,7 +94,8 @@ export function deserializePlaylist(raw: Wire<Playlist>): Playlist { } /** - * Returns `raw`, a playlist from JSON of unchecked type, as a {@link Playlist}. + * Returns `raw` as a {@link Playlist}, for `useFeedCache`, which reads it back + * untyped from sessionStorage. */ export function hydratePlaylist(raw: unknown): Playlist { return deserializePlaylist(raw as Wire<Playlist>); diff --git a/src/utils/shared/format.ts b/src/utils/shared/format.ts index c6d4bebe..d9bdd818 100644 --- a/src/utils/shared/format.ts +++ b/src/utils/shared/format.ts @@ -1,10 +1,14 @@ /** - * Formatting file sizes and file names for display. + * Formats a file dump's size and name for display, in the file fields, the + * previews and the dump creation dialog. * * @module */ -/** Returns `bytes` as a size in B, KB or MB. */ +/** + * Returns `bytes` as a size in B, KB or MB, where a KB is 1024 bytes. The units + * are not translated. + */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -12,11 +16,11 @@ export function formatBytes(bytes: number): string { } /** - * Returns the file name `name` as a readable dump title: the extension goes (it - * is noise in a feed, and the dump already carries its MIME type), and - * underscore runs become spaces, so `holiday_photo-01.final.jpg` reads as - * `holiday photo-01.final`. Names that are *only* an extension (`.gitignore`) - * are left alone. + * Returns the file name `name` as the title the dump creation dialog proposes. + * The extension goes, since the dump keeps its MIME type, and runs of + * underscores become spaces, so `holiday_photo-01.final.jpg` reads as + * `holiday photo-01.final`. A name that is only an extension (`.gitignore`) + * stays as it is. */ export function titleFromFileName(name: string): string { const withoutExt = name.replace(/(?!^)\.[^.]+$/, ""); diff --git a/src/utils/shared/proxyImage.ts b/src/utils/shared/proxyImage.ts index dcc7d8a8..ce89961f 100644 --- a/src/utils/shared/proxyImage.ts +++ b/src/utils/shared/proxyImage.ts @@ -1,15 +1,13 @@ /** - * Routing third-party images through the API's image proxy. + * Routes other sites' images through the API's image proxy, + * `GET /api/proxy-image`, for `Thumbnail` and `ThumbnailPlaceholder`. * * @module */ import { API_URL } from "../../config/api.ts"; -/** - * Returns the API's origin — `API_URL` is relative ("") when it is the page's - * own. - */ +/** Returns the API's origin. `API_URL` is "" when it is the page's own. */ function apiOrigin(): string { try { return new URL(API_URL, location.href).origin; @@ -19,9 +17,9 @@ function apiOrigin(): string { } /** - * Checks whether `url` points at a third party, i.e. whether proxying it would - * actually change anything. Our own files and thumbnails are served with no - * `Referer` checks, so retrying them through the proxy is pure waste. + * Checks whether `url` points at another site, where proxying it could help. + * `Thumbnail` retries through the proxy only then, since the site's own files + * and thumbnails would fail the same way again. */ export function canProxyImage(url: string): boolean { try { @@ -33,23 +31,20 @@ export function canProxyImage(url: string): boolean { } } -/** How {@link proxyImageUrl} proxies. */ +/** Passed to {@link proxyImageUrl} by `Thumbnail` to retry a failed image. */ export interface ProxyImageOptions { /** - * Proxy even when the URL is already HTTPS. Used as a retry after a direct - * load fails — typically hotlink protection rejecting our cross-site - * `Referer`, which the server-side fetch doesn't send. + * Proxies an HTTPS URL too, for a retry after a direct load failed, usually + * on hotlink protection. */ force?: boolean; } /** * Returns `url` routed through `GET /api/proxy-image`, or unchanged when it - * needs no proxy. - * - * By default only HTTP URLs are proxied, so they don't trigger mixed-content - * blocks when the frontend is served over HTTPS. `opts.force` opts an HTTPS URL - * in as well. + * needs no proxy. Only an HTTP URL is proxied by default, so that an HTTPS page + * doesn't block it as mixed content. A relative or localhost URL is never + * proxied. */ export function proxyImageUrl( url: string, @@ -59,7 +54,8 @@ export function proxyImageUrl( try { u = new URL(url); } catch { - return url; // relative or same-origin path — nothing to proxy + // A relative path is the site's own. + return url; } if (u.protocol !== "http:" && u.protocol !== "https:") return url; diff --git a/src/utils/shared/relativeTime.ts b/src/utils/shared/relativeTime.ts index b8932f62..88c9ef0d 100644 --- a/src/utils/shared/relativeTime.ts +++ b/src/utils/shared/relativeTime.ts @@ -1,15 +1,20 @@ /** - * Dates as the time since them. + * Shows a date as the time since it, beside every dump, comment, message and + * notification. * * @module */ import { i18n } from "@lingui/core"; -/** Returns how long ago `date` was ("3 minutes ago"), in the current locale. */ +/** + * Returns how long ago `date` was, as "3 minutes ago", in the current locale. + * It is computed at render, so the text only moves on when the caller + * re-renders. A month counts 30 days and a year 365. + */ export function relativeTime(date: Date): string { const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }); - const diff = date.getTime() - Date.now(); // negative = past + const diff = date.getTime() - Date.now(); const abs = Math.abs(diff) / 1000; if (abs < 60) return rtf.format(-Math.round(abs), "second"); diff --git a/src/utils/shared/sound.ts b/src/utils/shared/sound.ts index 991be3de..dd5f115f 100644 --- a/src/utils/shared/sound.ts +++ b/src/utils/shared/sound.ts @@ -1,29 +1,32 @@ /** - * Light UI sounds for live notifications and chat, synthesised with Web Audio - * so there's no asset to ship or cache. Two voices: a single soft blip for a - * chat message, and a two-note rising chime for a notification (a mention, a - * follow, an upvote…). + * Plays the sounds of live events, which `WSProvider` triggers, and stores + * whether they are on. They are synthesised with Web Audio, so there's no file + * to ship: a soft blip for a chat message, and a rising two-note chime for a + * notification. * * @module */ -/** The localStorage key of the sounds setting. */ const STORAGE_KEY = "sounds"; /** - * Shared across tabs: the id of the last event that chimed, so a user with the - * site open in several tabs hears one chime per event rather than one per tab. + * Holds, in localStorage, the id of the last event that chimed, so that a user + * with the site open in several tabs hears one chime per event, not one per + * tab. */ const CLAIM_KEY = "sounds-last-claim"; /** - * Milliseconds between chat blips. A burst of chat messages shouldn't - * machine-gun blips; one per window is enough to say "the chat is moving". + * The least time between chat blips, in milliseconds. One blip per window says + * that the chat is moving, and a burst of messages shouldn't blip for each. */ const CHAT_THROTTLE = 4_000; -/** Which chime: the chat's blip or a notification's. */ +/** Passed to {@link playChime} and {@link previewChime} to pick the sound. */ export type ChimeKind = "chat" | "notify"; -/** Returns whether sounds are on — the default when nothing is stored. */ +/** + * Returns whether sounds are on in this browser, which they are unless turned + * off. {@link playChime} checks it on every play. + */ export function readSoundsEnabled(): boolean { try { return localStorage.getItem(STORAGE_KEY) !== "off"; @@ -32,18 +35,16 @@ export function readSoundsEnabled(): boolean { } } -/** Stores whether sounds are on. */ +/** Stores whether sounds are on, for `useSoundsEnabled`'s setter. */ export function writeSoundsEnabled(enabled: boolean): void { try { localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off"); } catch { - // Private mode / blocked storage: the choice just won't persist. + // With storage blocked, the choice just won't persist. } } -/** The page's audio context, made on first use. */ let ctx: AudioContext | null = null; -/** When the last chat blip played, in epoch ms. */ let lastChatAt = 0; /** @@ -61,10 +62,9 @@ function audio(): AudioContext | null { } /** - * Resumes the audio context on the page's first user gesture. Browsers keep an - * AudioContext suspended until the page sees one, so without this a chime - * arriving later — possibly while the tab is in the background — couldn't be - * heard. + * Resumes the audio context on the page's first user gesture. Browsers keep it + * suspended until the page sees one, and a chime arriving later, perhaps in a + * background tab, couldn't resume it. */ function unlock() { const ac = audio(); @@ -75,13 +75,16 @@ function unlock() { globalThis.addEventListener("pointerdown", unlock); globalThis.addEventListener("keydown", unlock); -/** Claims `eventId` for this tab, and returns whether no other tab had. */ +/** + * Claims `eventId` for this tab, and returns whether no other tab had. Two tabs + * checking at the same instant can both win. + */ function claim(eventId: string): boolean { try { if (localStorage.getItem(CLAIM_KEY) === eventId) return false; localStorage.setItem(CLAIM_KEY, eventId); } catch { - // No storage: every tab chimes, which is the lesser evil. + // Without storage every tab chimes, which is the lesser evil. } return true; } @@ -101,7 +104,7 @@ function tone( const gain = ac.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(freq, start); - // Fast attack, exponential tail: a struck, glassy note rather than a beep. + // A fast attack and an exponential tail give a struck note, not a beep. gain.gain.setValueAtTime(0.0001, start); gain.gain.exponentialRampToValueAtTime(peak, start + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); @@ -110,7 +113,10 @@ function tone( osc.stop(start + duration + 0.02); } -/** Plays the chime `kind`, if the audio context is running. */ +/** + * Plays the chime `kind` if the audio context is running. It stays silent + * until the first user gesture has unlocked it. + */ function render(kind: ChimeKind) { const ac = audio(); if (!ac || ac.state !== "running") return; @@ -124,8 +130,9 @@ function render(kind: ChimeKind) { } /** - * Plays a chime for a live event, unless sounds are off, another tab already - * chimed for the same event, or (for chat) one played a moment ago. + * Plays the chime for a live event, unless sounds are off, another tab already + * chimed for `eventId`, or a chat blip played less than 4 seconds ago. The + * caller must pass an id that is the same in every tab, such as the message's. */ export function playChime(kind: ChimeKind, eventId: string): void { if (!readSoundsEnabled()) return; @@ -138,7 +145,10 @@ export function playChime(kind: ChimeKind, eventId: string): void { render(kind); } -/** Plays a chime unconditionally — used to preview it from the settings. */ +/** + * Plays the chime `kind` whatever the setting, for the profile page's settings + * to preview it. It is called from a click, which unlocks the audio. + */ export function previewChime(kind: ChimeKind): void { const ac = audio(); if (!ac) return; diff --git a/src/utils/shared/thumbnailTint.ts b/src/utils/shared/thumbnailTint.ts index 9c65e66f..05f3e579 100644 --- a/src/utils/shared/thumbnailTint.ts +++ b/src/utils/shared/thumbnailTint.ts @@ -1,35 +1,24 @@ /** - * Color and lettering for generated thumbnail placeholders. - * - * The tint is never painted on its own — it's mixed into the theme's surface - * color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for - * any site color, in every style, in both light and dark. + * Picks the colour and the letter of `ThumbnailPlaceholder`. The CSS mixes the + * tint into the theme's surface (`--thumb-tint`) rather than painting it alone, + * so the text keeps its contrast whatever the site's colour and the theme. * * @module */ /** - * Color and lettering for generated thumbnail placeholders. - * - * The tint is never painted on its own — it's mixed into the theme's surface - * color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for - * any site color, in every style, in both light and dark. - */ - -/** - * Checks whether `value` is a `#rrggbb` color. `accentColor` comes from a - * dumped page's `theme-color`, so it's attacker-controlled. Custom properties - * bypass CSS value parsing and get substituted verbatim wherever the var is - * used, so the canonical form is re-checked here before it ever reaches a style - * object. + * Checks whether `value` is a lowercase `#rrggbb` colour. An `accentColor` + * comes from a dumped page's `theme-color`, which anyone controls, and a custom + * property is substituted verbatim wherever it is used, unparsed. So the form + * is checked here before it reaches a style. */ export function isSafeHex(value: string | undefined): value is string { return !!value && /^#[0-9a-f]{6}$/.test(value); } /** - * Returns the FNV-1a hash of `seed` — small, stable, and well spread across the - * hue circle. + * Returns the FNV-1a hash of `seed`, which is small, stable, and well spread + * across the hue circle. */ function hashString(seed: string): number { let hash = 0x811c9dc5; @@ -41,13 +30,13 @@ function hashString(seed: string): number { } /** - * Returns a stable hue for `seed`, so the same site always gets the same tint. + * Returns a colour of a hue drawn from `seed`, so that the same site always + * gets the same tint. */ export function hueFromString(seed: string): string { return `hsl(${hashString(seed) % 360} 55% 55%)`; } -/** Returns the hostname of `url` without `www.`, undefined when it has none. */ function hostnameOf(url: string | undefined): string | undefined { if (!url) return undefined; try { @@ -58,10 +47,9 @@ function hostnameOf(url: string | undefined): string | undefined { } /** - * Returns the page's own brand color when it declared a usable one, otherwise a - * hue derived from its hostname — so every card gets a tint, including rows - * saved before accent extraction existed. File dumps have no hostname to hash - * and pass `seed` (their filename) instead. + * Returns the page's own colour when it declared a valid one, or else a hue + * drawn from `seed` or the hostname of `url`, so that every card gets a tint, + * even for a dump saved before colours were extracted. */ export function tintFor( { accentColor, url, seed }: { @@ -75,8 +63,8 @@ export function tintFor( } /** - * Returns the letter drawn when there's no favicon to show: the first letter or - * digit of `siteName`, else of the hostname of `url`. + * Returns the letter drawn when there's no favicon to show, the first letter or + * digit of `siteName`, or else of the hostname of `url`, or "?". */ export function initialsFor( siteName: string | undefined, diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 1bff2cd3..67ce5b8d 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -1,28 +1,31 @@ /** - * The site's own URLs: pages, uploaded files, thumbnails and covers. + * Builds the site's own URLs, for its pages and for the files, thumbnails and + * covers the API serves. Every link and media element of the frontend takes + * them from here. * * @module */ import { API_URL } from "../../config/api.ts"; -/** What a dump's file and thumbnail URLs are built from. */ +/** + * Passed to {@link dumpFileUrl} and {@link dumpThumbnailUrl}, usually as a + * whole `Dump`. Without the dates, the URL carries no current version and the + * browser revalidates it. + */ export type VersionedDump = { - /** The dump's id. */ id: string; - /** The uploaded file's size in bytes. */ fileSize?: number; - /** When the dump was posted. */ createdAt?: Date; - /** When the dump was last edited. */ updatedAt?: Date; }; /** - * Returns a file dump's version, as the server computes it (dumpFileVersion in - * api/services/dump-service.ts): it changes whenever the file can have. A URL - * carrying the current one is cached by the browser for good; any other is - * revalidated. + * Returns a file dump's version, as `dumpFileVersion` in + * api/services/dump-service.ts computes it, so the two must change together. + * It changes whenever the file can have. The server lets the browser cache a + * URL that carries the current version for good, and has it revalidate any + * other. */ export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string { const at = dump.updatedAt ?? dump.createdAt; @@ -30,10 +33,10 @@ export function dumpFileVersion(dump: Omit<VersionedDump, "id">): string { } /** - * Returns the direct URL to the uploaded file of `dump`. The viewer's `token` - * is passed as a query param — media elements (`<audio>`, `<img>`, …) can't - * send an `Authorization` header — so the server can authorize access to - * private dumps. Same approach as the WebSocket connection (`?token=`). + * Returns the URL of the uploaded file of `dump`. The caller passes the + * session's media token (`useAuth`'s `mediaToken`) as `token`, which goes in + * the query string since a media element can't send a header. The server needs + * it for a private dump's file. */ export function dumpFileUrl( dump: VersionedDump, @@ -44,10 +47,10 @@ export function dumpFileUrl( } /** - * Returns the thumbnail URL of `dump`, with the viewer's `token` (see {@link - * dumpFileUrl}): the custom thumbnail if it has one, else what the server makes - * from the file — a video still, or a scaled-down copy of an image for cards to - * show instead of the full upload. + * Returns the URL of the thumbnail of `dump`, with the media `token` as for + * {@link dumpFileUrl}. The server answers with the custom thumbnail if the dump + * has one, or else with one made from the file, a video still or a scaled-down + * image. */ export function dumpThumbnailUrl( dump: VersionedDump, @@ -63,8 +66,8 @@ export function dumpThumbnailUrl( } /** - * Returns `input` as an absolute URL: trimmed, and `https://` added when it has - * no scheme. + * Returns what a user typed as a link, trimmed, with `https://` added when it + * has no http(s) scheme. The result is not checked to be a valid URL. */ export function normalizeUrl(input: string): string { const s = input.trim(); @@ -73,21 +76,21 @@ export function normalizeUrl(input: string): string { return `https://${s}`; } -/** Returns the page of `dump`, by slug when it has one. */ +/** Returns the path of the page of `dump`, by slug when it has one. */ export function dumpUrl(dump: { id: string; slug?: string }): string { return `/dumps/${dump.slug ?? dump.id}`; } -/** Returns the page of `playlist`, by slug when it has one. */ +/** Returns the path of the page of `playlist`, by slug when it has one. */ export function playlistUrl(playlist: { id: string; slug?: string }): string { return `/playlists/${playlist.slug ?? playlist.id}`; } /** - * Returns the cover URL of `playlist`. Versioned on `updatedAt`, which a new - * cover bumps, so a replaced image isn't served from the browser's cache. A - * private playlist's cover carries the viewer's `token` (see {@link - * dumpFileUrl}); a public one doesn't, so its URL is the same for everyone. + * Returns the URL of the cover of `playlist`, versioned on `updatedAt`, which a + * new cover bumps. A private playlist's URL carries the media `token`, as for + * {@link dumpFileUrl}. A public one doesn't, so that its URL is the same for + * everyone. A playlist without `isPublic` counts as public. */ export function playlistImageUrl( playlist: { id: string; updatedAt?: Date; isPublic?: boolean }, diff --git a/src/utils/shared/visited.ts b/src/utils/shared/visited.ts index 6d1a2bd9..e7695b97 100644 --- a/src/utils/shared/visited.ts +++ b/src/utils/shared/visited.ts @@ -1,17 +1,15 @@ /** - * Which dumps and playlists this browser has opened, for the unread dot. + * Remembers in localStorage which dumps and playlists this browser has opened, + * for the unread dot on the dump and playlist cards. * * @module */ -/** The localStorage key of the visited dumps. */ const DUMP_KEY = "visited_dumps"; -/** The localStorage key of the visited playlists. */ const PLAYLIST_KEY = "visited_playlists"; -/** How recent an item must be to show the unread dot: 7 days. */ -const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +/** The age past which an item shows no unread dot, visited or not. */ +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; -/** Returns the ids stored under `key`. */ function loadSet(key: string): Set<string> { try { const raw = localStorage.getItem(key); @@ -21,14 +19,16 @@ function loadSet(key: string): Set<string> { } } -/** Stores `set` under `key`; a full storage is ignored. */ function saveSet(key: string, set: Set<string>): void { try { localStorage.setItem(key, JSON.stringify([...set])); - } catch { /* quota exceeded — ignore */ } + } catch { /* With storage full or blocked, the visit is forgotten. */ } } -/** Checks whether dump `id` was opened in this browser. */ +/** + * Checks whether dump `id` was opened in this browser. It parses the whole + * stored list on every call. + */ export function isDumpVisited(id: string): boolean { return loadSet(DUMP_KEY).has(id); } @@ -38,14 +38,17 @@ export function isPlaylistVisited(id: string): boolean { return loadSet(PLAYLIST_KEY).has(id); } -/** Records dump `id` as opened. */ +/** + * Records dump `id` as opened. Nothing re-renders, so a card already on screen + * keeps its dot until it renders again. + */ export function markDumpVisited(id: string): void { const set = loadSet(DUMP_KEY); set.add(id); saveSet(DUMP_KEY, set); } -/** Records playlist `id` as opened. */ +/** Records playlist `id` as opened, as {@link markDumpVisited} does a dump. */ export function markPlaylistVisited(id: string): void { const set = loadSet(PLAYLIST_KEY); set.add(id); @@ -53,8 +56,8 @@ export function markPlaylistVisited(id: string): void { } /** - * Checks whether `createdAt` is recent enough for the unread dot: only items - * newer than {@link MAX_AGE_MS} show it. + * Checks whether `createdAt` is less than 7 days old. Older items show no + * unread dot, since an old item not opened here isn't news. */ export function isRecent(createdAt: Date): boolean { return Date.now() - createdAt.getTime() < MAX_AGE_MS;