Files
gerbeur/api/model/interfaces.ts
khannurien 364c498a24
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:05:29 +00:00

1026 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { VALIDATION } from "../config.ts";
/**
* Backend
*/
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[];
}
/**
* Categories
*/
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;
}
export function isCreateCategoryRequest(
obj: unknown,
): obj is CreateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if (typeof o.slug !== "string") return false;
if (typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
export function isUpdateCategoryRequest(
obj: unknown,
): obj is UpdateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("slug" in o && typeof o.slug !== "string") return false;
if ("name" in o && typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
/**
* Authentication
*/
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);
}
export interface User {
id: string;
username: string;
passwordHash: string;
role: Role;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
description?: string;
invitedByUsername?: string;
email: string;
}
/** A user as anyone may see them: no password hash, no email. */
export type PublicUser = Omit<User, "passwordHash" | "email">;
/** A user as they see themselves: everything but the password hash. */
export type SelfUser = Omit<User, "passwordHash">;
export function toPublicUser(
{ passwordHash: _, email: _email, ...user }: User,
): PublicUser {
return user;
}
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
return user;
}
/** 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;
}
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";
}
/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */
export function fieldOf(obj: unknown, key: string): unknown {
return obj !== null && typeof obj === "object" && key in obj
? (obj as Record<string, unknown>)[key]
: undefined;
}
export function isRegisterUserRequest(
obj: unknown,
): obj is RegisterUserRequest {
return validateRegisterUserRequest(obj) === null;
}
/** Returns a human-readable error string, or null if the request is valid. */
export function validateRegisterUserRequest(obj: unknown): string | null {
if (
!obj || typeof obj !== "object" ||
!("username" in obj) || typeof obj.username !== "string" ||
!("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string" ||
!("email" in obj) || typeof obj.email !== "string"
) return "Invalid request";
const { username, password, email } = obj as RegisterUserRequest;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "Invalid email address";
}
if (
!new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
)
.test(username)
) {
return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`;
}
if (password.length < VALIDATION.PASSWORD_MIN) {
return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`;
}
if (password.length > VALIDATION.PASSWORD_MAX) {
return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`;
}
return null;
}
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("username" in o) {
if (typeof o.username !== "string") return false;
if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false;
}
if ("password" in o) {
if (typeof o.password !== "string") return false;
const len = (o.password as string).length;
if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) {
return false;
}
}
if ("role" in o && !isRole(o.role)) return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if ("email" in o) {
if (typeof o.email !== "string") return false;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false;
}
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
) return false;
return true;
}
/** 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;
}
export interface AttachmentResponse {
id: string;
}
export interface AuthPayload {
userId: string;
username: string;
role: Role;
exp: number;
iat?: number;
}
export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj &&
typeof obj === "object" &&
// Purpose-scoped tokens (password reset, media) are never session tokens.
!("purpose" in obj) &&
"userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" &&
"role" in obj && isRole(obj.role) &&
"exp" in obj && typeof obj.exp === "number";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;
exp: number;
}
export function isPasswordResetPayload(
obj: unknown,
): obj is PasswordResetPayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj &&
(obj as Record<string, unknown>).purpose === "password-reset" &&
"userId" in obj &&
typeof (obj as Record<string, unknown>).userId === "string";
}
/**
* API
*/
export enum APIErrorCode {
BAD_REQUEST = "BAD_REQUEST",
NOT_FOUND = "NOT_FOUND",
SERVER_ERROR = "SERVER_ERROR",
TIMEOUT = "TIMEOUT",
UNAUTHORIZED = "UNAUTHORIZED",
VALIDATION_ERROR = "VALIDATION_ERROR",
}
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[];
total: number;
hasMore: boolean;
}
export interface SearchResults {
dumps: PaginatedData<Dump>;
users: PublicUser[];
playlists: Playlist[];
}
/**
* `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 class APIException extends Error {
readonly code: APIErrorCode;
readonly status: number;
constructor(code: APIErrorCode, status: number, message: string) {
super(message);
this.code = code;
this.status = status;
}
}
/**
* Comments
*/
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;
}
/**
* Chat
*/
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;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawChatMessage = Omit<ChatMessage, "createdAt"> & {
createdAt: string;
};
export function isCreateCommentRequest(
obj: unknown,
): obj is CreateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX &&
(!("parentId" in o) || typeof o.parentId === "string" ||
o.parentId === null);
}
export interface UpdateCommentRequest {
body: string;
}
export function isUpdateCommentRequest(
obj: unknown,
): obj is UpdateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" &&
(o.body as string).trim().length > 0 &&
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX;
}
/**
* Playlists
*/
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[];
}
export function isCreatePlaylistRequest(
obj: unknown,
): obj is CreatePlaylistRequest {
if (
!obj || typeof obj !== "object" ||
!("title" in obj) || typeof obj.title !== "string" ||
!("isPublic" in obj) || typeof obj.isPublic !== "boolean"
) return false;
const o = obj as Record<string, unknown>;
if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
return true;
}
export function isUpdatePlaylistRequest(
obj: unknown,
): obj is UpdatePlaylistRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("title" in o) {
if (typeof o.title !== "string") return false;
if (
(o.title as string).length === 0 ||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
) return false;
}
if (
"description" in o && typeof o.description !== "string" &&
o.description !== null
) return false;
if (
typeof o.description === "string" &&
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return false;
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
return true;
}
export function isReorderPlaylistRequest(
obj: unknown,
): obj is ReorderPlaylistRequest {
return !!obj && typeof obj === "object" &&
"dumpIds" in obj && Array.isArray(obj.dumpIds) &&
(obj.dumpIds as unknown[]).every((id) => typeof id === "string");
}
/**
* Request DTOs
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
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[];
}
/**
* An existing dump pointing at the same URL, as shown by the create form's
* duplicate hint. Display-only projection — see `findDumpsByUrl`.
*/
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export function isCreateUrlDumpRequest(
obj: unknown,
): obj is CreateUrlDumpRequest {
if (
!obj || typeof obj !== "object" ||
!("url" in obj) || typeof obj.url !== "string"
) return false;
const o = obj as Record<string, unknown>;
if (
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("title" in o && typeof o.title !== "string" && o.title !== null) {
return false;
}
if (
typeof o.title === "string" &&
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}
export interface UpdateDumpRequest {
url?: string;
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("url" in o && typeof o.url !== "string" && o.url !== null) return false;
if ("title" in o) {
if (typeof o.title !== "string") return false;
const trimmed = (o.title as string).trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return false;
}
if (
"comment" in o && typeof o.comment !== "string" && o.comment !== null
) return false;
if (
typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}
/**
* WebSockets
*/
// ── 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: RawNotification;
}
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;
/**
* Follows
*/
export interface FollowStatus {
followedUserIds: string[];
followedPlaylistIds: string[];
}
/**
* Notifications
*/
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;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawNotification = Omit<Notification, "createdAt"> & {
createdAt: string;
};