v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The session context: the signed-in session and its setter, provided by
|
||||
* AuthProvider and read through `useAuth`.
|
||||
* Declares the session's React context. `AuthProvider` fills it and `useAuth`
|
||||
* reads it; nothing else should touch it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,15 +9,23 @@ import { createContext, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
/** What {@link AuthContext} holds. */
|
||||
/**
|
||||
* Provided by `AuthProvider` to `useAuth`, which builds the session's API on
|
||||
* it.
|
||||
*/
|
||||
export interface AuthContextValue {
|
||||
/** The signed-in session, null when signed out. */
|
||||
authResponse: Session | null;
|
||||
/** Replaces the session; AuthProvider keeps the stored one in step. */
|
||||
/**
|
||||
* Replaces the session in memory only. Its callers store the new session
|
||||
* themselves, with `storeSession`, so other tabs follow.
|
||||
*/
|
||||
setAuthResponse: Dispatch<SetStateAction<Session | null>>;
|
||||
}
|
||||
|
||||
/** The session context. */
|
||||
/**
|
||||
* The session's context. Its default, a signed-out session whose setter does
|
||||
* nothing, is only seen outside `AuthProvider`, as in some tests.
|
||||
*/
|
||||
export const AuthContext: React.Context<AuthContextValue> = createContext<
|
||||
AuthContextValue
|
||||
>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The session's provider: holds it, keeps it in step with the other tabs and
|
||||
* the server.
|
||||
* Holds the session for the whole app and keeps it in step with the other tabs
|
||||
* and with the server. `App` mounts it near the root.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,18 +19,16 @@ import {
|
||||
} from "../../utils/auth/storage.ts";
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
/** The props of {@link AuthProvider}. */
|
||||
/** Passed to {@link AuthProvider} by `App`. */
|
||||
export interface AuthProviderProps {
|
||||
/** The app, which reads the session. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link AuthContext}. The session starts as the stored one, follows
|
||||
* the stored one when another tab signs in, out or changes the password, is
|
||||
* checked with the server on mount and whenever the tab regains focus (a 401
|
||||
* ends it; a changed role is picked up), and gets a media token when it
|
||||
* predates them.
|
||||
* Provides {@link AuthContext}. The session starts as the stored one and
|
||||
* follows it when another tab changes it. The server checks it on mount and
|
||||
* whenever the tab regains focus: a 401 ends it, and a changed role replaces
|
||||
* the stored user. A session from before media tokens gets one.
|
||||
*/
|
||||
export function AuthProvider(
|
||||
{ children }: AuthProviderProps,
|
||||
@@ -39,9 +37,9 @@ export function AuthProvider(
|
||||
readStoredSession,
|
||||
);
|
||||
|
||||
// Every tab shares the stored session, so follow it: a login, logout or
|
||||
// password change in another tab applies here too, rather than this tab
|
||||
// carrying on with a token the server has since revoked.
|
||||
// A login, logout or password change in another tab applies here too, so
|
||||
// this tab doesn't carry on with a token the server has revoked. A null key
|
||||
// means localStorage was cleared.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return;
|
||||
@@ -51,12 +49,10 @@ export function AuthProvider(
|
||||
return () => globalThis.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
// The `exp` pre-check above only catches expiry; it cannot detect a token the
|
||||
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
|
||||
// leaves the signature invalid but the token unexpired). Ask the server — the
|
||||
// only authority on validity — and clear the session if it returns 401, so the
|
||||
// UI never shows a false "logged in" state. Re-check when the tab regains
|
||||
// focus to catch tokens invalidated by a server restart while we were away.
|
||||
// readStoredSession only drops expired tokens. The server also rejects
|
||||
// tokens revoked by a password change and tokens signed with a former
|
||||
// GERBEUR_JWT_SECRET, so ask it, and again when the tab comes back, since
|
||||
// either can happen while it sits in the background.
|
||||
const token = authResponse?.token;
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -68,16 +64,15 @@ export function AuthProvider(
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Only this token is dead; the session may have moved on to a
|
||||
// fresh one (another tab's password change) while we asked.
|
||||
// Only this token is dead. The session may have moved on to fresh
|
||||
// tokens while we asked (a password change, here or in another tab).
|
||||
forgetStoredSession(token);
|
||||
setAuthResponse((prev) => prev?.token === token ? null : prev);
|
||||
return;
|
||||
}
|
||||
// The stored user is a snapshot from login time; the role (and other
|
||||
// profile fields) can change server-side — e.g. an admin promoting this
|
||||
// user. Sync from the authoritative /me response so the UI reflects role
|
||||
// changes without forcing a logout/login cycle.
|
||||
// The stored user dates from sign-in, and an admin may have changed
|
||||
// the role since. Only a role change replaces it, so an unchanged
|
||||
// session keeps its identity and doesn't re-render the app.
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json?.success && json.data) {
|
||||
@@ -91,7 +86,7 @@ export function AuthProvider(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't log out an offline user; only an explicit 401 clears the session.
|
||||
// Offline is not signed out: only a 401 ends the session.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,8 +102,8 @@ export function AuthProvider(
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// Sessions from before media tokens existed have none, and would keep putting
|
||||
// the full session token in file and thumbnail URLs; fetch one once.
|
||||
// Without a media token, file and thumbnail URLs carry the session token in
|
||||
// their query string, where it leaks into logs and history.
|
||||
const needsMediaToken = !!token && !authResponse?.mediaToken;
|
||||
useEffect(() => {
|
||||
if (!token || !needsMediaToken) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The categories context: every category, loaded once by CategoriesProvider.
|
||||
* Declares the categories' React context. `CategoriesProvider` fills it and
|
||||
* `useCategories` reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,19 +8,28 @@
|
||||
import { createContext } from "react";
|
||||
import type { Category } from "../../../shared/category.ts";
|
||||
|
||||
/** What {@link CategoriesContext} holds. */
|
||||
/**
|
||||
* Provided by `CategoriesProvider` to `useCategories`, for the category
|
||||
* switcher and picker, the dump pages and cards, and the category manager.
|
||||
*/
|
||||
export interface CategoriesContextValue {
|
||||
/** Every category, in order. */
|
||||
/** Every category, in the server's order, by position then slug. */
|
||||
categories: Category[];
|
||||
/** The categories by id, for resolving dump category chips. */
|
||||
/** Resolves a dump's `categoryIds` to its category chips. */
|
||||
byId: Map<string, Category>;
|
||||
/** Whether the list has been fetched. */
|
||||
/**
|
||||
* Whether the first fetch has ended. It is also true after a network error,
|
||||
* with no categories.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
/** Re-fetches the list (used after admin create/update/delete). */
|
||||
/** Refetches the list. The category manager calls it after each change. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The categories context. */
|
||||
/**
|
||||
* The categories' context. Its default, an empty list never loaded, is only
|
||||
* seen outside `CategoriesProvider`.
|
||||
*/
|
||||
export const CategoriesContext: React.Context<CategoriesContextValue> =
|
||||
createContext<
|
||||
CategoriesContextValue
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The categories' provider: fetches the list once, and again on request.
|
||||
* Loads the site's categories once for the whole app, and again when the
|
||||
* category manager changes them. `App` mounts it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -23,7 +24,7 @@ import type { Wire } from "../../../shared/api.ts";
|
||||
/**
|
||||
* Returns every category, or null when the API answers a failure.
|
||||
*
|
||||
* @throws {Error} When the request fails.
|
||||
* @throws {Error} When the request fails or answers an error status.
|
||||
*/
|
||||
async function fetchCategories(
|
||||
signal?: AbortSignal,
|
||||
@@ -35,13 +36,15 @@ async function fetchCategories(
|
||||
return (body.data as Wire<Category>[]).map(deserializeCategory);
|
||||
}
|
||||
|
||||
/** The props of {@link CategoriesProvider}. */
|
||||
/** Passed to {@link CategoriesProvider} by `App`. */
|
||||
export interface CategoriesProviderProps {
|
||||
/** The app, which reads the categories. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Provides {@link CategoriesContext}, fetching the categories on mount. */
|
||||
/**
|
||||
* Provides {@link CategoriesContext}, fetching the categories on mount. A
|
||||
* failed fetch keeps the previous list.
|
||||
*/
|
||||
export function CategoriesProvider(
|
||||
{ children }: CategoriesProviderProps,
|
||||
): React.JSX.Element {
|
||||
@@ -55,6 +58,8 @@ export function CategoriesProvider(
|
||||
setCategories(data);
|
||||
setIsLoaded(true);
|
||||
})
|
||||
// A failed fetch still ends the loading, so that pages waiting on it
|
||||
// don't wait forever.
|
||||
.catch((err) => {
|
||||
if ((err as Error).name !== "AbortError") setIsLoaded(true);
|
||||
}), []);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
/**
|
||||
* The chat context: whether the chatbox is open, provided by ChatProvider.
|
||||
* Declares the context through which the header's chat button and the floating
|
||||
* chat button open the one chat modal, which `ChatProvider` owns. Read it with
|
||||
* `useChat`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** What {@link ChatContext} holds. */
|
||||
/** Provided by `ChatProvider` through {@link ChatContext}. */
|
||||
export interface ChatContextValue {
|
||||
/** Whether the chatbox is open. */
|
||||
/** Never true for a visitor, whatever `openChat` or the URL asked. */
|
||||
isChatOpen: boolean;
|
||||
/** Opens the chatbox. */
|
||||
openChat: () => void;
|
||||
/** Closes the chatbox. */
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
/** The chat context. */
|
||||
/**
|
||||
* Holds the chat modal's open state. Outside `ChatProvider` it is closed and
|
||||
* its functions do nothing.
|
||||
*/
|
||||
export const ChatContext: React.Context<ChatContextValue> = createContext<
|
||||
ChatContextValue
|
||||
>({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The chat's provider, which owns the one chat modal.
|
||||
* Provides `ChatContext` and renders the chat modal while it is open. `App`
|
||||
* mounts it inside the router, around every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,25 +17,23 @@ import { useSearchParams } from "react-router";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
|
||||
|
||||
/** The chat modal, loaded on first open. */
|
||||
/** Loaded on first open, so visitors never download the chat. */
|
||||
const ChatModal = lazy(() =>
|
||||
import("../../components/chat/ChatModal.tsx").then((m) => ({
|
||||
default: m.ChatModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** The props of {@link ChatProvider}. */
|
||||
/** Passed to {@link ChatProvider} by `App`. */
|
||||
export interface ChatProviderProps {
|
||||
/** The app, which opens and closes the chat. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link ChatContext} and owns the single live-chat modal instance so
|
||||
* the header button, the floating button, and notification deep-links all drive
|
||||
* one modal. Open state is derived (no effects): a manual open, or a
|
||||
* `?chat=open` query param (used by mention notifications), opens it — and it's
|
||||
* only ever open for signed-in users.
|
||||
* Provides {@link ChatContext} and renders the one chat modal while it is
|
||||
* open. `openChat` opens it, and so does a `?chat=open` query parameter on any
|
||||
* page, which is where a chat mention notification links. It stays closed for
|
||||
* visitors.
|
||||
*/
|
||||
export function ChatProvider(
|
||||
{ children }: ChatProviderProps,
|
||||
@@ -49,7 +48,7 @@ export function ChatProvider(
|
||||
const openChat = useCallback(() => setManuallyOpen(true), []);
|
||||
const closeChat = useCallback(() => {
|
||||
setManuallyOpen(false);
|
||||
// Strip the deep-link param so a reload/back-nav doesn't reopen the modal.
|
||||
// Or a reload would open the chat again.
|
||||
if (searchParams.get("chat") === "open") {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("chat");
|
||||
|
||||
@@ -1,85 +1,81 @@
|
||||
/**
|
||||
* The player contexts: what plays, the queue and the controls, and apart from
|
||||
* them the playhead. PlayerProvider provides both; GlobalPlayer draws them.
|
||||
* Defines the player's two contexts, one for the queue and the controls and one
|
||||
* for the playhead alone. PlayerProvider provides both around the whole app;
|
||||
* GlobalPlayer draws the player, and the cards and previews that start playback
|
||||
* read and drive it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** Fields every playable item shares, whatever its source. */
|
||||
/**
|
||||
* The fields of every {@link PlayerItem}, which GlobalPlayer's header shows.
|
||||
*/
|
||||
export interface PlayerItemBase {
|
||||
/** The item's title. */
|
||||
title?: string;
|
||||
/** The page of the dump it was played from. */
|
||||
/** The dump page the header's title links to. */
|
||||
dumpHref?: string;
|
||||
/**
|
||||
* Square image for the player header — provider thumbnail, album art, or a
|
||||
* video still. Raw URL: Thumbnail decides whether to proxy it.
|
||||
*/
|
||||
/** A raw URL, which `Thumbnail` proxies when it must. */
|
||||
artworkUrl?: string;
|
||||
/**
|
||||
* Secondary line under the title: the artist, the site name, the filename —
|
||||
* whatever identifies the source at a glance.
|
||||
* Shown under the title. It holds the artist, the site's name or the file's
|
||||
* name.
|
||||
*/
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the player can play: a provider's embed, an uploaded file, or an
|
||||
* expiring stream.
|
||||
* Built by the cards and previews that start playback, and held in
|
||||
* PlayerProvider's queue, which localStorage keeps across reloads. An `embed`
|
||||
* plays in the provider's iframe; a `file` (an upload) and a `stream` play in
|
||||
* MediaPlayer.
|
||||
*/
|
||||
export type PlayerItem =
|
||||
| (PlayerItemBase & {
|
||||
/** A provider's iframe player. */
|
||||
kind: "embed";
|
||||
/** The embed's URL. */
|
||||
embedUrl: string;
|
||||
/** The provider, e.g. "youtube". */
|
||||
/** The provider, as in `RichContent.type`. */
|
||||
type: string;
|
||||
})
|
||||
| (PlayerItemBase & {
|
||||
/** An uploaded file, played by a media element. */
|
||||
kind: "file";
|
||||
/** The file's URL. */
|
||||
/** Carries the listener's media token when they are signed in. */
|
||||
fileUrl: string;
|
||||
/** The file's type. */
|
||||
/** Decides between the audio and the video player. */
|
||||
mimeType: string;
|
||||
})
|
||||
/**
|
||||
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
|
||||
*
|
||||
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
|
||||
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
|
||||
* came from and `resolveIndex` its position in that page's tracklist. That
|
||||
* pair is also the item's stable identity, since `streamUrl` changes on every
|
||||
* re-resolve.
|
||||
* A track streamed from another site, today a Bandcamp mp3. Its URL is
|
||||
* signed and dies after about 24 hours, so the item keeps the page it came
|
||||
* from (`resolveUrl`) and its place in that page's tracklist (`resolveIndex`)
|
||||
* to fetch a fresh one. That pair is also its identity, since `streamUrl`
|
||||
* changes on every re-resolve.
|
||||
*/
|
||||
| (PlayerItemBase & {
|
||||
/** An expiring stream. */
|
||||
kind: "stream";
|
||||
/** The signed stream URL. */
|
||||
streamUrl: string;
|
||||
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
|
||||
/**
|
||||
* The service, which also styles the player (`global-player--bandcamp`).
|
||||
*/
|
||||
type: string;
|
||||
/** Known before playback from the tracklist, so rows can show a length. */
|
||||
/** Known from the tracklist before playback, for the tracklist's rows. */
|
||||
duration?: number;
|
||||
/** The track's number on its release. */
|
||||
/** Numbers the tracklist's row, in place of the queue position. */
|
||||
trackNum?: number;
|
||||
/** The page the stream was resolved from. */
|
||||
resolveUrl: string;
|
||||
/** The track's position in that page's tracklist. */
|
||||
resolveIndex: number;
|
||||
/** Epoch ms of the resolution that produced `streamUrl`. */
|
||||
/** When `streamUrl` was signed, in epoch ms, for `isStreamStale`. */
|
||||
resolvedAt: number;
|
||||
/** Iframe embed to fall back to if native playback stops working. */
|
||||
/** The iframe PlayerProvider falls back to if the stream stays dead. */
|
||||
embedUrl?: string;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a stable identity for `item`, used for active-state comparisons and
|
||||
* to decide when the player is showing something new. Deliberately independent
|
||||
* of `streamUrl` so a re-resolved track is still recognised as the same track.
|
||||
* Returns a key that identifies `item` whatever its stream URL, so that a
|
||||
* re-resolved track is still the same track. GlobalPlayer keys its tracklist
|
||||
* on it and tells a new item by it; PlayerProvider tells repeated failures of
|
||||
* one track by it.
|
||||
*/
|
||||
export function playerItemKey(item: PlayerItem): string {
|
||||
switch (item.kind) {
|
||||
@@ -93,13 +89,13 @@ export function playerItemKey(item: PlayerItem): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link PlayerContext} holds: playback state, readable by any consumer,
|
||||
* and controls, callable by any consumer.
|
||||
* Provided by PlayerProvider through {@link PlayerContext}. Cards and previews
|
||||
* read what plays and call the controls; GlobalPlayer and MediaPlayer use the
|
||||
* internal fields to report the media's state back.
|
||||
*/
|
||||
export interface PlayerContextValue {
|
||||
/** The item playing or paused; always `queue[queueIndex]`. */
|
||||
/** Always `queue[queueIndex]`, or null when nothing plays. */
|
||||
current: PlayerItem | null;
|
||||
/** Whether it is playing. */
|
||||
playing: boolean;
|
||||
|
||||
/**
|
||||
@@ -107,62 +103,60 @@ export interface PlayerContextValue {
|
||||
* and albums share one code path.
|
||||
*/
|
||||
queue: PlayerItem[];
|
||||
/** The position of `current` in the queue. */
|
||||
queueIndex: number;
|
||||
/** Whether an item follows `current`. */
|
||||
hasNext: boolean;
|
||||
/** Whether an item precedes `current`. */
|
||||
hasPrevious: boolean;
|
||||
/** True while a stream is being (re-)resolved, for spinner affordances. */
|
||||
/** True while a stream's URLs are re-resolved, which GlobalPlayer shows. */
|
||||
resolving: boolean;
|
||||
|
||||
/**
|
||||
* Initial seek offset for the active item: non-zero only for an item restored
|
||||
* from a previous session, or resumed after a re-resolve.
|
||||
* Where MediaPlayer starts the current item, in seconds: not 0 only for an
|
||||
* item restored from a previous session, or resumed after a re-resolve.
|
||||
*/
|
||||
startTime: number;
|
||||
/**
|
||||
* Whether the active MediaPlayer should autoplay. A restored item appears
|
||||
* paused; a fresh play() autoplays on the user gesture.
|
||||
* Whether the current item starts on mount. A restored session appears
|
||||
* paused, since no user gesture is behind it; a fresh `play()` autoplays.
|
||||
*/
|
||||
autoplay: boolean;
|
||||
|
||||
/** Plays `item` alone. */
|
||||
/** Plays `item` alone, replacing the queue. */
|
||||
play(item: PlayerItem): void;
|
||||
/** Plays `items` as the queue, from `startIndex`. */
|
||||
/** Replaces the queue with `items` and plays it from `startIndex`. */
|
||||
playQueue(items: PlayerItem[], startIndex?: number): void;
|
||||
/** Plays the queue's item at `index`. */
|
||||
/** Plays the queue's item at `index`, or restarts it when it is current. */
|
||||
playAt(index: number): void;
|
||||
/** Plays the next item. */
|
||||
next(): void;
|
||||
/** Plays the previous item. */
|
||||
/**
|
||||
* Restarts the item after its first 3 seconds, else plays the previous one.
|
||||
*/
|
||||
previous(): void;
|
||||
/** Stops and closes the player. */
|
||||
/** Empties the queue, which closes the player. */
|
||||
stop(): void;
|
||||
/** Seeks to `time` seconds. */
|
||||
/** Seeks a file or stream to `time` seconds. An embed doesn't move. */
|
||||
seekTo(time: number): void;
|
||||
/** Pauses or resumes. */
|
||||
/** Pauses or resumes a file or stream. An embed has its own controls. */
|
||||
togglePlay(): void;
|
||||
|
||||
/**
|
||||
* Internal: where GlobalPlayer registers MediaPlayer's seek handle, so seekTo
|
||||
* can reach into the actual media element.
|
||||
*/
|
||||
/** Where the mounted MediaPlayer registers its seek handle, for `seekTo`. */
|
||||
seekRef: { current: ((t: number) => void) | null };
|
||||
/** Internal: MediaPlayer's play/pause handle, for togglePlay. */
|
||||
/** Where the mounted MediaPlayer registers its play/pause handle. */
|
||||
toggleRef: { current: (() => void) | null };
|
||||
|
||||
/** Internal: GlobalPlayer reports whether the media is playing. */
|
||||
/** Called by MediaPlayer when its media starts or stops. */
|
||||
onPlayStateChange(playing: boolean): void;
|
||||
/** Internal: GlobalPlayer reports the playhead and the duration. */
|
||||
/** Called by MediaPlayer as its media plays. */
|
||||
onTimeUpdate(time: number, duration: number): void;
|
||||
/** Current item finished — advances the queue, or stops at the end. */
|
||||
/** Called by MediaPlayer at its media's end, to play the next item. */
|
||||
onEnded(): void;
|
||||
/** The media element failed. For streams, triggers a re-resolve. */
|
||||
/** Called by MediaPlayer when its media fails, to re-resolve a stream. */
|
||||
onError(): void;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/**
|
||||
* Given by {@link PlayerContext} outside PlayerProvider: nothing plays and no
|
||||
* control does anything. The tests' render helper starts from it.
|
||||
*/
|
||||
export const defaultPlayerValue: PlayerContextValue = {
|
||||
current: null,
|
||||
playing: false,
|
||||
@@ -189,7 +183,7 @@ export const defaultPlayerValue: PlayerContextValue = {
|
||||
onError: () => {},
|
||||
};
|
||||
|
||||
/** The player context. */
|
||||
/** The player's state and controls, from PlayerProvider. */
|
||||
export const PlayerContext: React.Context<PlayerContextValue> = createContext<
|
||||
PlayerContextValue
|
||||
>(
|
||||
@@ -197,24 +191,25 @@ export const PlayerContext: React.Context<PlayerContextValue> = createContext<
|
||||
);
|
||||
|
||||
/**
|
||||
* The playhead, apart from the rest: it moves several times a second while
|
||||
* something plays, and only what draws a position should re-render with it —
|
||||
* not every card that reads `current` or `playing`.
|
||||
* Provided by PlayerProvider through {@link PlayerTimeContext}, apart from the
|
||||
* rest because it changes several times a second while something plays. Only
|
||||
* what draws a position, like the dump page's audio preview, reads it, so the
|
||||
* cards that read {@link PlayerContext} don't re-render with it.
|
||||
*/
|
||||
export interface PlayerTimeValue {
|
||||
/** The playhead, in seconds. */
|
||||
/** In seconds. */
|
||||
currentTime: number;
|
||||
/** The current item's length, in seconds; 0 until known. */
|
||||
/** In seconds, 0 until the media's metadata loads. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given by {@link PlayerTimeContext} outside PlayerProvider. */
|
||||
export const defaultPlayerTimeValue: PlayerTimeValue = {
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
/** The playhead context. */
|
||||
/** The playhead of the current file or stream, from PlayerProvider. */
|
||||
export const PlayerTimeContext: React.Context<PlayerTimeValue> = createContext<
|
||||
PlayerTimeValue
|
||||
>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The player's provider: the queue, the playback state and the controls, kept
|
||||
* across reloads, and the expiring streams kept fresh.
|
||||
* Holds the player's queue, playback state and controls, saves them across
|
||||
* reloads and keeps streams' signed URLs fresh. App mounts it around the whole
|
||||
* app, and GlobalPlayer draws what it holds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,24 +19,18 @@ import {
|
||||
} from "../../utils/player/streamSources.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The localStorage key of the stored session. */
|
||||
const STORAGE_KEY = "player";
|
||||
|
||||
/**
|
||||
* Snapshot persisted across reloads: the whole queue, which entry was playing
|
||||
* and how far in.
|
||||
*
|
||||
* v1 stored a single `{ item, time }`. `readSession` still accepts that shape
|
||||
* so a session written by the previous build survives the upgrade.
|
||||
* Written to localStorage by {@link PlayerProvider} as the queue changes and on
|
||||
* `pagehide`, and read back by {@link readSession} on load. Version 1 stored a
|
||||
* single `{ item, time }`, which readSession still accepts.
|
||||
*/
|
||||
interface StoredSession {
|
||||
/** The format's version. */
|
||||
v: 2;
|
||||
/** The queue. */
|
||||
queue: PlayerItem[];
|
||||
/** The position of the entry that was playing. */
|
||||
index: number;
|
||||
/** How far in it was, in seconds. */
|
||||
/** How far into `queue[index]`, in seconds. */
|
||||
time: number;
|
||||
/**
|
||||
* Whose queue this is (null for a guest). File URLs carry the listener's
|
||||
@@ -44,7 +39,10 @@ interface StoredSession {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** Checks whether `value` looks like a {@link PlayerItem}. */
|
||||
/**
|
||||
* Checks whether `value`, read from localStorage, looks like a
|
||||
* {@link PlayerItem}. Only its `kind` is checked.
|
||||
*/
|
||||
function isPlayerItem(value: unknown): value is PlayerItem {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
@@ -64,9 +62,8 @@ function readSession(
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const time = Number(parsed.time) || 0;
|
||||
|
||||
// Another account's queue, or one saved before queues had owners: the
|
||||
// latter may be anybody's, and only file URLs hold a token worth keeping
|
||||
// to themselves.
|
||||
// A session saved before sessions had owners may be anybody's. Only file
|
||||
// URLs carry a media token, so it is kept only when it holds none.
|
||||
if (parsed.owner === undefined) {
|
||||
const items = Array.isArray(parsed.queue) ? parsed.queue : [parsed.item];
|
||||
if (items.some((i) => isPlayerItem(i) && i.kind === "file")) return null;
|
||||
@@ -85,7 +82,7 @@ function readSession(
|
||||
return { queue, index, time };
|
||||
}
|
||||
|
||||
// Legacy v1 single-item session.
|
||||
// A version 1 session, of a single item.
|
||||
if (isPlayerItem(parsed.item)) {
|
||||
return { queue: [parsed.item], index: 0, time };
|
||||
}
|
||||
@@ -95,28 +92,30 @@ function readSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Don't retry the same failing track more than once per window. */
|
||||
/**
|
||||
* A track that fails again this soon after its last re-resolve is dead, not
|
||||
* stale, and is skipped.
|
||||
*/
|
||||
const RERESOLVE_COOLDOWN_MS = 30_000;
|
||||
|
||||
/** The props of {@link PlayerProvider}. */
|
||||
/** Passed to {@link PlayerProvider} by App, around the whole app. */
|
||||
export interface PlayerProviderProps {
|
||||
/** The app, which plays through the player. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link PlayerContext} and {@link PlayerTimeContext}. The queue and
|
||||
* the position are stored as they change and restored on load, paused; signing
|
||||
* out, or in as someone else, ends the session. A Bandcamp stream whose signed
|
||||
* URL expires is re-resolved and resumed where it was, falling back to the
|
||||
* embed if that fails.
|
||||
* the position are saved as they change and restored on load, paused; signing
|
||||
* out, or in as someone else, closes the player. A stream whose signed URL
|
||||
* expires is re-resolved and resumed where it was, and falls back to its embed
|
||||
* if that fails.
|
||||
*/
|
||||
export function PlayerProvider(
|
||||
{ children }: PlayerProviderProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const owner = user?.id ?? null;
|
||||
// Lazy initialiser: readSession runs exactly once, on first render.
|
||||
// Read once, for the account AuthProvider restored synchronously.
|
||||
const [restored] = useState(() => readSession(owner));
|
||||
|
||||
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
|
||||
@@ -124,10 +123,7 @@ export function PlayerProvider(
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
// Resume offset for the active item — carried by a restored item, and by an
|
||||
// item whose stream URL was re-resolved mid-listen.
|
||||
const [startTime, setStartTime] = useState(restored?.time ?? 0);
|
||||
// Restored items start paused (no user gesture); fresh play() autoplays.
|
||||
const [autoplay, setAutoplay] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
@@ -135,22 +131,19 @@ export function PlayerProvider(
|
||||
const hasNext = queueIndex + 1 < queue.length;
|
||||
const hasPrevious = queueIndex > 0;
|
||||
|
||||
// GlobalPlayer registers the active MediaPlayer's imperative handles here
|
||||
const seekRef = useRef<((t: number) => void) | null>(null);
|
||||
const toggleRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Suppresses stale timeupdate callbacks that fire between play() and the old
|
||||
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
|
||||
// Set when the player moves to a new item, until the new media starts: the
|
||||
// old MediaPlayer can still report its playhead before it unmounts.
|
||||
const suppressUpdates = useRef(false);
|
||||
|
||||
// Bumped whenever the player is moved somewhere new — a fresh queue, a queue
|
||||
// step, a stop. An async re-resolve compares it against the value it captured
|
||||
// to tell whether it still owns the player by the time it lands.
|
||||
// Bumped whenever the player moves somewhere new (a fresh queue, a step, a
|
||||
// stop), so that a re-resolve landing later can tell it no longer applies.
|
||||
const playGen = useRef(0);
|
||||
|
||||
// Latest state, for callbacks that must not close over a stale render.
|
||||
// Written in an effect (after render) rather than during it, the same
|
||||
// convention MediaPlayer uses for its callback refs.
|
||||
// The latest state, for callbacks that must not close over an old render.
|
||||
// Written after render, since the linter forbids writing refs during it.
|
||||
const stateRef = useRef({ current, currentTime, playing, queue, queueIndex });
|
||||
useEffect(() => {
|
||||
stateRef.current = { current, currentTime, playing, queue, queueIndex };
|
||||
@@ -173,7 +166,7 @@ export function PlayerProvider(
|
||||
playQueue([item], 0);
|
||||
}, [playQueue]);
|
||||
|
||||
/** Move within the existing queue, as a fresh (autoplaying) item. */
|
||||
/** Moves to `index` in the queue and autoplays it, as a fresh queue would. */
|
||||
const advanceTo = useCallback((index: number) => {
|
||||
playGen.current++;
|
||||
suppressUpdates.current = true;
|
||||
@@ -197,7 +190,8 @@ export function PlayerProvider(
|
||||
|
||||
const seekTo = useCallback((t: number) => {
|
||||
seekRef.current?.(t);
|
||||
setCurrentTime(t); // optimistic — prevents waveform jitter before timeupdate fires
|
||||
// Shown at once, or the playhead jumps back until the next timeupdate.
|
||||
setCurrentTime(t);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
@@ -206,13 +200,11 @@ export function PlayerProvider(
|
||||
|
||||
const playAt = useCallback((index: number) => {
|
||||
if (index < 0 || index >= queue.length) return;
|
||||
// Re-selecting the playing row restarts it. Going through advanceTo would
|
||||
// leave the index unchanged — so the element would never remount and never
|
||||
// seek — while still flipping `playing` false against audio that's running.
|
||||
// advanceTo to the same index wouldn't remount the element, so it wouldn't
|
||||
// seek, and it would set `playing` false against audio that runs.
|
||||
if (index === queueIndex) {
|
||||
seekTo(0);
|
||||
// Rewinding alone would make the click look like a no-op on a row that
|
||||
// has ended, or that the user paused. Start it again.
|
||||
// On an ended or paused row, rewinding alone would look like nothing.
|
||||
if (!stateRef.current.playing) togglePlay();
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +215,10 @@ export function PlayerProvider(
|
||||
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
|
||||
}, [queueIndex, queue.length, advanceTo]);
|
||||
|
||||
/** Restart the track first, like every other music player, then step back. */
|
||||
/**
|
||||
* Plays the previous track, unless 3 seconds of this one have played: then
|
||||
* it restarts this one, like other players.
|
||||
*/
|
||||
const previous = useCallback(() => {
|
||||
if (stateRef.current.currentTime > 3 || queueIndex === 0) {
|
||||
seekTo(0);
|
||||
@@ -251,10 +246,9 @@ export function PlayerProvider(
|
||||
setPlaying(false);
|
||||
}, [queueIndex, queue.length, advanceTo]);
|
||||
|
||||
// ── Expiring streams ───────────────────────────────────────────────────────────
|
||||
// Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a
|
||||
// long listen, will 403. Re-resolve the source page, swap in fresh URLs and
|
||||
// resume at the same offset; if that fails too, fall back to the iframe embed.
|
||||
// Bandcamp signs its mp3 URLs for about 24 hours, so a session restored the
|
||||
// next day, or a long listen, gets a 403. The page is re-resolved and the
|
||||
// track resumed at the same offset; failing that, the embed takes over.
|
||||
const lastReresolve = useRef<{ key: string; at: number } | null>(null);
|
||||
|
||||
const fallBackToEmbed = useCallback((
|
||||
@@ -274,8 +268,8 @@ export function PlayerProvider(
|
||||
}, [playQueue, stop]);
|
||||
|
||||
/**
|
||||
* A track that stays dead after a re-resolve is one track, not one album:
|
||||
* step over it and keep the queue. Only the last one falls back to the embed.
|
||||
* Skips a track that stays dead after a re-resolve, since the rest of the
|
||||
* album may play. Only the last track falls back to the embed.
|
||||
*/
|
||||
const skipOrFallBack = useCallback((
|
||||
item: Extract<PlayerItem, { kind: "stream" }>,
|
||||
@@ -298,19 +292,17 @@ export function PlayerProvider(
|
||||
const now = Date.now();
|
||||
const last = lastReresolve.current;
|
||||
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
|
||||
// Already tried recently — the track is genuinely dead, not just stale.
|
||||
skipOrFallBack(item);
|
||||
return;
|
||||
}
|
||||
lastReresolve.current = { key, at: now };
|
||||
|
||||
// Resolving is a network round-trip against Bandcamp. The user can pick
|
||||
// another dump, skip on, or close the player while it's in flight — and a
|
||||
// result that lands after that must not drag the player back here.
|
||||
// The user may play something else, skip or close the player meanwhile,
|
||||
// and a late result must not drag the player back here.
|
||||
const gen = playGen.current;
|
||||
const stillOurs = () => playGen.current === gen;
|
||||
// Separate from `gen`: the spinner belongs to the newest resolve, whichever
|
||||
// item that one is for, so a superseded resolve must not clear it.
|
||||
// Not `gen`: the spinner belongs to the newest resolve, whichever item it
|
||||
// is for, and an older one must not clear it.
|
||||
const seq = ++resolveSeq.current;
|
||||
|
||||
setResolving(true);
|
||||
@@ -320,9 +312,9 @@ export function PlayerProvider(
|
||||
const found = items.findIndex((i) =>
|
||||
i.kind === "stream" && i.resolveIndex === item.resolveIndex
|
||||
);
|
||||
// The track can be gone from the release, or no longer streamable once
|
||||
// Bandcamp's free-play cap kicks in. Falling back to the first track is
|
||||
// fine; carrying the old offset onto it would seek past its end.
|
||||
// The track may be gone, or capped by Bandcamp's free plays. The first
|
||||
// track then plays from its start, since the old offset may be past its
|
||||
// end.
|
||||
const index = found < 0 ? 0 : found;
|
||||
const resume = found < 0 ? 0 : resumeAt;
|
||||
suppressUpdates.current = true;
|
||||
@@ -334,8 +326,7 @@ export function PlayerProvider(
|
||||
setAutoplay(shouldAutoplay);
|
||||
setPlaying(false);
|
||||
} catch {
|
||||
// The resolve itself failed (offline, endpoint down) — that's the whole
|
||||
// album, not one track, so the embed is the right fallback.
|
||||
// The whole album failed to resolve, not one track.
|
||||
if (stillOurs()) fallBackToEmbed(item);
|
||||
} finally {
|
||||
if (resolveSeq.current === seq) setResolving(false);
|
||||
@@ -351,29 +342,26 @@ export function PlayerProvider(
|
||||
});
|
||||
}, [reresolve]);
|
||||
|
||||
// A session restored from a previous day holds URLs that are already dead.
|
||||
// Refresh them up front so the first press of play doesn't visibly stall.
|
||||
// A session from a previous day holds dead URLs. Refreshing them now keeps
|
||||
// the first press of play from stalling.
|
||||
useEffect(() => {
|
||||
if (!restored) return;
|
||||
const item = restored.queue[restored.index];
|
||||
if (item?.kind === "stream" && isStreamStale(item)) {
|
||||
// Genuinely an external-system sync: the stored URLs are dead and only
|
||||
// the network can replace them. The setState this triggers is the point.
|
||||
// Syncing with the network is the point of the setState this triggers.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void reresolve(item, {
|
||||
resumeAt: restored.time,
|
||||
shouldAutoplay: false,
|
||||
});
|
||||
}
|
||||
// Runs once, against the session captured at mount.
|
||||
// Once, for the session read at mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────────
|
||||
// Write the queue to storage as soon as it changes (survives crashes), then
|
||||
// refresh the playback position on pagehide — which also fires on a normal
|
||||
// reload — so the resume offset is accurate without thrashing localStorage on
|
||||
// every timeupdate.
|
||||
// The queue is saved as soon as it changes, which survives a crash, and the
|
||||
// position on `pagehide` (a reload fires it too), rather than on every
|
||||
// timeupdate.
|
||||
useEffect(() => {
|
||||
if (queue.length > 0) {
|
||||
localStorage.setItem(
|
||||
@@ -391,12 +379,12 @@ export function PlayerProvider(
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
// currentTime intentionally omitted from deps — see pagehide writer below.
|
||||
// Not on currentTime: the pagehide handler saves the position.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, queueIndex, owner]);
|
||||
|
||||
// Signing out, or in as someone else, ends the listening session: the queue
|
||||
// was built with the previous account's media token. Signing in keeps a
|
||||
// Signing out, or in as someone else, closes the player, since the queue's
|
||||
// file URLs carry the previous account's media token. Signing in keeps a
|
||||
// guest's queue, which holds no token.
|
||||
const [queueOwner, setQueueOwner] = useState(owner);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
/**
|
||||
* The follow context: whom and what the signed-in user follows, provided by
|
||||
* FollowProvider.
|
||||
* Declares the context that holds the users and playlists the signed-in user
|
||||
* follows, for the Follow buttons. `FollowProvider` provides it and
|
||||
* `useFollows` reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** What {@link FollowContext} holds. */
|
||||
/**
|
||||
* Provided by `FollowProvider` through {@link FollowContext}. The four
|
||||
* functions change the sets at once and undo the change when the request
|
||||
* fails. Their promises never reject, so a caller can't tell a failure.
|
||||
*/
|
||||
export interface FollowContextValue {
|
||||
/** The ids of the users they follow. */
|
||||
followedUserIds: Set<string>;
|
||||
/** The ids of the playlists they follow. */
|
||||
followedPlaylistIds: Set<string>;
|
||||
/** Follows a user. */
|
||||
followUser: (userId: string) => Promise<void>;
|
||||
/** Stops following a user. */
|
||||
unfollowUser: (userId: string) => Promise<void>;
|
||||
/** Follows a playlist. */
|
||||
followPlaylist: (playlistId: string) => Promise<void>;
|
||||
/** Stops following a playlist. */
|
||||
unfollowPlaylist: (playlistId: string) => Promise<void>;
|
||||
/** Whether the follow status has been fetched. */
|
||||
/**
|
||||
* False until the follows are fetched, and always for a visitor. The sets
|
||||
* are empty until then, so a button would show "Follow" for a followed user.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `FollowProvider`: nothing is followed. */
|
||||
export const defaultFollowValue: FollowContextValue = {
|
||||
followedUserIds: new Set(),
|
||||
followedPlaylistIds: new Set(),
|
||||
@@ -36,7 +38,7 @@ export const defaultFollowValue: FollowContextValue = {
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
/** The follow context. */
|
||||
/** Holds the signed-in user's follows. Read it with `useFollows`. */
|
||||
export const FollowContext: React.Context<FollowContextValue> = createContext<
|
||||
FollowContextValue
|
||||
>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The follows' provider: what the signed-in user follows, changed
|
||||
* optimistically.
|
||||
* Provides `FollowContext`, from `GET /api/follows/status`. `App` mounts it
|
||||
* around every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,16 +17,15 @@ import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import type { FollowStatus } from "../../../shared/follow.ts";
|
||||
|
||||
/** The props of {@link FollowProvider}. */
|
||||
/** Passed to {@link FollowProvider} by `App`. */
|
||||
export interface FollowProviderProps {
|
||||
/** The app, which reads and changes the follows. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link FollowContext}: fetches the follow status when someone signs
|
||||
* in, clears it when they sign out. A follow or unfollow applies at once and is
|
||||
* rolled back if the request fails.
|
||||
* Provides {@link FollowContext}. It fetches the follows whenever the token
|
||||
* changes, and empties them on sign-out. Follows made in another tab don't
|
||||
* show until the next sign-in or reload: no socket message carries them.
|
||||
*/
|
||||
export function FollowProvider(
|
||||
{ children }: FollowProviderProps,
|
||||
@@ -42,9 +41,8 @@ export function FollowProvider(
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
// Deferred to a microtask (rather than called directly) so this
|
||||
// doesn't fire synchronously within the effect body — same timing
|
||||
// for the user, just not in the same tick.
|
||||
// The React hooks lint forbids a synchronous setState in an effect. The
|
||||
// microtask changes nothing the user can see.
|
||||
queueMicrotask(() => {
|
||||
setFollowedUserIds(new Set());
|
||||
setFollowedPlaylistIds(new Set());
|
||||
@@ -66,6 +64,7 @@ export function FollowProvider(
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Enables the buttons anyway, though the sets may be empty or stale.
|
||||
if (err.name !== "AbortError") setIsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* The theme context: the style and the color scheme, provided by ThemeProvider.
|
||||
* Declares the theme's React context. `ThemeProvider` fills it and `useTheme`
|
||||
* reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** A site style, one stylesheet each under src/themes/. */
|
||||
/**
|
||||
* A site style. Each has a stylesheet under `src/themes/`, keyed on the root
|
||||
* element's `data-style`, which is absent for "smooth".
|
||||
*/
|
||||
export type StyleName =
|
||||
| "smooth"
|
||||
| "brutalist"
|
||||
@@ -14,22 +18,21 @@ export type StyleName =
|
||||
| "nyt"
|
||||
| "kanagawa"
|
||||
| "melange";
|
||||
/** Light, dark, or following the system. */
|
||||
/** A color scheme. "auto" follows the system's. */
|
||||
export type ColorScheme = "auto" | "light" | "dark";
|
||||
|
||||
/** What {@link ThemeContext} holds. */
|
||||
/**
|
||||
* Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the
|
||||
* choice for this browser.
|
||||
*/
|
||||
export interface ThemeContextValue {
|
||||
/** The chosen style. */
|
||||
style: StyleName;
|
||||
/** The chosen color scheme. */
|
||||
colorScheme: ColorScheme;
|
||||
/** Chooses a style. */
|
||||
setStyle(s: StyleName): void;
|
||||
/** Chooses a color scheme. */
|
||||
setColorScheme(c: ColorScheme): void;
|
||||
}
|
||||
|
||||
/** The theme context. */
|
||||
/** The theme's context. Its default is only seen outside `ThemeProvider`. */
|
||||
export const ThemeContext: React.Context<ThemeContextValue> = createContext<
|
||||
ThemeContextValue
|
||||
>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The theme's provider: the chosen style and color scheme, stored and applied
|
||||
* to the document.
|
||||
* Holds the chosen style and color scheme, stores them in localStorage and
|
||||
* applies them to the document. `App` mounts it outermost.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,24 +12,26 @@ import {
|
||||
ThemeContext,
|
||||
} from "./ThemeContext.ts";
|
||||
|
||||
/** The props of {@link ThemeProvider}. */
|
||||
/** Passed to {@link ThemeProvider} by `App`. */
|
||||
export interface ThemeProviderProps {
|
||||
/** The app. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link ThemeContext}. The style and color scheme are read from
|
||||
* localStorage, stored back when they change, and set on the root element as
|
||||
* `data-style` and `data-color-scheme` (absent for the defaults, `smooth` and
|
||||
* `auto`), which the stylesheets key on.
|
||||
* Provides {@link ThemeContext}. It sets the choices on the root element as
|
||||
* `data-style` and `data-color-scheme`, which the stylesheets key on, and
|
||||
* leaves them out for the defaults, "smooth" and "auto".
|
||||
*
|
||||
* An inline script in index.html sets the same attributes from the same
|
||||
* localStorage keys before the first paint, so the page doesn't flash the
|
||||
* default theme. The two must agree.
|
||||
*/
|
||||
export function ThemeProvider(
|
||||
{ children }: ThemeProviderProps,
|
||||
): React.JSX.Element {
|
||||
const [style, setStyleState] = useState<StyleName>(() => {
|
||||
const stored = localStorage.getItem("style");
|
||||
// migrate legacy "default" value
|
||||
// "smooth" was once stored as "default".
|
||||
if (stored === "default" || stored === null) return "smooth";
|
||||
return stored as StyleName;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The avatar override context: avatars changed live, provided by WSProvider.
|
||||
* Declares the context through which `Avatar` learns of avatars changed since
|
||||
* the page loaded. `WSProvider` fills it from `user_updated` messages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,22 +8,22 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* Live avatar state learned from `user_updated` WS events. Kept in its own
|
||||
* context (not WSContext) so that `Avatar` — rendered many times across feeds
|
||||
* and comment threads — only re-renders when an avatar actually changes, not on
|
||||
* every vote/comment/dump event.
|
||||
* Set by `WSProvider` for a user whose profile changed, and read by `Avatar`,
|
||||
* where it wins over the possibly stale props the page fetched.
|
||||
*/
|
||||
export interface AvatarOverride {
|
||||
/**
|
||||
* Whether the user currently has an avatar, so a freshly added or removed
|
||||
* avatar flips even when the surrounding list data is stale.
|
||||
*/
|
||||
hasAvatar: boolean;
|
||||
/** Cache-busts the avatar URL (derived from the user's `updatedAt`). */
|
||||
/**
|
||||
* The user's `updatedAt` in ms, or the arrival time without one. It busts the
|
||||
* cached image.
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** The avatar override context, keyed by user id. */
|
||||
/**
|
||||
* Holds an {@link AvatarOverride} by user id. It is apart from `WSContext`, so
|
||||
* the many avatars of a page don't re-render on every vote or comment.
|
||||
*/
|
||||
export const AvatarOverrideContext: React.Context<
|
||||
Record<string, AvatarOverride>
|
||||
> = createContext<
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The socket contexts, split three ways by how often each part changes, so a
|
||||
* consumer re-renders only with what it reads: typing and presence update every
|
||||
* few seconds while people are around, chat with every message, and a feed has
|
||||
* no use for either. WSProvider provides all three.
|
||||
* Declares the three contexts `WSProvider` fills from the WebSocket, and the
|
||||
* types they carry. They are split by how often each part changes, so that a
|
||||
* feed doesn't re-render with every chat message or presence change.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,225 +15,266 @@ import type { OnlineUser } from "../../../shared/ws.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
|
||||
/** A vote someone cast or withdrew. */
|
||||
/**
|
||||
* Set as `lastVoteEvent` by `WSProvider` on a `votes_update`. The profile
|
||||
* pages read it to add or drop a dump from a user's upvoted list.
|
||||
*/
|
||||
export interface VoteEvent {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Who voted. */
|
||||
voterId: string;
|
||||
/** Whether the vote was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** A like someone cast or withdrew. */
|
||||
/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */
|
||||
export interface LikeEvent {
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Who liked it. */
|
||||
likerId: string;
|
||||
/** Whether the like was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** A playlist created, changed, deleted, or whose dumps changed. */
|
||||
/**
|
||||
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message.
|
||||
* The pages that show playlists apply it, some through `usePlaylistListSync`.
|
||||
* Which fields are set depends on `type`.
|
||||
*/
|
||||
export interface PlaylistEvent {
|
||||
/** What happened. */
|
||||
type: "created" | "updated" | "deleted" | "dumps_updated";
|
||||
/** The playlist's id. */
|
||||
playlistId: string;
|
||||
/** The playlist as it is now, when created or updated. */
|
||||
/** Set when created or updated. */
|
||||
playlist?: Playlist;
|
||||
/** The owner's id, when deleted. */
|
||||
/** The owner's id, set when deleted. */
|
||||
userId?: string;
|
||||
/** The dumps the user can see in it, in order, when its dumps changed. */
|
||||
/**
|
||||
* Set when the dumps changed. It lists only the dumps this user may see, in
|
||||
* order.
|
||||
*/
|
||||
dumpIds?: string[];
|
||||
/** How many dumps it holds, when its dumps changed. */
|
||||
/** Set when the dumps changed. It counts private dumps too. */
|
||||
dumpCount?: number;
|
||||
}
|
||||
|
||||
/** A comment posted, edited or deleted. */
|
||||
/**
|
||||
* Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The
|
||||
* `Dump` page applies it to its thread, and `useDumpListSync` to its comment
|
||||
* counts.
|
||||
*/
|
||||
export interface CommentEvent {
|
||||
/** What happened. */
|
||||
type: "created" | "deleted" | "updated";
|
||||
/** The id of the dump it is under. */
|
||||
dumpId: string;
|
||||
/** The comment as it is now, when posted or edited. */
|
||||
/** Set when created or updated. */
|
||||
comment?: Comment;
|
||||
/** The comment's id, when deleted. */
|
||||
/** Set when deleted. */
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
/** A user whose public profile changed. */
|
||||
/**
|
||||
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the
|
||||
* server sends on any change to a profile. `UserPublicProfile` applies it.
|
||||
*/
|
||||
export interface UserEvent {
|
||||
/** The user as they are now. */
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/** The replied-to message, as shown in the reply bar and on a pending send. */
|
||||
/**
|
||||
* Chosen in `ChatModal` for the reply bar, and kept on the pending message so
|
||||
* it shows the reply before the server's echo. Only `id` goes to the server.
|
||||
*/
|
||||
export interface ChatReplyTarget {
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** Its author's name. */
|
||||
author: string;
|
||||
/** Its text. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message this client sent that the server hasn't echoed back yet — shown in
|
||||
* the list as "sending", or "failed" with a way to retry or discard it.
|
||||
* Held by `WSProvider` for a chat message this client sent and the server
|
||||
* hasn't echoed back yet. `ChatModal` shows it, as `PendingItem`, after the
|
||||
* messages the server has.
|
||||
*/
|
||||
export interface PendingChatMessage {
|
||||
/** The id the echo will carry back. */
|
||||
/**
|
||||
* 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;
|
||||
/** The message. */
|
||||
body: string;
|
||||
/** The message it replies to. */
|
||||
replyTo?: ChatReplyTarget;
|
||||
/** When it was sent. */
|
||||
/** When this client sent it, to find it in the history after a reconnect. */
|
||||
createdAt: Date;
|
||||
/** Whether it is on its way or failed. */
|
||||
/**
|
||||
* `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";
|
||||
/** Why it failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
|
||||
/**
|
||||
* Set as `chatError` by `WSProvider` when the server refuses a chat edit or
|
||||
* delete. `ChatModal` shows it for a few seconds.
|
||||
*/
|
||||
export interface ChatError {
|
||||
/** Distinguishes one error from the next. */
|
||||
/** Tells apart two errors with the same message. */
|
||||
id: number;
|
||||
/** What went wrong. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link WSContext} holds: the connection, votes, likes, and the live
|
||||
* dump, playlist, comment, user and notification events.
|
||||
* Provided by `WSProvider` through {@link WSContext}, read with `useWS`. The
|
||||
* `last*Event` fields hold only the latest event of their 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 {
|
||||
/** The connection's state. */
|
||||
wsStatus: "connecting" | "connected" | "disconnected";
|
||||
/** Why the socket is down, while it is. */
|
||||
/** Set while the socket is down, for the banner that says so. */
|
||||
wsErrorMessage: string | null;
|
||||
/** Live vote counts, by dump id. */
|
||||
/**
|
||||
* Counts known to the socket, by dump id: pushed by the server, or changed
|
||||
* optimistically. A missing dump shows its fetched `voteCount`.
|
||||
*/
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
/** Reset from the server's `welcome` on every connection. */
|
||||
myVotes: Set<string>;
|
||||
/** Live like counts, by comment id. */
|
||||
/** As `voteCounts`, for comments. */
|
||||
likeCounts: Record<string, number>;
|
||||
/** The ids of the comments the user likes. */
|
||||
/** As `myVotes`, for comments. */
|
||||
myLikes: Set<string>;
|
||||
/** Dumps posted (or made public) this session, newest first (capped). */
|
||||
/**
|
||||
* Dumps that arrived this session, newest first and at most 100: the ones
|
||||
* the server announced, which are public, and the ones this user posted,
|
||||
* which may be private. `Index` puts them above the feed it fetched.
|
||||
*/
|
||||
recentDumps: Dump[];
|
||||
/** Ids of dumps deleted this session. */
|
||||
/**
|
||||
* Dumps deleted or made private this session, for the lists to filter out.
|
||||
* A dump made public again leaves it.
|
||||
*/
|
||||
deletedDumpIds: Set<string>;
|
||||
/** The latest vote. */
|
||||
lastVoteEvent: VoteEvent | null;
|
||||
/** The latest like. */
|
||||
/** Read by no consumer at the moment. */
|
||||
lastLikeEvent: LikeEvent | null;
|
||||
/** The latest edited dump. */
|
||||
/** The dump as it is after its latest edit. */
|
||||
lastDumpEvent: Dump | null;
|
||||
/** The latest playlist change. */
|
||||
lastPlaylistEvent: PlaylistEvent | null;
|
||||
/** Ids of playlists deleted this session. */
|
||||
deletedPlaylistIds: Set<string>;
|
||||
/** The latest comment change. */
|
||||
lastCommentEvent: CommentEvent | null;
|
||||
/** The latest profile change. */
|
||||
lastUserEvent: UserEvent | null;
|
||||
/** How many notifications the user hasn't read. */
|
||||
/** Read from the `welcome`, then counted up by each new notification. */
|
||||
unreadNotificationCount: number;
|
||||
/** The latest notification received. */
|
||||
/** Read by the bell, which rings, and by the notifications page. */
|
||||
lastNotification: Notification | null;
|
||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||
/**
|
||||
* Incremented on each reconnect, never on the first connection, so pages
|
||||
* fetch again what they missed while the socket was down.
|
||||
*/
|
||||
connectionEpoch: number;
|
||||
/**
|
||||
* Upvotes a dump, optimistically. `shownCount`: the count on screen, used
|
||||
* until a live one arrives.
|
||||
* 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, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Withdraws an upvote, as {@link WSContextValue.castVote} casts one. */
|
||||
removeVote: (dumpId: string, shownCount?: number) => void;
|
||||
/** Likes a comment, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Likes a comment, as {@link WSContextValue.castVote} upvotes a dump. */
|
||||
castCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
/** Withdraws a like, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
|
||||
removeCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
/**
|
||||
* Adds a dump this client just posted to `recentDumps`, without waiting for
|
||||
* its broadcast.
|
||||
* Adds a dump this user just posted to `recentDumps`, without waiting for
|
||||
* its broadcast, which a private dump never gets. `DumpCreateModal` calls it.
|
||||
*/
|
||||
injectDump: (dump: Dump) => void;
|
||||
/** Sets the unread notification count to zero. */
|
||||
/**
|
||||
* Zeroes the bell's count, once the notifications page has marked them read
|
||||
* on the server.
|
||||
*/
|
||||
clearUnreadNotifications: () => void;
|
||||
}
|
||||
|
||||
/** The chat room: its live stream, unread state and what to send into it. */
|
||||
/**
|
||||
* Provided by `WSProvider` through {@link WSChatContext}, read with
|
||||
* `useWSChat`. The socket sends chat messages only to signed-in users, so for
|
||||
* a visitor it stays empty.
|
||||
*/
|
||||
export interface WSChatContextValue {
|
||||
/** Live buffer of chat messages received this session (capped). */
|
||||
/**
|
||||
* The messages received this session, at most 200, for `ChatModal` to merge
|
||||
* with the history it fetches. A reconnect empties it while the chat is
|
||||
* closed, since `ChatModal` fetches everything again on open.
|
||||
*/
|
||||
chatMessages: ChatMessage[];
|
||||
/**
|
||||
* Ids of chat messages deleted live this session, so fetched history can
|
||||
* filter them out.
|
||||
* Messages deleted this session, which `ChatModal` filters out of its
|
||||
* fetched history.
|
||||
*/
|
||||
deletedChatIds: Set<string>;
|
||||
/**
|
||||
* Messages from others since the read marker — kept by the server, so it
|
||||
* survives reloads and agrees across tabs and devices.
|
||||
* Messages from others after the read marker. The server keeps the marker,
|
||||
* so the count agrees across tabs and devices.
|
||||
*/
|
||||
unreadChatCount: number;
|
||||
/**
|
||||
* The server's read marker: created_at of the newest message the user has
|
||||
* seen. Null until the socket's welcome.
|
||||
* The time of the newest message the user has seen, which the server keeps.
|
||||
* Null until the `welcome`, and for a visitor.
|
||||
*/
|
||||
chatReadAt: Date | null;
|
||||
/** Messages sent and not yet echoed back. */
|
||||
pendingChatMessages: PendingChatMessage[];
|
||||
/** The latest failed edit or delete, until dismissed. */
|
||||
/** Only for edits and deletes. A failed send shows on its pending message. */
|
||||
chatError: ChatError | null;
|
||||
/**
|
||||
* Users currently composing a chat message (excludes nobody — the chatbox
|
||||
* filters out the current user itself). Shaped like presence for avatars.
|
||||
*/
|
||||
/** Includes the user when they are typing: `ChatModal` filters them out. */
|
||||
typingUsers: OnlineUser[];
|
||||
/** Sends a message, replying to `replyTo` if given. */
|
||||
/** Trims `body` and sends it as a pending message. Ignores a blank body. */
|
||||
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||
/** Resends the failed message `nonce`. */
|
||||
retryChatMessage: (nonce: string) => void;
|
||||
/** Drops the failed message `nonce`. */
|
||||
discardChatMessage: (nonce: string) => void;
|
||||
/** Edits a message. */
|
||||
/**
|
||||
* 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;
|
||||
/** Deletes a message. */
|
||||
/** Sends the deletion if the socket is open, as `editChatMessage` does. */
|
||||
deleteChatMessage: (id: string) => void;
|
||||
/** Clears `chatError`. */
|
||||
dismissChatError: () => void;
|
||||
/** Moves the read marker up to the message created at `at`. */
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Folds an authoritative fetch of the stream between `from` and `to` into the
|
||||
* live buffer after a reconnect: buffered messages in that range which the
|
||||
* server no longer has were deleted while the socket was down.
|
||||
* Replaces the live buffer's messages between `from` and `to` with
|
||||
* `messages`, which `ChatModal` fetched after a reconnect. 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;
|
||||
/**
|
||||
* Informs the server whether the user is currently composing a message, so it
|
||||
* can show/hide the "is typing" indicator to other clients.
|
||||
* 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;
|
||||
/**
|
||||
* Informs the server whether the chatbox is open (suppresses redundant
|
||||
* mention notifications while the user is reading chat).
|
||||
* Tells the server whether the user is reading the chat: open, in a visible
|
||||
* tab. The server sends no notification for a mention the user reads live,
|
||||
* and this client doesn't count the message unread.
|
||||
*/
|
||||
setChatOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Who's connected. */
|
||||
/**
|
||||
* Provided by `WSProvider` through {@link WSPresenceContext}, read with
|
||||
* `useWSPresence`.
|
||||
*/
|
||||
export interface WSPresenceContextValue {
|
||||
/** The signed-in users with a socket open. */
|
||||
/** The signed-in users with a socket open, each once. */
|
||||
onlineUsers: OnlineUser[];
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`, as if it were still connecting. */
|
||||
export const defaultWSValue: WSContextValue = {
|
||||
wsStatus: "connecting",
|
||||
wsErrorMessage: null,
|
||||
@@ -262,7 +302,7 @@ export const defaultWSValue: WSContextValue = {
|
||||
clearUnreadNotifications: () => {},
|
||||
};
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`: an empty chat. */
|
||||
export const defaultWSChatValue: WSChatContextValue = {
|
||||
chatMessages: [],
|
||||
deletedChatIds: new Set(),
|
||||
@@ -283,22 +323,25 @@ export const defaultWSChatValue: WSChatContextValue = {
|
||||
setChatOpen: () => {},
|
||||
};
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`: nobody is online. */
|
||||
export const defaultWSPresenceValue: WSPresenceContextValue = {
|
||||
onlineUsers: [],
|
||||
};
|
||||
|
||||
/** The socket context: connection, votes, likes and live events. */
|
||||
/**
|
||||
* Holds the connection, the votes and likes, and the latest live event of
|
||||
* each kind. Read it with `useWS`.
|
||||
*/
|
||||
export const WSContext: React.Context<WSContextValue> = createContext<
|
||||
WSContextValue
|
||||
>(defaultWSValue);
|
||||
/** The chat context. */
|
||||
/** Holds the chat's live stream and read state. Read it with `useWSChat`. */
|
||||
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
|
||||
WSChatContextValue
|
||||
>(
|
||||
defaultWSChatValue,
|
||||
);
|
||||
/** The presence context. */
|
||||
/** Holds who is online. Read it with `useWSPresence`. */
|
||||
export const WSPresenceContext: React.Context<WSPresenceContextValue> =
|
||||
createContext<
|
||||
WSPresenceContextValue
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* The socket's provider: one connection per session, and the live state it
|
||||
* feeds — presence, votes, likes, dumps, playlists, comments, users,
|
||||
* notifications and the chat.
|
||||
* Holds the app's one WebSocket to `/ws` and turns its messages into the state
|
||||
* of `WSContext`, `WSChatContext`, `WSPresenceContext` and
|
||||
* `AvatarOverrideContext`. `App` mounts it around every page. The protocol is
|
||||
* `shared/ws.ts`, and the server's side is `api/routes/ws.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -59,69 +60,55 @@ import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
import { playChime } from "../../utils/shared/sound.ts";
|
||||
import { mentionsUser } from "../../utils/chat/text.ts";
|
||||
|
||||
/** The props of {@link WSProvider}. */
|
||||
/** Passed to {@link WSProvider} by `App`. */
|
||||
export interface WSProviderProps {
|
||||
/** The app, which reads the live state. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The longest wait between reconnection attempts, in ms. */
|
||||
const MAX_BACKOFF = 30_000;
|
||||
/**
|
||||
* How long an optimistic vote or like waits for its acknowledgement before it
|
||||
* is rolled back, in ms.
|
||||
*/
|
||||
/** How long an optimistic vote or like waits for the server, in ms. */
|
||||
const ACK_TIMEOUT = 5_000;
|
||||
/**
|
||||
* How long a connection attempt may take before the socket is shown as down, in
|
||||
* ms.
|
||||
*/
|
||||
/** How long a connection attempt may take before the banner shows, in ms. */
|
||||
const CONNECT_TIMEOUT = 2_500;
|
||||
/**
|
||||
* Cap on the live "new dumps" buffer, so a long-lived session can't grow it
|
||||
* without bound. Feeds merge these on top of their own paginated list, so a
|
||||
* generous cap is plenty — older live arrivals are reachable via normal
|
||||
* pagination.
|
||||
* The most dumps `recentDumps` holds. Older arrivals are in the feeds' later
|
||||
* pages.
|
||||
*/
|
||||
const MAX_RECENT_DUMPS = 100;
|
||||
/**
|
||||
* Cap on the live chat buffer, so a long-lived session can't grow it without
|
||||
* bound. Older messages remain reachable via the /api/chat history endpoint.
|
||||
* The most messages `chatMessages` holds. Older ones are in the history,
|
||||
* which `ChatModal` fetches.
|
||||
*/
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
/**
|
||||
* How long a sent chat message may go without its echo before it's shown as
|
||||
* failed, in ms.
|
||||
*/
|
||||
/** How long a sent chat message waits for its echo, in ms. */
|
||||
const CHAT_SEND_TIMEOUT = 10_000;
|
||||
/** How often the read marker may go out, in ms. */
|
||||
/**
|
||||
* The least time between two read markers, in ms. The server drops markers
|
||||
* past a rate of one a second.
|
||||
*/
|
||||
const CHAT_READ_THROTTLE = 1_000;
|
||||
/**
|
||||
* A failed send whose text turns up in the resynced history within this long of
|
||||
* being sent did reach the server — only its echo was lost.
|
||||
* How close in time a message of the history must be to a failed send with
|
||||
* the same text to count as that send, in ms.
|
||||
*/
|
||||
const CHAT_ECHO_WINDOW = 60_000;
|
||||
|
||||
/** An optimistic vote awaiting its acknowledgement. */
|
||||
/** An optimistic vote waiting for the server. */
|
||||
interface PendingVote {
|
||||
/** The timer that rolls it back. */
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
/** Undoes it. */
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
/** An optimistic like awaiting its acknowledgement. */
|
||||
/** An optimistic like waiting for the server. */
|
||||
interface PendingLike {
|
||||
/** The timer that rolls it back. */
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
/** Undoes it. */
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `data` as a server message, or null when it isn't one. The runtime
|
||||
* check is minimal: the `type` field must be a string, so the cast to the
|
||||
* discriminated union is safe and TypeScript narrows from there.
|
||||
* Returns `data` as a server message, or null when it isn't JSON with a string
|
||||
* `type`. The fields aren't checked: the server is trusted.
|
||||
*/
|
||||
function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
||||
try {
|
||||
@@ -137,12 +124,11 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
||||
|
||||
/**
|
||||
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
|
||||
* and {@link AvatarOverrideContext}. It connects with the session's token and
|
||||
* reconnects with a growing backoff; each reconnect bumps `connectionEpoch` so
|
||||
* pages backfill what they missed. Votes, likes and chat messages apply at once
|
||||
* and are confirmed or rolled back by the server's answer. A new notification
|
||||
* or chat message chimes, and the unread count goes in the tab title. A
|
||||
* `force_logout` signs the user out; a change of session clears the live state.
|
||||
* and {@link AvatarOverrideContext}. It connects with the session's token, or
|
||||
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token
|
||||
* (sign-in, sign-out, another account) clears the live state and reconnects.
|
||||
* A new notification or chat message chimes, and the unread counts go in the
|
||||
* tab title. A `force_logout` signs the user out.
|
||||
*/
|
||||
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { token, user, logout } = useAuth();
|
||||
@@ -152,9 +138,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
>("connecting");
|
||||
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Tracks the token to detect login/logout below. The actual reset runs after
|
||||
// all state declarations (see the `prevToken !== token` block) because it
|
||||
// touches setters that aren't in scope yet here.
|
||||
// For the reset below, which must follow every state declaration.
|
||||
const [prevToken, setPrevToken] = useState(token);
|
||||
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
@@ -190,20 +174,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
>([]);
|
||||
const [chatError, setChatError] = useState<ChatError | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
// Bumped each time the socket reconnects (a `welcome` after the first), so
|
||||
// pages can backfill events they missed while disconnected. Monotonic — not
|
||||
// reset on token change.
|
||||
// Never reset: pages compare it with the value they last saw.
|
||||
const [connectionEpoch, setConnectionEpoch] = useState(0);
|
||||
|
||||
// Reset during render when the token changes (login/logout/account switch),
|
||||
// rather than inside the effect (which would cause a cascading re-render).
|
||||
// The socket reconnects and `welcome` re-syncs presence/votes/likes/unread,
|
||||
// but the accumulated realtime state below would otherwise bleed from the
|
||||
// previous session into the next (e.g. another account's recent dumps).
|
||||
// A new token means another session: the `welcome` resyncs votes, likes and
|
||||
// unread counts, but what the last session gathered (another account's
|
||||
// private dumps, its pending messages) must go. Reset during render, since
|
||||
// an effect would render the stale state once more.
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setWSStatus("connecting");
|
||||
@@ -234,15 +214,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
// The socket's handlers and the stable callbacks read the current values
|
||||
// through these refs. Depending on the values instead would reconnect the
|
||||
// socket, or change the callbacks, on every vote.
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
const myVotesRef = useRef(myVotes);
|
||||
const likeCountsRef = useRef(likeCounts);
|
||||
const myLikesRef = useRef(myLikes);
|
||||
const userIdRef = useRef(userId);
|
||||
const usernameRef = useRef(user?.username ?? null);
|
||||
// Stable ref for logout so the effect doesn't reconnect when the function
|
||||
// reference changes on re-renders.
|
||||
const onForceLogoutRef = useRef(logout);
|
||||
useLayoutEffect(() => {
|
||||
voteCountsRef.current = voteCounts;
|
||||
@@ -255,23 +235,23 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks whether the chatbox is open so it can be re-announced to the server
|
||||
// after a reconnect (the fresh socket starts with the flag cleared).
|
||||
// A new socket starts with the chat closed on the server's side, so the
|
||||
// `welcome` handler announces it again.
|
||||
const chatOpenRef = useRef(false);
|
||||
// The user is only actually reading the chat when the chatbox is open *and*
|
||||
// the tab is on screen: a chatbox left open in a background tab must not
|
||||
// A chat left open in a background tab isn't being read: it must not
|
||||
// swallow mention notifications or unread counts.
|
||||
const isViewingChat = useCallback(
|
||||
() => chatOpenRef.current && !document.hidden,
|
||||
[],
|
||||
);
|
||||
// Echo timeouts of in-flight chat sends, by nonce.
|
||||
// By nonce. A send is in flight while it has a timer here.
|
||||
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const chatErrorSeqRef = useRef(0);
|
||||
// Read marker bookkeeping: the newest marker sent (or learned from the
|
||||
// server), and a trailing timer so a burst of arrivals sends one marker.
|
||||
// The read marker, in ms. `Sent` is the newest the server has, from this tab
|
||||
// or another. `Queued` is the newest to send, which a trailing timer sends
|
||||
// once for a burst of messages.
|
||||
const chatReadSentRef = useRef(0);
|
||||
const chatReadQueuedRef = useRef(0);
|
||||
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -293,11 +273,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
|
||||
}, []);
|
||||
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
// By dump id, and by comment id for likes. One per item: a second click
|
||||
// before the answer replaces the first's timer, so only the latest change
|
||||
// can be rolled back.
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
);
|
||||
// Tracks pending optimistic comment likes: commentId → pending rollback handler
|
||||
const pendingLikesRef = useRef<Map<string, PendingLike>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -360,8 +341,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let everConnected = false;
|
||||
// Counts `welcome` messages for this token; the first is the initial
|
||||
// connect, any later one is a reconnect worth a backfill epoch bump.
|
||||
// For this token. Every `welcome` after the first is a reconnect.
|
||||
let welcomeCount = 0;
|
||||
|
||||
function connect() {
|
||||
@@ -373,6 +353,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const ws = new WebSocket(url);
|
||||
socketRef.current = ws;
|
||||
|
||||
// A connection that hangs may take far longer than this to fail. Closing
|
||||
// it shows the banner and goes through `onclose` to the next attempt.
|
||||
connectTimeout = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.CONNECTING) return;
|
||||
setWSStatus("disconnected");
|
||||
@@ -398,13 +380,14 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
// The server closes a socket that misses a pong.
|
||||
ws.send(
|
||||
JSON.stringify({ type: "pong" } satisfies ClientToServerMessage),
|
||||
);
|
||||
break;
|
||||
|
||||
case "welcome":
|
||||
backoff = 500; // reset backoff on successful connect
|
||||
backoff = 500;
|
||||
setOnlineUsers(msg.users);
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
@@ -418,24 +401,21 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
readAt.getTime(),
|
||||
);
|
||||
}
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
// The server's sets replace whatever was pending. The optimistic
|
||||
// counts stay, though, until a live count replaces them.
|
||||
clearAllPendingVotes();
|
||||
clearAllPendingLikes();
|
||||
// Reconnect (not the first welcome): signal pages to backfill the
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) {
|
||||
setConnectionEpoch((n) => n + 1);
|
||||
// With the chatbox closed nothing will resync the live buffer,
|
||||
// and whatever was edited or deleted meanwhile would win over
|
||||
// the fresh history it fetches on open — so just drop it.
|
||||
// With the chat closed, nothing resyncs the live buffer, and its
|
||||
// stale copies would win over the history fetched on open.
|
||||
if (!chatOpenRef.current) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
}
|
||||
}
|
||||
// A marker that was waiting on the socket goes out now.
|
||||
// A marker set while offline goes out now.
|
||||
if (chatReadQueuedRef.current > chatReadSentRef.current) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
@@ -447,8 +427,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
);
|
||||
chatReadSentRef.current = chatReadQueuedRef.current;
|
||||
}
|
||||
// Restore the chat-open flag on the fresh connection so mention
|
||||
// suppression keeps working across reconnects.
|
||||
// The server knows the chat is open per socket, and this one is
|
||||
// new.
|
||||
if (isViewingChat()) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
@@ -469,8 +449,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { dumpId, voteCount, voterId, action } = msg;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
setLastVoteEvent({ dumpId, voterId, action });
|
||||
// Keep myVotes in sync across tabs: if this vote event belongs to
|
||||
// the current user (from another tab), update myVotes accordingly.
|
||||
// The user's own vote, from this tab or another: it settles the
|
||||
// pending one and keeps every tab's button in step.
|
||||
if (voterId === userIdRef.current) {
|
||||
clearPendingVote(dumpId);
|
||||
setMyVotes((prev) => {
|
||||
@@ -487,8 +467,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { commentId, likeCount, likerId, action } = msg;
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
setLastLikeEvent({ commentId, likerId, action });
|
||||
// Keep myLikes in sync across tabs: if this like event belongs to
|
||||
// the current user (from another tab), update myLikes accordingly.
|
||||
// As for votes.
|
||||
if (likerId === userIdRef.current) {
|
||||
clearPendingLike(commentId);
|
||||
setMyLikes((prev) => {
|
||||
@@ -502,8 +481,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "dump_created": {
|
||||
// Also sent when a dump goes from private to public: to everyone
|
||||
// who couldn't see it, that's an arrival.
|
||||
// Also sent when a dump goes public, which to everyone else is an
|
||||
// arrival, and may undo its `dump_deleted` from going private.
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setDeletedDumpIds((prev) => {
|
||||
if (!prev.has(dump.id)) return prev;
|
||||
@@ -523,9 +502,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "dump_updated": {
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setLastDumpEvent(dump);
|
||||
// An edit is not an arrival: refresh a live entry in place, but
|
||||
// don't pull an old dump to the top of the feeds (going public
|
||||
// comes as a `dump_created`).
|
||||
// An edit is not an arrival: it must not pull an old dump to the
|
||||
// top of the feeds.
|
||||
setRecentDumps((prev) =>
|
||||
prev.some((d) => d.id === dump.id)
|
||||
? prev.map((d) => d.id === dump.id ? dump : d)
|
||||
@@ -535,6 +513,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "dump_deleted": {
|
||||
// Also sent when a dump goes private.
|
||||
const { dumpId } = msg;
|
||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
@@ -544,9 +523,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
clearPendingVote(dumpId);
|
||||
// Reconcile with authoritative count
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
// Confirm vote state
|
||||
setMyVotes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(dumpId);
|
||||
@@ -559,9 +536,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "comment_like_ack": {
|
||||
const { commentId, action, likeCount } = msg;
|
||||
clearPendingLike(commentId);
|
||||
// Reconcile with authoritative count
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
// Confirm like state
|
||||
setMyLikes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(commentId);
|
||||
@@ -603,8 +578,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "user_updated": {
|
||||
const user = deserializePublicUser(msg.user);
|
||||
setLastUserEvent({ user });
|
||||
// Refresh this user's avatar everywhere it's rendered. updatedAt
|
||||
// (bumped on any profile/avatar write) doubles as the version.
|
||||
setAvatarOverrides((prev) => ({
|
||||
...prev,
|
||||
[user.id]: {
|
||||
@@ -655,16 +628,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
||||
});
|
||||
// The sender's own echo: its pending copy is now a message.
|
||||
if (msg.nonce) retirePendingChat(msg.nonce);
|
||||
// Count as unread unless it's the current user's own message, or
|
||||
// they're looking at it (the chatbox then moves the read marker).
|
||||
// A message read live isn't counted: the chat moves the marker.
|
||||
if (message.userId !== userIdRef.current) {
|
||||
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
|
||||
const username = usernameRef.current;
|
||||
if (username && mentionsUser(message.body, username)) {
|
||||
// Someone reading the chat gets no notification for a mention
|
||||
// (the server skips it), so the chime has to come from here;
|
||||
// otherwise the notification that follows brings its own.
|
||||
// The server sends no notification for a mention read live, so
|
||||
// the chime comes from here. Otherwise the notification that
|
||||
// follows chimes.
|
||||
if (isViewingChat()) playChime("notify", message.id);
|
||||
} else if (!isViewingChat()) {
|
||||
playChime("chat", message.id);
|
||||
@@ -675,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
case "chat_message_updated": {
|
||||
const message = deserializeChatMessage(msg.message);
|
||||
// An edit of a message older than the buffer is added to it, so
|
||||
// it wins over the history's copy. `ChatModal` sorts by time.
|
||||
setChatMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === message.id);
|
||||
if (idx === -1) return [...prev, message];
|
||||
@@ -697,6 +672,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
break;
|
||||
|
||||
case "chat_read_update": {
|
||||
// Sent to every socket of the user, whichever tab moved it.
|
||||
const readAt = new Date(msg.readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
@@ -718,9 +694,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
else setChatError({ id: ++chatErrorSeqRef.current, message });
|
||||
break;
|
||||
}
|
||||
// When the server identifies the failed optimistic action, revert
|
||||
// its pending vote/like right away instead of waiting out the ACK
|
||||
// timeout. Without context, the per-action timer handles cleanup.
|
||||
// A failed vote or like names its dump or comment, so it is
|
||||
// rolled back at once. Other errors leave it to the timer.
|
||||
if (msg.dumpId) {
|
||||
const pending = pendingRef.current.get(msg.dumpId);
|
||||
if (pending) {
|
||||
@@ -747,9 +722,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
connectTimeout = null;
|
||||
}
|
||||
if (closed) return;
|
||||
// Sends still waiting on their echo won't get one on this socket.
|
||||
// They may have landed, though: the resync after reconnect retires
|
||||
// any whose text shows up in the history.
|
||||
// Sends still waiting won't get their echo on this socket. They may
|
||||
// have landed, though: `reconcileChat` retires those it finds.
|
||||
for (const nonce of chatSendTimersRef.current.keys()) {
|
||||
failPendingChat(
|
||||
nonce,
|
||||
@@ -769,7 +743,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
// `onclose` follows, and reconnects.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -804,11 +778,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
]);
|
||||
|
||||
const castVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
// Until the socket brings a count, the one on screen is the only one.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (prevVoted) return; // already voted
|
||||
if (prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -817,7 +790,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -834,15 +806,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
// Nothing is sent while the socket is down, so the timer rolls it back.
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (!prevVoted) return; // not voted
|
||||
if (!prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -854,7 +824,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
[dumpId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -871,16 +840,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const castCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (prevLiked) return; // already liked
|
||||
if (prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -889,7 +855,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -909,18 +874,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
|
||||
const removeCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return; // not liked
|
||||
if (!prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -932,7 +894,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
[commentId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -952,7 +913,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
@@ -968,9 +928,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
// Sends (or resends) a pending message: it shows as "sending" until its echo
|
||||
// arrives, or as failed if the socket is down, the server rejects it, or no
|
||||
// echo comes back in time.
|
||||
// The pending message leaves the list with its echo (`chat_message`), or
|
||||
// fails on a refusal (`error`), on a closed socket or after the timeout.
|
||||
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
@@ -1025,8 +984,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const retryChatMessage = useCallback((nonce: string) => {
|
||||
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
|
||||
if (!pending) return;
|
||||
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
|
||||
// retire the retry.
|
||||
// A late echo of the first attempt must not retire the retry.
|
||||
const retry: PendingChatMessage = {
|
||||
...pending,
|
||||
nonce: crypto.randomUUID(),
|
||||
@@ -1076,7 +1034,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const at = chatReadQueuedRef.current;
|
||||
if (at <= chatReadSentRef.current) return;
|
||||
const socket = socketRef.current;
|
||||
// Offline: stays queued, and the next welcome sends it.
|
||||
// Offline, it stays queued, and the next `welcome` sends it.
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
@@ -1096,7 +1054,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
chatReadQueuedRef.current = time;
|
||||
// The badge goes at once; the server's count follows with the marker.
|
||||
// The badge goes at once. The server's count follows the marker.
|
||||
setUnreadChatCount(0);
|
||||
if (chatReadTimerRef.current) return;
|
||||
const wait = Math.max(
|
||||
@@ -1113,9 +1071,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
const reconcileChat = useCallback(
|
||||
(messages: ChatMessage[], from: Date, to: Date) => {
|
||||
// In that stretch the fetched copy is the truth: a live copy of a
|
||||
// message edited while the socket was down is stale, and the merge in
|
||||
// the chatbox would let it win.
|
||||
// A live copy of a message edited while the socket was down is stale,
|
||||
// and `ChatModal`'s merge would let it win over the fetched one.
|
||||
const byId = new Map(messages.map((m) => [m.id, m]));
|
||||
setChatMessages((prev) =>
|
||||
prev
|
||||
@@ -1124,7 +1081,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
)
|
||||
.map((m) => byId.get(m.id) ?? m)
|
||||
);
|
||||
// Failed sends that did land (only the echo was lost) are retired.
|
||||
// A failed send that did land lost only its echo. Its text and time
|
||||
// are all there is to recognize it by.
|
||||
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
||||
for (const pending of pendingChatRef.current) {
|
||||
if (pending.status !== "failed") continue;
|
||||
@@ -1149,8 +1107,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tell the server whether the user is reading the chat right now (chatbox
|
||||
// open and tab visible), so it knows when a mention notification is moot.
|
||||
const sendChatFocus = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
@@ -1169,7 +1125,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
sendChatFocus();
|
||||
}, [sendChatFocus]);
|
||||
|
||||
// Re-announce chat focus whenever the tab is hidden or shown.
|
||||
// Hiding the tab stops the user reading an open chat, and showing it again
|
||||
// resumes.
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (chatOpenRef.current) sendChatFocus();
|
||||
@@ -1178,7 +1135,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, [sendChatFocus]);
|
||||
|
||||
// Unread count in the tab title, so a background tab shows there's news.
|
||||
useEffect(() => {
|
||||
setTitleBadge(unreadNotificationCount + unreadChatCount);
|
||||
}, [unreadNotificationCount, unreadChatCount]);
|
||||
|
||||
Reference in New Issue
Block a user