385 lines
14 KiB
TypeScript
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,
|
|
);
|