v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
49
shared/api.ts
Normal file
49
shared/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* The envelope every endpoint answers with, and the error codes it carries.
|
||||
*/
|
||||
|
||||
export const APIErrorCode = {
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
SERVER_ERROR: "SERVER_ERROR",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
} as const;
|
||||
|
||||
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
|
||||
|
||||
export interface APIError {
|
||||
code: APIErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface APISuccess<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
error?: never;
|
||||
}
|
||||
|
||||
export interface APIFailure {
|
||||
success: false;
|
||||
data?: never;
|
||||
error: APIError;
|
||||
}
|
||||
|
||||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||||
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
/** The whole list's size — only where it's shown (search results). */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `T` as it arrives over JSON (HTTP, the socket, localStorage): every Date is
|
||||
* an ISO string.
|
||||
*/
|
||||
export type Wire<T> = T extends Date ? string
|
||||
: T extends readonly (infer U)[] ? Wire<U>[]
|
||||
: T extends object ? { [K in keyof T]: Wire<T[K]> }
|
||||
: T;
|
||||
20
shared/category.ts
Normal file
20
shared/category.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
slug: string;
|
||||
name: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
slug?: string;
|
||||
name?: string;
|
||||
position?: number;
|
||||
}
|
||||
16
shared/chat.ts
Normal file
16
shared/chat.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
userId: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
authorUsername: string;
|
||||
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). */
|
||||
replyToId?: string;
|
||||
/** Author/snippet 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;
|
||||
replyToBody?: string;
|
||||
}
|
||||
22
shared/comment.ts
Normal file
22
shared/comment.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface Comment {
|
||||
id: string;
|
||||
dumpId: string;
|
||||
userId: string;
|
||||
parentId?: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
deleted: boolean;
|
||||
likeCount: number;
|
||||
authorUsername: string;
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
body: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCommentRequest {
|
||||
body: string;
|
||||
}
|
||||
88
shared/dump.ts
Normal file
88
shared/dump.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export interface RichContent {
|
||||
type: string;
|
||||
url: string;
|
||||
siteName?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/**
|
||||
* A real preview image (og:image, a large content image, a video still).
|
||||
* Never a favicon — an absent value means "this page offers no artwork",
|
||||
* which is what makes the generated placeholder possible.
|
||||
*/
|
||||
thumbnailUrl?: string;
|
||||
/** The page's own icon, used as the placeholder's glyph. */
|
||||
faviconUrl?: string;
|
||||
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||
accentColor?: string;
|
||||
videoId?: string;
|
||||
embedUrl?: string;
|
||||
}
|
||||
|
||||
export interface Dump {
|
||||
id: string;
|
||||
kind: "url" | "file";
|
||||
title: string;
|
||||
slug?: string;
|
||||
comment?: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
url?: string;
|
||||
richContent?: RichContent;
|
||||
fileName?: string;
|
||||
fileMime?: string;
|
||||
fileSize?: number;
|
||||
voteCount: number;
|
||||
commentCount: number;
|
||||
isPrivate: boolean;
|
||||
thumbnailMime?: string;
|
||||
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`.
|
||||
*/
|
||||
export interface DumpUrlMatch {
|
||||
id: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
voteCount: number;
|
||||
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".
|
||||
*/
|
||||
export interface LinkPreview {
|
||||
reached: boolean;
|
||||
richContent: RichContent | null;
|
||||
}
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||
title?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateDumpRequest {
|
||||
url?: string;
|
||||
title?: string;
|
||||
/** `null` removes the note; leaving it out keeps it. */
|
||||
comment?: string | null;
|
||||
isPrivate?: boolean;
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
/** `POST /api/attachments`: the id the uploaded image is served under. */
|
||||
export interface AttachmentResponse {
|
||||
id: string;
|
||||
}
|
||||
4
shared/follow.ts
Normal file
4
shared/follow.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface FollowStatus {
|
||||
followedUserIds: string[];
|
||||
followedPlaylistIds: string[];
|
||||
}
|
||||
4
shared/mention.ts
Normal file
4
shared/mention.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Matches @username not already inside a markdown link ([...] or (...)) and
|
||||
// not mid-word, so an email address doesn't mention its domain. The server
|
||||
// notifies whoever it matches; the chat highlights the same names.
|
||||
export const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
86
shared/notification.ts
Normal file
86
shared/notification.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export type NotificationType =
|
||||
| "playlist_followed"
|
||||
| "user_followed"
|
||||
| "user_dump_posted"
|
||||
| "playlist_dump_added"
|
||||
| "dump_upvoted"
|
||||
| "user_mentioned"
|
||||
| "dump_commented"
|
||||
| "comment_liked";
|
||||
|
||||
export interface PlaylistFollowedData {
|
||||
followerId: string;
|
||||
followerUsername: string;
|
||||
playlistId: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
export interface UserFollowedData {
|
||||
followerId: string;
|
||||
followerUsername: string;
|
||||
}
|
||||
|
||||
export interface UserDumpPostedData {
|
||||
dumperId: string;
|
||||
dumperUsername: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export interface PlaylistDumpAddedData {
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
playlistId: string;
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
export interface DumpUpvotedData {
|
||||
voterId: string;
|
||||
voterUsername: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export interface UserMentionedData {
|
||||
mentionerId: string;
|
||||
mentionerUsername: string;
|
||||
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||
contextId: string;
|
||||
contextTitle: string;
|
||||
dumpId?: string;
|
||||
}
|
||||
|
||||
export interface DumpCommentedData {
|
||||
commenterId: string;
|
||||
commenterUsername: string;
|
||||
commentId: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export interface CommentLikedData {
|
||||
likerId: string;
|
||||
likerUsername: string;
|
||||
commentId: string;
|
||||
dumpId: string;
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
export type NotificationData =
|
||||
| PlaylistFollowedData
|
||||
| UserFollowedData
|
||||
| UserDumpPostedData
|
||||
| PlaylistDumpAddedData
|
||||
| DumpUpvotedData
|
||||
| UserMentionedData
|
||||
| DumpCommentedData
|
||||
| CommentLikedData;
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
data: NotificationData;
|
||||
read: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
37
shared/permissions.ts
Normal file
37
shared/permissions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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").
|
||||
export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "chat:moderate"
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
// Roles are fixed in code. `admin` is a superset of every permission (current
|
||||
// and future); `moderator` gets the content-moderation permissions only.
|
||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
],
|
||||
user: [],
|
||||
};
|
||||
|
||||
export function hasPermission(role: Role, permission: Permission): boolean {
|
||||
return ROLE_PERMISSIONS[role].includes(permission);
|
||||
}
|
||||
41
shared/player.ts
Normal file
41
shared/player.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */
|
||||
export interface TralbumTrack {
|
||||
/** Position in the parsed array — the stable handle used to re-resolve a
|
||||
* track after its signed URL expires. */
|
||||
index: number;
|
||||
trackNum?: number;
|
||||
title: string;
|
||||
/** Seconds, fractional. */
|
||||
duration?: number;
|
||||
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. */
|
||||
streamable: boolean;
|
||||
/** Bandcamp caps free plays per track; a capped track may serve a clip. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
export interface Tralbum {
|
||||
sourceUrl: string;
|
||||
itemType: "album" | "track";
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artworkUrl?: string;
|
||||
tracks: TralbumTrack[];
|
||||
/** Epoch ms of the fetch that produced these signed URLs. */
|
||||
resolvedAt: number;
|
||||
}
|
||||
|
||||
/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */
|
||||
export interface YouTubePlaylistTrack {
|
||||
videoId: string;
|
||||
title: string;
|
||||
/** Seconds. */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface YouTubePlaylist {
|
||||
listId: string;
|
||||
title?: string;
|
||||
tracks: YouTubePlaylistTrack[];
|
||||
}
|
||||
40
shared/playlist.ts
Normal file
40
shared/playlist.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Dump } from "./dump.ts";
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
imageMime?: string;
|
||||
dumpCount?: number;
|
||||
ownerUsername?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistWithDumps extends Playlist {
|
||||
dumps: Dump[];
|
||||
}
|
||||
|
||||
export interface PlaylistMembership {
|
||||
playlist: Playlist;
|
||||
hasDump: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePlaylistRequest {
|
||||
title: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlaylistRequest {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface ReorderPlaylistRequest {
|
||||
dumpIds: string[];
|
||||
}
|
||||
10
shared/search.ts
Normal file
10
shared/search.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { PaginatedData } from "./api.ts";
|
||||
import type { Dump } from "./dump.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
export interface SearchResults {
|
||||
dumps: PaginatedData<Dump>;
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
}
|
||||
13
shared/test/permissions.test.ts
Normal file
13
shared/test/permissions.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { assert, assertEquals, assertFalse } from "@std/assert";
|
||||
import { hasPermission, ROLE_PERMISSIONS } from "../permissions.ts";
|
||||
|
||||
Deno.test("permissions: admin has all, moderator moderates, user nothing", () => {
|
||||
for (const perm of ROLE_PERMISSIONS.admin) {
|
||||
assert(hasPermission("admin", perm));
|
||||
}
|
||||
assert(hasPermission("moderator", "dump:moderate"));
|
||||
assert(hasPermission("moderator", "chat:moderate"));
|
||||
assertFalse(hasPermission("moderator", "user:manage"));
|
||||
assertFalse(hasPermission("moderator", "category:manage"));
|
||||
assertEquals(ROLE_PERMISSIONS.user, []);
|
||||
});
|
||||
8
shared/test/user.test.ts
Normal file
8
shared/test/user.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { assert, assertFalse } from "@std/assert";
|
||||
import { isRole } from "../user.ts";
|
||||
|
||||
Deno.test("isRole", () => {
|
||||
assert(isRole("user") && isRole("moderator") && isRole("admin"));
|
||||
assertFalse(isRole("root"));
|
||||
assertFalse(isRole(undefined));
|
||||
});
|
||||
77
shared/user.ts
Normal file
77
shared/user.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type Role = "user" | "moderator" | "admin";
|
||||
|
||||
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
|
||||
|
||||
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. */
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
/** A user as they see themselves: everything but the password hash. */
|
||||
export interface SelfUser extends PublicUser {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** A match for the @mention autocomplete. */
|
||||
export interface UserSuggestion {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime: string | null;
|
||||
}
|
||||
|
||||
/** Someone a user invited, directly or down the line. */
|
||||
export interface Invitee {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime?: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LoginUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
inviteToken: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
username?: string;
|
||||
password?: string;
|
||||
role?: Role;
|
||||
description?: string | null;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** The session token, plus the media-only one for file and thumbnail URLs. */
|
||||
export interface AuthTokens {
|
||||
token: string;
|
||||
mediaToken: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse extends AuthTokens {
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">;
|
||||
|
||||
export interface InviteResponse {
|
||||
token: string;
|
||||
}
|
||||
19
shared/validation.ts
Normal file
19
shared/validation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Limits the server enforces and the forms check before sending. */
|
||||
export const VALIDATION = {
|
||||
USERNAME_MIN: 1,
|
||||
USERNAME_MAX: 32,
|
||||
PASSWORD_MIN: 8,
|
||||
PASSWORD_MAX: 128,
|
||||
DUMP_TITLE_MAX: 200,
|
||||
DUMP_COMMENT_MAX: 5000,
|
||||
PLAYLIST_TITLE_MAX: 100,
|
||||
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||
COMMENT_BODY_MAX: 5000,
|
||||
USER_DESCRIPTION_MAX: 2000,
|
||||
CATEGORY_NAME_MAX: 50,
|
||||
CATEGORY_SLUG_MAX: 50,
|
||||
CHAT_MESSAGE_MAX: 2000,
|
||||
} as const;
|
||||
|
||||
/** Largest file a file dump may hold. */
|
||||
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
297
shared/ws.ts
Normal file
297
shared/ws.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { ChatMessage } from "./chat.ts";
|
||||
import type { Comment } from "./comment.ts";
|
||||
import type { Dump } from "./dump.ts";
|
||||
import type { Notification } from "./notification.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ── Client → Server ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PingMessage {
|
||||
type: "ping";
|
||||
}
|
||||
|
||||
export interface PongMessage {
|
||||
type: "pong";
|
||||
}
|
||||
|
||||
export interface VoteCastMessage {
|
||||
type: "vote_cast";
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
export interface VoteRemoveMessage {
|
||||
type: "vote_remove";
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
export interface CommentLikeCastMessage {
|
||||
type: "comment_like_cast";
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface CommentLikeRemoveMessage {
|
||||
type: "comment_like_remove";
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface ChatSendMessage {
|
||||
type: "chat_send";
|
||||
body: string;
|
||||
/** Id of the message being replied to, if this is a reply. */
|
||||
replyToId?: string;
|
||||
/** Client-chosen id echoed on the broadcast (or on the error), so the
|
||||
* sender can match the outcome to its pending message. */
|
||||
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 ChatTypingUpdate.
|
||||
export interface ChatTypingMessage {
|
||||
type: "chat_typing";
|
||||
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.
|
||||
export interface ChatFocusMessage {
|
||||
type: "chat_focus";
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface ChatEditMessage {
|
||||
type: "chat_edit";
|
||||
id: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChatDeleteMessage {
|
||||
type: "chat_delete";
|
||||
id: string;
|
||||
}
|
||||
|
||||
// The user has seen the chat up to the message created at `at`.
|
||||
export interface ChatReadMessage {
|
||||
type: "chat_read";
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type ClientToServerMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| VoteCastMessage
|
||||
| VoteRemoveMessage
|
||||
| CommentLikeCastMessage
|
||||
| CommentLikeRemoveMessage
|
||||
| ChatSendMessage
|
||||
| ChatTypingMessage
|
||||
| ChatFocusMessage
|
||||
| ChatEditMessage
|
||||
| ChatDeleteMessage
|
||||
| ChatReadMessage;
|
||||
|
||||
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface OnlineUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
hasAvatar: boolean;
|
||||
avatarVersion?: number;
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
type: "welcome";
|
||||
users: OnlineUser[];
|
||||
myVotes: string[];
|
||||
myCommentLikes: string[];
|
||||
unreadNotificationCount: number;
|
||||
/** The user's chat read marker and the unread count behind it (signed-in
|
||||
* users only). */
|
||||
chatReadAt?: string;
|
||||
unreadChatCount: number;
|
||||
}
|
||||
|
||||
export interface PresenceUpdateMessage {
|
||||
type: "presence_update";
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
export interface VotesUpdateMessage {
|
||||
type: "votes_update";
|
||||
dumpId: string;
|
||||
voteCount: number;
|
||||
voterId: string;
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
export interface VoteAckMessage {
|
||||
type: "vote_ack";
|
||||
dumpId: string;
|
||||
action: "cast" | "remove";
|
||||
voteCount: number;
|
||||
}
|
||||
|
||||
export interface CommentLikesUpdateMessage {
|
||||
type: "comment_likes_update";
|
||||
commentId: string;
|
||||
likeCount: number;
|
||||
likerId: string;
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
export interface CommentLikeAckMessage {
|
||||
type: "comment_like_ack";
|
||||
commentId: string;
|
||||
action: "cast" | "remove";
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
export interface DumpCreatedMessage {
|
||||
type: "dump_created";
|
||||
dump: Dump;
|
||||
}
|
||||
|
||||
export interface DumpUpdatedMessage {
|
||||
type: "dump_updated";
|
||||
dump: Dump;
|
||||
}
|
||||
|
||||
export interface DumpDeletedMessage {
|
||||
type: "dump_deleted";
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
export interface PlaylistCreatedMessage {
|
||||
type: "playlist_created";
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
export interface PlaylistUpdatedMessage {
|
||||
type: "playlist_updated";
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
export interface PlaylistDeletedMessage {
|
||||
type: "playlist_deleted";
|
||||
playlistId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface PlaylistDumpsUpdatedMessage {
|
||||
type: "playlist_dumps_updated";
|
||||
playlistId: string;
|
||||
/** The members this recipient can see, in order. */
|
||||
dumpIds: string[];
|
||||
/** Every member, as Playlist.dumpCount counts them. */
|
||||
dumpCount: number;
|
||||
}
|
||||
|
||||
export interface UserUpdatedMessage {
|
||||
type: "user_updated";
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface CommentCreatedMessage {
|
||||
type: "comment_created";
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentUpdatedMessage {
|
||||
type: "comment_updated";
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentDeletedMessage {
|
||||
type: "comment_deleted";
|
||||
commentId: string;
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
export interface NotificationCreatedMessage {
|
||||
type: "notification_created";
|
||||
notification: Notification;
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: "error";
|
||||
message?: string;
|
||||
// Context for optimistic-action failures so the client can revert the exact
|
||||
// pending vote/like immediately instead of waiting for its ACK timeout.
|
||||
dumpId?: string;
|
||||
commentId?: string;
|
||||
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. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface ForceLogoutMessage {
|
||||
type: "force_logout";
|
||||
}
|
||||
|
||||
export interface ChatMessageMessage {
|
||||
type: "chat_message";
|
||||
message: ChatMessage;
|
||||
/** The sender's nonce, so its pending copy can be retired. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageUpdatedMessage {
|
||||
type: "chat_message_updated";
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
export interface ChatMessageDeletedMessage {
|
||||
type: "chat_message_deleted";
|
||||
id: string;
|
||||
}
|
||||
|
||||
// The current set of users composing a chat message. Reuses OnlineUser so the
|
||||
// client can render the same avatars as the presence row.
|
||||
export interface ChatTypingUpdateMessage {
|
||||
type: "chat_typing_update";
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
// The user's read marker moved (from any of their tabs or devices).
|
||||
export interface ChatReadUpdateMessage {
|
||||
type: "chat_read_update";
|
||||
readAt: string;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| WelcomeMessage
|
||||
| PresenceUpdateMessage
|
||||
| VotesUpdateMessage
|
||||
| VoteAckMessage
|
||||
| CommentLikesUpdateMessage
|
||||
| CommentLikeAckMessage
|
||||
| DumpCreatedMessage
|
||||
| DumpUpdatedMessage
|
||||
| DumpDeletedMessage
|
||||
| PlaylistCreatedMessage
|
||||
| PlaylistUpdatedMessage
|
||||
| PlaylistDeletedMessage
|
||||
| PlaylistDumpsUpdatedMessage
|
||||
| UserUpdatedMessage
|
||||
| CommentCreatedMessage
|
||||
| CommentUpdatedMessage
|
||||
| CommentDeletedMessage
|
||||
| NotificationCreatedMessage
|
||||
| ErrorMessage
|
||||
| ForceLogoutMessage
|
||||
| ChatMessageMessage
|
||||
| ChatMessageUpdatedMessage
|
||||
| ChatMessageDeletedMessage
|
||||
| ChatTypingUpdateMessage
|
||||
| ChatReadUpdateMessage;
|
||||
Reference in New Issue
Block a user