v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -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>[]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[];
}

View File

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

View File

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

View File

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

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

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

View File

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

View File

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