Files
gerbeur/src/contexts/ws/WSContext.ts
khannurien 4468438ba7 v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:08:59 +00:00

385 lines
14 KiB
TypeScript

/**
* Live updates from the server reach the pages through the three contexts
* declared here: votes and likes, dumps, playlists, comments, profiles and
* notifications in one, the chat in another, who is online in the third. They
* are split so that a feed doesn't re-render with every chat message or
* presence change. `WSProvider` fills them from its WebSocket, and components
* read them with `useWS`, `useWSChat` and `useWSPresence`.
*
* @module
*/
import { createContext } from "react";
import type { ChatMessage } from "../../../shared/chat.ts";
import type { Comment } from "../../../shared/comment.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Notification } from "../../../shared/notification.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
/**
* Keeps a user's upvoted list live, on their profile's overview and tab.
* `WSProvider` sets it as `lastVoteEvent` on each `votes_update`, and
* `useUpvotedList` adds or drops the dump.
*/
export interface VoteEvent {
dumpId: string;
voterId: string;
action: "cast" | "remove";
}
/**
* Keeps the playlists on screen up to date, in lists, on a playlist's page and
* on a dump's page. `WSProvider` sets it as `lastPlaylistEvent` on each
* `playlist_*` message; lists apply it through `usePlaylistListSync`, a
* playlist's page through `usePlaylist`. Which fields are set depends on
* `type`.
*/
export interface PlaylistEvent {
type: "created" | "updated" | "deleted" | "dumps_updated";
playlistId: string;
/** Set when created or updated. */
playlist?: Playlist;
/** The owner's id, set when deleted. */
userId?: string;
/**
* Set when the dumps changed. It lists only the dumps this user may see, in
* order.
*/
dumpIds?: string[];
/** Set when the dumps changed. It counts private dumps too. */
dumpCount?: number;
}
/**
* Keeps comment threads and comment counts live. `WSProvider` sets it as
* `lastCommentEvent` on each `comment_*` message; the `Dump` page applies it
* to its thread, and `useDumpListSync` to the counts on dump cards.
*/
export interface CommentEvent {
type: "created" | "deleted" | "updated";
dumpId: string;
/** Set when created or updated. */
comment?: Comment;
/** Set when deleted. */
commentId?: string;
}
/**
* Keeps a profile page live while its user edits their profile. `WSProvider`
* sets it as `lastUserEvent` on each `user_updated`, which the server sends on
* any change to a profile, and `useProfile` applies it.
*/
export interface UserEvent {
user: PublicUser;
}
/**
* Quotes the message a chat message replies to. `ChatModal` shows it in the
* reply bar once the user picks it, and it stays on the pending message so
* the quote shows before the server's echo. Only `id` goes to the server.
*/
export interface ChatReplyTarget {
id: string;
author: string;
body: string;
}
/**
* Shows a chat message the moment the user sends it, and lets them retry it if
* it fails. `WSProvider` holds it until the server echoes the message back, and
* `ChatModal` shows it, as `PendingItem`, after the messages the server has.
*/
export interface PendingChatMessage {
/**
* Sent with `chat_send` and returned with the echo or the error. A retry
* gets a new one, so a late echo of the first attempt can't retire it.
*/
nonce: string;
body: string;
replyTo?: ChatReplyTarget;
/** When this client sent it, to find it in the history after a reconnect. */
createdAt: Date;
/**
* `failed` when the server refused it, the socket was down or closed, or no
* echo came in 10 s. A failed message may still have reached the server.
*/
status: "sending" | "failed";
error?: string;
}
/**
* Tells the user the server refused their chat edit or delete. `WSProvider`
* sets it as `chatError`, and `ChatModal` shows it for a few seconds.
*/
export interface ChatError {
/** Tells apart two errors with the same message. */
id: number;
message: string;
}
/**
* Carries everything live but the chat and presence, for pages to lay over
* what they fetched. `WSProvider` provides it through {@link WSContext}, and
* components read it with `useWS`. Each `last*Event` field holds only the
* latest event of its kind: a consumer reacts in an effect when one changes,
* and an event that another replaces within the same render is missed. After a
* reconnect, `connectionEpoch` tells pages to fetch what they missed.
*/
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
/** Set while the socket is down, for the banner that says so. */
wsErrorMessage: string | null;
/**
* Shows vote counts live. It holds, by dump id, the counts the server pushed
* or this tab changed optimistically; a dump missing from it shows its
* fetched `voteCount`.
*/
voteCounts: Record<string, number>;
/**
* Marks the dumps this user upvoted, for their vote buttons. The server's
* `welcome` resets it on every connection.
*/
myVotes: Set<string>;
/** As `voteCounts`, for comments. */
likeCounts: Record<string, number>;
/** As `myVotes`, for comments. */
myLikes: Set<string>;
/**
* Shows new dumps on the index as they are posted, above the feed `Index`
* fetched. It holds, newest first and at most 100, the ones the server
* announced this session, which are public, and the ones this user posted,
* which may be private.
*/
recentDumps: Dump[];
/**
* Takes deleted dumps off every list at once. It holds the dumps deleted this
* session, or made private by someone else; a dump made public again leaves
* it.
*/
deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null;
/**
* Keeps a dump up to date in every list that shows it. It holds the dump as
* its latest `dump_created` or `dump_updated` has it, and a list updates it
* in place, or puts it back where it was.
*/
lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null;
deletedPlaylistIds: Set<string>;
lastCommentEvent: CommentEvent | null;
lastUserEvent: UserEvent | null;
/**
* Drives the bell's badge. It comes from the `welcome`, then each new
* notification counts it up.
*/
unreadNotificationCount: number;
/**
* Makes the bell ring, and puts the notification on the notifications page
* as it arrives.
*/
lastNotification: Notification | null;
/**
* Tells pages to fetch again what they missed while the socket was down. It
* goes up on each reconnect, never on the first connection.
*/
connectionEpoch: number;
/**
* Keeps the Follow buttons right when the user follows or unfollows in
* another tab. It goes up on every follow change, in any tab, and
* `FollowProvider` fetches the follow status again.
*/
followsVersion: number;
/**
* Upvotes a dump at once and sends `vote_cast`. The server's answer confirms
* or reverts it, and so does a timeout of 5 s, which covers a closed socket.
* `shownCount` is the count on screen, used when `voteCounts` has none yet.
* Does nothing when the user already voted.
*/
castVote: (dumpId: string, shownCount?: number) => void;
/** Withdraws an upvote, as {@link WSContextValue.castVote} casts one. */
removeVote: (dumpId: string, shownCount?: number) => void;
/** Likes a comment, as {@link WSContextValue.castVote} upvotes a dump. */
castCommentLike: (commentId: string, shownCount?: number) => void;
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
removeCommentLike: (commentId: string, shownCount?: number) => void;
/**
* Shows a dump this user just posted on the index without waiting for its
* broadcast, which a private dump never gets. `DumpCreateModal` calls it,
* and it adds the dump to `recentDumps`.
*/
injectDump: (dump: Dump) => void;
/**
* Zeroes the bell's count, once the notifications page has marked them read
* on the server.
*/
clearUnreadNotifications: () => void;
}
/**
* Carries the chat's live side, which `ChatModal` lays over the history it
* fetches and the chat buttons show as a badge. `WSProvider` provides it
* through {@link WSChatContext}, and components read it with `useWSChat`. The
* socket sends chat only to signed-in users, so for a visitor it stays empty.
*/
export interface WSChatContextValue {
/**
* Shows chat messages as they arrive. It holds the ones received this
* session, at most 200, which `ChatModal` merges with the history it
* fetches. A reconnect empties it while the chat is closed, since `ChatModal`
* fetches everything again on open.
*/
chatMessages: ChatMessage[];
/**
* Takes the messages deleted this session out of the history `ChatModal`
* fetched.
*/
deletedChatIds: Set<string>;
/**
* Drives the chat buttons' unread badge. It counts the messages from others
* after the read marker, which the server keeps, so the count agrees across
* tabs and devices.
*/
unreadChatCount: number;
/**
* Places the "new" divider when the chat opens. It holds the time of the
* newest message the user has seen, which the server keeps, and is null
* until the `welcome`, and for a visitor.
*/
chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[];
/** Only for edits and deletes. A failed send shows on its pending message. */
chatError: ChatError | null;
/**
* Shows who is typing. It includes the user themselves, whom `ChatModal`
* filters out.
*/
typingUsers: OnlineUser[];
/** Trims `body` and sends it as a pending message. Ignores a blank body. */
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
retryChatMessage: (nonce: string) => void;
discardChatMessage: (nonce: string) => void;
/**
* Sends the edit if the socket is open, else drops it. It shows once the
* server broadcasts it, or fails as `chatError`.
*/
editChatMessage: (id: string, body: string) => void;
/** Sends the deletion if the socket is open, as `editChatMessage` does. */
deleteChatMessage: (id: string) => void;
dismissChatError: () => void;
/**
* Moves the read marker up to `at`, the time of the newest message seen. It
* zeroes `unreadChatCount` at once and sends the marker at most once a
* second. A marker set offline goes out with the next `welcome`.
*/
markChatRead: (at: Date) => void;
/**
* Repairs the live buffer after a reconnect. It replaces its messages between
* `from` and `to` with `messages`, which `ChatModal` fetched. A message
* missing from them was deleted while the socket was down. A failed send
* found among them did reach the server, and leaves the pending list.
*/
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/**
* Tells the server whether the user is typing, when the socket is open.
* `ChatModal` resends `true` every few seconds while typing continues, since
* the server forgets a typer after 6 s.
*/
setChatTyping: (typing: boolean) => void;
/**
* Spares the user a notification for a mention they read live. It tells the
* server whether the chat is open in a visible tab; while it is, the server
* sends no notification for a mention, and this client doesn't count the
* message unread.
*/
setChatOpen: (open: boolean) => void;
}
/**
* Carries who is online, for `PresenceRow`. `WSProvider` provides it through
* {@link WSPresenceContext}, and components read it with `useWSPresence`.
*/
export interface WSPresenceContextValue {
/** The signed-in users with a socket open, each once. */
onlineUsers: OnlineUser[];
}
/**
* Lets a component render outside `WSProvider`, as if the socket were
* connecting.
*/
export const defaultWSValue: WSContextValue = {
wsStatus: "connecting",
wsErrorMessage: null,
voteCounts: {},
myVotes: new Set(),
likeCounts: {},
myLikes: new Set(),
recentDumps: [],
deletedDumpIds: new Set(),
lastVoteEvent: null,
lastDumpEvent: null,
lastPlaylistEvent: null,
deletedPlaylistIds: new Set(),
lastCommentEvent: null,
lastUserEvent: null,
unreadNotificationCount: 0,
lastNotification: null,
connectionEpoch: 0,
followsVersion: 0,
castVote: () => {},
removeVote: () => {},
castCommentLike: () => {},
removeCommentLike: () => {},
injectDump: () => {},
clearUnreadNotifications: () => {},
};
/** Lets a component render outside `WSProvider`, with an empty chat. */
export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
sendChatMessage: () => {},
retryChatMessage: () => {},
discardChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
dismissChatError: () => {},
markChatRead: () => {},
reconcileChat: () => {},
setChatTyping: () => {},
setChatOpen: () => {},
};
/** Lets a component render outside `WSProvider`, with nobody online. */
export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [],
};
/**
* Carries the live state pages lay over what they fetched: the connection, the
* votes and likes, and the latest event of each kind. Read it with `useWS`.
*/
export const WSContext: React.Context<WSContextValue> = createContext<
WSContextValue
>(defaultWSValue);
/** Carries the chat's live stream and read state. Read it with `useWSChat`. */
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
WSChatContextValue
>(
defaultWSChatValue,
);
/** Carries who is online. Read it with `useWSPresence`. */
export const WSPresenceContext: React.Context<WSPresenceContextValue> =
createContext<
WSPresenceContextValue
>(
defaultWSPresenceValue,
);