/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …". A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it. A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
530 lines
15 KiB
TypeScript
530 lines
15 KiB
TypeScript
/**
|
|
* Live updates, votes, likes and the chat travel over one WebSocket between
|
|
* `WSProvider` in the client and `routes/ws.ts` in the server. This module
|
|
* types its messages, 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
|
|
*/
|
|
|
|
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";
|
|
|
|
/**
|
|
* 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 {
|
|
type: "ping";
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "pong";
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "vote_cast";
|
|
dumpId: string;
|
|
}
|
|
|
|
/** Sent by `WSProvider` to withdraw an upvote, as {@link VoteCastMessage}. */
|
|
export interface VoteRemoveMessage {
|
|
type: "vote_remove";
|
|
dumpId: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by `WSProvider` when the user likes a comment, and handled as
|
|
* {@link VoteCastMessage} is, with {@link CommentLikeAckMessage} for the ack.
|
|
*/
|
|
export interface CommentLikeCastMessage {
|
|
type: "comment_like_cast";
|
|
commentId: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by `WSProvider` to withdraw a like, as {@link CommentLikeCastMessage}.
|
|
*/
|
|
export interface CommentLikeRemoveMessage {
|
|
type: "comment_like_remove";
|
|
commentId: string;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_send";
|
|
body: string;
|
|
/** Ignored if longer than 64 characters or naming no message. */
|
|
replyToId?: string;
|
|
/**
|
|
* 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 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 {
|
|
type: "chat_typing";
|
|
typing: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_focus";
|
|
/** True only while the chatbox is open in a visible tab. */
|
|
open: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_edit";
|
|
id: string;
|
|
body: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by `WSProvider` when the user deletes a chat message, as
|
|
* {@link ChatEditMessage}. The server broadcasts
|
|
* {@link ChatMessageDeletedMessage}.
|
|
*/
|
|
export interface ChatDeleteMessage {
|
|
type: "chat_delete";
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_read";
|
|
at: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by `WSProvider`, and dispatched by the route's `handleMessage`, which
|
|
* checks each field's type before using it.
|
|
*/
|
|
export type ClientToServerMessage =
|
|
| PingMessage
|
|
| PongMessage
|
|
| VoteCastMessage
|
|
| VoteRemoveMessage
|
|
| CommentLikeCastMessage
|
|
| CommentLikeRemoveMessage
|
|
| ChatSendMessage
|
|
| ChatTypingMessage
|
|
| ChatFocusMessage
|
|
| ChatEditMessage
|
|
| ChatDeleteMessage
|
|
| ChatReadMessage;
|
|
|
|
/**
|
|
* 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 {
|
|
userId: string;
|
|
username: string;
|
|
hasAvatar: boolean;
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "welcome";
|
|
users: OnlineUser[];
|
|
myVotes: string[];
|
|
myCommentLikes: string[];
|
|
unreadNotificationCount: number;
|
|
/** Absent on an anonymous socket. */
|
|
chatReadAt?: string;
|
|
unreadChatCount: number;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server to every socket when a socket opens or closes, and when
|
|
* a connected user's avatar changes.
|
|
*/
|
|
export interface PresenceUpdateMessage {
|
|
type: "presence_update";
|
|
users: OnlineUser[];
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "votes_update";
|
|
dumpId: string;
|
|
voteCount: number;
|
|
voterId: string;
|
|
action: "cast" | "remove";
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "vote_ack";
|
|
dumpId: string;
|
|
action: "cast" | "remove";
|
|
voteCount: number;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "comment_likes_update";
|
|
commentId: string;
|
|
likeCount: number;
|
|
likerId: string;
|
|
action: "cast" | "remove";
|
|
}
|
|
|
|
/**
|
|
* Sent by the server to the socket that liked or unliked a comment, as
|
|
* {@link VoteAckMessage} is for a vote.
|
|
*/
|
|
export interface CommentLikeAckMessage {
|
|
type: "comment_like_ack";
|
|
commentId: string;
|
|
action: "cast" | "remove";
|
|
likeCount: number;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a dump is posted, to every socket if it is public
|
|
* and to its poster's alone if it is private. It also goes to everyone but the
|
|
* poster when a private dump goes public, since to them it is new.
|
|
* `WSProvider` puts it at the top of its recent dumps.
|
|
*/
|
|
export interface DumpCreatedMessage {
|
|
type: "dump_created";
|
|
dump: Dump;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a dump changes, to every socket if it is public and
|
|
* to its poster's alone if it is private, as when it has just gone private.
|
|
* `WSProvider` refreshes it in place, without moving it up the feeds.
|
|
*/
|
|
export interface DumpUpdatedMessage {
|
|
type: "dump_updated";
|
|
dump: Dump;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a dump is deleted, to every socket if it was public
|
|
* and to its poster's alone if it was private. It also goes to everyone but
|
|
* the poster when a public dump goes private. `WSProvider` drops it from every
|
|
* list.
|
|
*/
|
|
export interface DumpDeletedMessage {
|
|
type: "dump_deleted";
|
|
dumpId: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when the dumps related to some dumps changed: a note or
|
|
* a comment gained or lost a link to a dump, or a dump that has such links
|
|
* turned public. It goes to the sockets whose user may see both ends of a
|
|
* link. The `Dump` page showing one of `dumpIds` fetches its related dumps
|
|
* again.
|
|
*/
|
|
export interface DumpRelatedChangedMessage {
|
|
type: "dump_related_changed";
|
|
/**
|
|
* The dump whose links changed, then the dumps at their other end, without
|
|
* those this socket's user may not see.
|
|
*/
|
|
dumpIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a playlist is created, to every socket if it is
|
|
* public, else to its owner's. It also goes to everyone but the owner when a
|
|
* private playlist goes public, since to them it is new. `WSProvider` passes
|
|
* it on to the playlist pages as a `playlist` event, as it does the three
|
|
* messages below.
|
|
*/
|
|
export interface PlaylistCreatedMessage {
|
|
type: "playlist_created";
|
|
playlist: Playlist;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a playlist changes, to every socket if it is public,
|
|
* else to its owner's, as when it has just gone private. Its `dumpCount` and
|
|
* `lastDumpAt` are the socket's user's, as in `playlist_created`.
|
|
*/
|
|
export interface PlaylistUpdatedMessage {
|
|
type: "playlist_updated";
|
|
playlist: Playlist;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a playlist is deleted, to those who could see it. It
|
|
* also goes to everyone but the owner when a public playlist goes private.
|
|
*/
|
|
export interface PlaylistDeletedMessage {
|
|
type: "playlist_deleted";
|
|
playlistId: string;
|
|
userId: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when dumps are added to, removed from or reordered in a
|
|
* playlist, to those who can see it, and when one of its dumps is deleted or
|
|
* changes visibility, which changes what its viewers see of it.
|
|
*/
|
|
export interface PlaylistDumpsUpdatedMessage {
|
|
type: "playlist_dumps_updated";
|
|
playlistId: string;
|
|
/**
|
|
* Only the dumps this socket's user may see, in order: another user's
|
|
* private dump is left out.
|
|
*/
|
|
dumpIds: string[];
|
|
/**
|
|
* When the latest of them was added, the playlist's `lastDumpAt` for this
|
|
* socket's user. It is missing when `dumpIds` is empty.
|
|
*/
|
|
lastDumpAt?: Date;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server to every socket after a profile edit, a rename, a role
|
|
* change or an avatar upload. `WSProvider` refreshes the user's avatar
|
|
* wherever it shows, and passes it on as a `user` event, which updates their
|
|
* profile and their name on the lists.
|
|
*/
|
|
export interface UserUpdatedMessage {
|
|
type: "user_updated";
|
|
user: PublicUser;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server when a comment is posted, to every socket if its dump is
|
|
* public, else to the dump's poster's. `WSProvider` passes it on to the dump's
|
|
* page and the dump lists as a `comment` event, as it does the two messages
|
|
* below.
|
|
*/
|
|
export interface CommentCreatedMessage {
|
|
type: "comment_created";
|
|
comment: Comment;
|
|
}
|
|
|
|
/** Sent by the server when a comment is edited, as when one is posted. */
|
|
export interface CommentUpdatedMessage {
|
|
type: "comment_updated";
|
|
comment: Comment;
|
|
}
|
|
|
|
/** Sent by the server when a comment is deleted, as when one is posted. */
|
|
export interface CommentDeletedMessage {
|
|
type: "comment_deleted";
|
|
commentId: string;
|
|
/** Lets a dump's page ignore the comments of other dumps. */
|
|
dumpId: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by the notification service to every socket of the notified user.
|
|
* `WSProvider` bumps the unread count and chimes.
|
|
*/
|
|
export interface NotificationCreatedMessage {
|
|
type: "notification_created";
|
|
notification: Notification;
|
|
}
|
|
|
|
/**
|
|
* Sent by the notification service to every socket of a user who opened their
|
|
* notifications, which marks them all read. `WSProvider` zeroes the bell's
|
|
* count, in the other tabs and devices too.
|
|
*/
|
|
export interface NotificationsReadMessage {
|
|
type: "notifications_read";
|
|
}
|
|
|
|
/**
|
|
* Sent by the category service to every socket after a category is created,
|
|
* edited or deleted. `CategoriesProvider` fetches the categories again.
|
|
*/
|
|
export interface CategoriesChangedMessage {
|
|
type: "categories_changed";
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "error";
|
|
/** In English: the chatbox shows it untranslated. */
|
|
message?: string;
|
|
dumpId?: string;
|
|
commentId?: string;
|
|
action?: "cast" | "remove";
|
|
scope?: "chat";
|
|
/** Set only on the failure of a {@link ChatSendMessage} that had one. */
|
|
nonce?: string;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_message";
|
|
message: ChatMessage;
|
|
/**
|
|
* The sender's {@link ChatSendMessage} nonce, by which its client swaps the
|
|
* pending copy for this one.
|
|
*/
|
|
nonce?: string;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server to every signed-in socket when a chat message is edited.
|
|
*/
|
|
export interface ChatMessageUpdatedMessage {
|
|
type: "chat_message_updated";
|
|
message: ChatMessage;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_message_deleted";
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_typing_update";
|
|
users: OnlineUser[];
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
type: "chat_read_update";
|
|
readAt: string;
|
|
/** Counts only the messages of other users after `readAt`. */
|
|
unread: number;
|
|
}
|
|
|
|
/**
|
|
* Sent by the server to every socket of a user who followed or unfollowed a
|
|
* user or a playlist, so that `FollowProvider` in their other tabs refetches
|
|
* the follow status.
|
|
*/
|
|
export interface FollowsChangedMessage {
|
|
type: "follows_changed";
|
|
}
|
|
|
|
/**
|
|
* Sent by `services/ws-service.ts` and the WebSocket route, and handled by
|
|
* `WSProvider`'s `onmessage`.
|
|
*/
|
|
export type ServerToClientMessage =
|
|
| PingMessage
|
|
| PongMessage
|
|
| WelcomeMessage
|
|
| PresenceUpdateMessage
|
|
| VotesUpdateMessage
|
|
| VoteAckMessage
|
|
| CommentLikesUpdateMessage
|
|
| CommentLikeAckMessage
|
|
| DumpCreatedMessage
|
|
| DumpUpdatedMessage
|
|
| DumpDeletedMessage
|
|
| DumpRelatedChangedMessage
|
|
| PlaylistCreatedMessage
|
|
| PlaylistUpdatedMessage
|
|
| PlaylistDeletedMessage
|
|
| PlaylistDumpsUpdatedMessage
|
|
| UserUpdatedMessage
|
|
| CommentCreatedMessage
|
|
| CommentUpdatedMessage
|
|
| CommentDeletedMessage
|
|
| NotificationCreatedMessage
|
|
| NotificationsReadMessage
|
|
| CategoriesChangedMessage
|
|
| ErrorMessage
|
|
| ChatMessageMessage
|
|
| ChatMessageUpdatedMessage
|
|
| ChatMessageDeletedMessage
|
|
| ChatTypingUpdateMessage
|
|
| ChatReadUpdateMessage
|
|
| FollowsChangedMessage;
|