/** * 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; /** * Marks the dumps this user upvoted, for their vote buttons. The server's * `welcome` resets it on every connection. */ myVotes: Set; /** As `voteCounts`, for comments. */ likeCounts: Record; /** As `myVotes`, for comments. */ myLikes: Set; /** * 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; 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; 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; /** * 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 = createContext< WSContextValue >(defaultWSValue); /** Carries the chat's live stream and read state. Read it with `useWSChat`. */ export const WSChatContext: React.Context = createContext< WSChatContextValue >( defaultWSChatValue, ); /** Carries who is online. Read it with `useWSPresence`. */ export const WSPresenceContext: React.Context = createContext< WSPresenceContextValue >( defaultWSPresenceValue, );