v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 07:23:03 +00:00
parent b85a71d86e
commit 563daa720c
171 changed files with 3921 additions and 851 deletions

View File

@@ -1,8 +1,14 @@
/**
* The session, and requests made with it.
*
* @module
*/
import { useCallback, useContext } from "react";
import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import type { AuthResponse } from "../../../shared/user.ts";
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
import type { Session } from "../../utils/auth/storage.ts";
import {
forgetStoredSession,
@@ -10,7 +16,56 @@ import {
storeSession,
} from "../../utils/auth/storage.ts";
export const useAuth = () => {
/** What {@link useAuth} returns. */
export interface UseAuthResult {
/** The signed-in user, null when signed out. */
user: SelfUser | null;
/** The session token. */
token: string | null;
/**
* The token for file and thumbnail URLs, where it is visible in the query
* string. Sessions from before media tokens fall back to the session token
* until AuthProvider fetches one.
*/
mediaToken: string | null;
/** Starts a session from a sign-in or sign-up response, in every tab. */
login: (response: AuthResponse) => void;
/**
* Ends the session this render holds. A 401 can land after the session has
* moved on to fresh tokens (a password change, here or in another tab), and
* must not sign those out.
*/
logout: () => void;
/** Merges `patch` into the current session, in memory and in storage. */
updateSession: (patch: Partial<Session>) => void;
/**
* `fetch` with the session's token, and a JSON content type unless the body
* is FormData. An expired token, or a 401, ends the session; the former
* answers a synthetic 401 without calling the server.
*/
authFetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a 50
* MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
authUpload: (
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method?: string,
) => Promise<Response>;
}
/** Returns the session and what acts on it. */
export function useAuth(): UseAuthResult {
const { authResponse, setAuthResponse } = useContext(AuthContext);
const login = useCallback((response: AuthResponse) => {
@@ -27,7 +82,6 @@ export const useAuth = () => {
forgetStoredSession(sessionToken);
}, [setAuthResponse, sessionToken]);
/** Merge into the current session, in memory and in storage. */
const updateSession = useCallback((patch: Partial<Session>) => {
setAuthResponse((prev) => {
if (!prev) return prev;
@@ -70,18 +124,6 @@ export const useAuth = () => {
return res;
}, [authResponse?.token, logout]);
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 0–1 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
const authUpload = useCallback((
url: string,
body: FormData,
@@ -133,9 +175,6 @@ export const useAuth = () => {
return {
user: authResponse?.user ?? null,
token: authResponse?.token ?? null,
// For file and thumbnail URLs, where the token is visible in the query
// string. Sessions from before media tokens fall back to the session token
// until AuthProvider fetches one.
mediaToken: authResponse?.mediaToken ?? authResponse?.token ?? null,
login,
logout,
@@ -143,9 +182,20 @@ export const useAuth = () => {
authFetch,
authUpload,
};
};
}
export const useRequiredAuth = () => {
/** What {@link useRequiredAuth} returns: {@link UseAuthResult} with a user. */
export interface UseRequiredAuthResult extends UseAuthResult {
/** The signed-in user. */
user: SelfUser;
}
/**
* Returns the session, for a page behind a sign-in.
*
* @throws {Error} When no one is signed in: the route should have guarded it.
*/
export function useRequiredAuth(): UseRequiredAuthResult {
const {
user,
token,
@@ -172,4 +222,4 @@ export const useRequiredAuth = () => {
authFetch,
authUpload,
};
};
}

View File

@@ -1,4 +1,16 @@
import { useContext } from "react";
import { CategoriesContext } from "../../contexts/category/CategoriesContext.ts";
/**
* The categories, from their context.
*
* @module
*/
export const useCategories = () => useContext(CategoriesContext);
import { useContext } from "react";
import {
CategoriesContext,
type CategoriesContextValue,
} from "../../contexts/category/CategoriesContext.ts";
/** Returns every category, and a way to refresh them. */
export function useCategories(): CategoriesContextValue {
return useContext(CategoriesContext);
}

View File

@@ -1,6 +1,16 @@
import { useContext } from "react";
import { ChatContext } from "../../contexts/chat/ChatContext.ts";
/**
* The chatbox's open state, from its context.
*
* @module
*/
export function useChat() {
import { useContext } from "react";
import {
ChatContext,
type ChatContextValue,
} from "../../contexts/chat/ChatContext.ts";
/** Returns whether the chatbox is open, and a way to open and close it. */
export function useChat(): ChatContextValue {
return useContext(ChatContext);
}

View File

@@ -1,25 +1,59 @@
/**
* Link behaviour for a clickable feed card.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
import type { Dump } from "../../../shared/dump.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../../utils/shared/visited.ts";
/** Checks whether `e` happened over an anchor inside the card. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
/** Checks whether `e` is a ctrl/cmd/shift click. */
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
/** What {@link useDumpCardLink} returns. */
export interface UseDumpCardLinkResult {
/** Navigates in place. */
go: () => void;
/**
* For click handlers that do something other than navigate (play): opens the
* dump in a new tab and returns true when the click was a new-tab request.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** The handlers to spread onto the card. */
cardProps: {
/** Opens the dump, in a new tab for a ctrl/cmd/shift click. */
onClick: (e: MouseEvent) => void;
/**
* Stops a middle press from starting autoscroll (Windows and Linux
* browsers), which eats the auxclick that follows.
*/
onMouseDown: (e: MouseEvent) => void;
/** Opens the dump in a new tab on a middle click. */
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* A feed card is a clickable element rather than a link, so it gets none of a
* link's behaviour for free. This gives it back: a middle click (or a
* ctrl/cmd/shift click) anywhere on the card opens the dump in a new tab, even
* over a play button whose plain click starts the media. Anchors inside the
* card (the title, a source link, category chips) keep their own target.
* Returns link behaviour for the card of `dump`. A feed card is a clickable
* element rather than a link, so it gets none of a link's behaviour for free.
* This gives it back: a middle click (or a ctrl/cmd/shift click) anywhere on
* the card opens the dump in a new tab, even over a play button whose plain
* click starts the media. Anchors inside the card (the title, a source link,
* category chips) keep their own target. Either way the dump is marked visited.
*/
export function useDumpCardLink(dump: Pick<Dump, "id" | "slug">) {
export function useDumpCardLink(
dump: Pick<Dump, "id" | "slug">,
): UseDumpCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
@@ -33,10 +67,7 @@ export function useDumpCardLink(dump: Pick<Dump, "id" | "slug">) {
}
return {
/** Navigates in place. */
go,
/** For click handlers that do something other than navigate (play):
* returns true when the click was a new-tab request and was handled. */
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();

View File

@@ -1,8 +1,15 @@
/**
* Keeping a list of dumps in step with the socket.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Dump } from "../../../shared/dump.ts";
import { useWS } from "../ws/useWS.ts";
interface DumpListSyncOptions {
/** How {@link useDumpListSync} treats the list. */
export interface DumpListSyncOptions {
/** Keep private dumps visible (caller is the owner). */
isOwner?: boolean;
/**
@@ -18,11 +25,13 @@ interface DumpListSyncOptions {
}
/**
* Keeps a dump list in sync with real-time WS events:
* - deletedDumpIds growing → filter
* - lastDumpEvent "updated" → update in-place, or remove if now private
* - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
* - lastCommentEvent created/deleted → adjust the dump's commentCount
* Keeps the dump list set through `setDumps` in sync with real-time WS events:
*
* - a dump deleted → filtered out
* - a dump updated → updated in place, or removed if now private
* - an updated dump not in the list → prepended if `ownerId` matches and it is
* visible
* - a comment created or deleted → the dump's `commentCount` adjusted
*/
export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,

View File

@@ -1,3 +1,9 @@
/**
* A profile subpage's paginated list of dumps.
*
* @module
*/
import {
type RefCallback,
useCallback,
@@ -22,7 +28,8 @@ import { useFeedCache } from "../feed/useFeedCache.ts";
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
import { useScrollSave } from "../feed/useScrollSave.ts";
type State =
/** The feed's state: loading, failed, or loaded with a page of dumps. */
export type UserDumpFeedState =
| { status: "loading" }
| { status: "error"; error: string }
| {
@@ -34,26 +41,33 @@ type State =
loadingMore: boolean;
};
interface UseUserDumpFeedOptions {
/** How {@link useUserDumpFeed} reports. */
export interface UseUserDumpFeedOptions {
/** Called with newly appended items whenever a loadMore succeeds. */
onItemsAppended?: (items: Dump[]) => void;
}
interface UseUserDumpFeedResult {
state: State;
setState: React.Dispatch<React.SetStateAction<State>>;
/** What {@link useUserDumpFeed} returns. */
export interface UseUserDumpFeedResult {
/** The feed's state. */
state: UserDumpFeedState;
/** Replaces the state. */
setState: React.Dispatch<React.SetStateAction<UserDumpFeedState>>;
/** Updates the loaded dumps, for live sync. */
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/**
* The ref of the element that loads the next page when scrolled into view.
*/
sentinelRef: RefCallback<HTMLDivElement>;
}
/**
* Shared data-fetching, pagination, and scroll-save logic for profile subpages
* that display a paginated list of dumps (e.g. UserDumps, UserUpvoted).
*
* @param username The route param value (may be undefined during hydration)
* @param endpoint Relative path after /api/users/:username, e.g. "dumps" or "votes"
* @param cacheKey sessionStorage key for the feed cache
* @param options Optional callbacks
* Returns the dumps of `username` from `endpoint`, with pagination, and saves
* the feed and its scroll position under `cacheKey` so going back restores
* both. Shared by the profile subpages that display a paginated list of dumps
* (UserDumps, UserUpvoted). `username` is the route param (may be undefined
* during hydration); `endpoint` the path after /api/users/:username, e.g.
* "dumps" or "votes".
*/
export function useUserDumpFeed(
username: string | undefined,
@@ -64,7 +78,7 @@ export function useUserDumpFeed(
const { token } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
const [state, setState] = useState<State>({ status: "loading" });
const [state, setState] = useState<UserDumpFeedState>({ status: "loading" });
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);

View File

@@ -1,35 +1,76 @@
/**
* The editor's emoji picker state: opened by typing `:query` or from the
* button.
*
* @module
*/
import { useCallback, useRef, useState } from "react";
import { insideCode } from "../../utils/editor/text.ts";
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
// letter then at least one more word char / '+' / '-'. Two characters, not
// one, so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open
// the picker; Unicode letters so French names ("coeur", "cœur") search too.
/**
* Trigger: ':' not preceded by a letter, digit or another ':', followed by a
* letter then at least one more word char / '+' / '-'. Two characters, not one,
* so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open the
* picker; Unicode letters so French names ("coeur", "cœur") search too.
*/
const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u;
/**
* The picker's mode.
*
* `inline`: opened by typing `:query` — focus stays in the textarea, the
* picker's search follows what's typed, and a pick replaces the `:query`.
* `browse`: opened from the button — focus goes to the picker's own search,
* and a pick lands at the caret.
* `browse`: opened from the button — focus goes to the picker's own search, and
* a pick lands at the caret.
*/
export type EmojiMode = "closed" | "inline" | "browse";
/** The picker's state. */
interface EmojiState {
/** Its mode. */
mode: EmojiMode;
/** What it searches for. */
query: string;
/** The range of the textarea's value a pick replaces. */
/** The range of the textarea's value a pick replaces: its start… */
start: number;
/** …and its end. */
end: number;
}
/** The closed picker. */
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
/** What {@link useEmojiTrigger} returns. */
export interface UseEmojiTriggerResult {
/** The picker's mode. */
emojiMode: EmojiMode;
/** What the picker searches for. */
emojiQuery: string;
/**
* Call on every change (with `open`) and caret move (without): follows an
* inline `:query` as it's typed, and closes once the caret leaves it. Leaves
* a browse-mode picker alone.
*/
syncEmojiTrigger: (value: string, cursor: number, open: boolean) => void;
/** Opens the picker from the button, a pick replacing `[start, end)`. */
openEmojiBrowser: (start: number, end: number) => void;
/** Sets what the picker searches for. */
setEmojiQuery: (query: string) => void;
/** Inserts the emoji `native` and closes the picker. */
selectEmoji: (native: string) => void;
/** Closes the picker. */
closeEmoji: () => void;
}
/**
* Returns the emoji picker's state for a textarea; a pick goes through
* `replace`, which replaces `[start, end)` of the textarea's value, with a
* space after when `word` is set (see TextEditor).
*/
export function useEmojiTrigger(
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
): UseEmojiTriggerResult {
const [state, setStateRaw] = useState<EmojiState>(CLOSED);
// Mirrored synchronously, so a change and a caret move in the same event
// each see what the other just decided.
@@ -39,11 +80,6 @@ export function useEmojiTrigger(
setStateRaw(next);
}, []);
/**
* Call on every change (with `open`) and caret move (without): follows an
* inline `:query` as it's typed, and closes once the caret leaves it.
* Leaves a browse-mode picker alone.
*/
const syncEmojiTrigger = useCallback(
(value: string, cursor: number, open: boolean) => {
const s = stateRef.current;

View File

@@ -1,3 +1,10 @@
/**
* The editor's @mention autocomplete: the `@query` under the caret, the users
* it matches, and keyboard selection.
*
* @module
*/
import {
type KeyboardEvent,
useCallback,
@@ -7,24 +14,28 @@ import {
} from "react";
import { API_URL } from "../../config/api.ts";
import { insideCode } from "../../utils/editor/text.ts";
import type { UserSuggestion } from "../../../shared/user.ts";
export interface UserResult {
id: string;
username: string;
avatarMime: string | null;
}
/** The autocomplete's state. */
interface MentionState {
/** Whether the list is shown. */
open: boolean;
/** The name typed after the `@`. */
query: string;
/** Where the `@` sits, and the caret after the query: the range a pick
* replaces. */
/**
* Where the `@` sits, and the caret after the query: the range a pick
* replaces.
*/
start: number;
/** The caret after the query. */
end: number;
results: UserResult[];
/** The matching users. */
results: UserSuggestion[];
/** The highlighted match. */
selectedIndex: number;
}
/** The closed autocomplete. */
const CLOSED: MentionState = {
open: false,
query: "",
@@ -34,11 +45,17 @@ const CLOSED: MentionState = {
selectedIndex: 0,
};
// The server's mention rule (api/services/notification-service.ts): an `@` not
// preceded by `[`, `(` or a word character, so what's offered is exactly what
// would notify.
/**
* The server's mention rule (api/services/notification-service.ts): an `@` not
* preceded by `[`, `(` or a word character, so what's offered is exactly what
* would notify.
*/
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/;
/**
* Returns the `@query` that ends at `pos` in `value`, or null when there is
* none (or it is inside code).
*/
function getMentionQuery(
value: string,
pos: number,
@@ -62,11 +79,39 @@ export type MentionKey = Pick<
| "stopPropagation"
>;
/** What {@link useMentionAutocomplete} returns. */
export interface UseMentionAutocompleteResult {
/** Whether the list is shown. */
mentionOpen: boolean;
/** The matching users. */
mentionResults: UserSuggestion[];
/** The highlighted match. */
mentionSelectedIndex: number;
/**
* Call on every change (with `lookup`) and caret move (without): tracks the
* `@query` under the caret, searching for it after a short pause, and closes
* once the caret leaves it.
*/
syncMention: (value: string, pos: number, lookup: boolean) => void;
/** Closes the list. */
closeMention: () => void;
/**
* Handles arrows, Enter/Tab and Escape while the list is shown; returns
* whether it handled the key.
*/
handleMentionKeyDown: (e: MentionKey) => boolean;
/** Inserts `@username` and closes the list. */
handleMentionSelect: (username: string) => void;
}
/**
* Returns the @mention autocomplete for a textarea; a pick goes through
* `replace`, which replaces `[start, end)` of the textarea's value, with a
* space after when `word` is set (see TextEditor).
*/
export function useMentionAutocomplete(
/** Replaces `[start, end)` of the textarea's value, with a space after
* when `word` is set; see TextEditor. */
replace: (start: number, end: number, text: string, word: boolean) => void,
) {
): UseMentionAutocompleteResult {
const [state, setStateRaw] = useState<MentionState>(CLOSED);
// Mirrored synchronously so the key handler and a fetch landing late both
// read what the last keystroke decided, not the last render.
@@ -90,11 +135,6 @@ export function useMentionAutocomplete(
if (stateRef.current !== CLOSED) setState(CLOSED);
}, [cancelLookup, setState]);
/**
* Call on every change (with `lookup`) and caret move (without): tracks the
* `@query` under the caret, searching for it after a short pause, and closes
* once the caret leaves it.
*/
const syncMention = useCallback(
(value: string, pos: number, lookup: boolean) => {
const mention = getMentionQuery(value, pos);
@@ -161,7 +201,6 @@ export function useMentionAutocomplete(
[close, replace],
);
/** Returns whether it handled the key. */
const handleMentionKeyDown = useCallback(
(e: MentionKey): boolean => {
const s = stateRef.current;

View File

@@ -1,3 +1,9 @@
/**
* The feed tab the index opens on, as the user chose it.
*
* @module
*/
import { useCallback, useState } from "react";
import {
DEFAULT_FEED_TAB,
@@ -5,8 +11,13 @@ import {
type FeedTab,
} from "../../config/feedTabs.ts";
/** The localStorage key of the chosen tab. */
const STORAGE_KEY = "default-feed-tab";
/**
* Returns the stored tab, or {@link DEFAULT_FEED_TAB} when none valid is
* stored.
*/
function readStored(): FeedTab {
const stored = localStorage.getItem(STORAGE_KEY);
return stored && (FEED_TABS as readonly string[]).includes(stored)
@@ -15,10 +26,10 @@ function readStored(): FeedTab {
}
/**
* User preference for which feed tab the index opens on, persisted to
* localStorage (defaults to DEFAULT_FEED_TAB). Returns the stored tab and a setter that
* persists the choice. Consumers that only need to honour the preference (e.g.
* the index default) can ignore the setter.
* Returns the user's preference for which feed tab the index opens on,
* persisted to localStorage (defaults to {@link DEFAULT_FEED_TAB}), and a
* setter that persists the choice. Consumers that only need to honour the
* preference (e.g. the index default) can ignore the setter.
*/
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
const [tab, setTabState] = useState<FeedTab>(readStored);

View File

@@ -1,18 +1,35 @@
/**
* The dismissal animation of items leaving a list: a grace period, then a
* collapse.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Manages a 2-second cooldown + 350ms dismissal animation for a set of items.
*
* Call `startFading(id)` when an item should begin fading out.
* Call `cancelFading(id)` to abort the animation (e.g. the action was reversed).
* Use the returned `fading` record to apply CSS classes:
* - `"cooldown"` → item is in the 2-second grace period (darkened)
* - `"dismissing"` → item is collapsing (opacity 0 + grid-row collapse)
* Where an item is in its dismissal: `cooldown` is the 2-second grace period
* (darkened), `dismissing` the collapse (opacity 0 + grid-row collapse).
*/
export function useFading() {
const [fading, setFading] = useState<
Record<string, "cooldown" | "dismissing">
>({});
export type FadeState = "cooldown" | "dismissing";
/** What {@link useFading} returns. */
export interface UseFadingResult {
/** The items being dismissed, by id, and where each is; for CSS classes. */
fading: Record<string, FadeState>;
/** Starts dismissing item `id`. */
startFading: (id: string) => void;
/** Stops dismissing item `id` (e.g. the action was reversed). */
cancelFading: (id: string) => void;
/** Cancels all in-progress animations (e.g. on page navigation). */
cancelAll: () => void;
}
/**
* Returns a 2-second cooldown + 350ms dismissal animation for a set of items.
*/
export function useFading(): UseFadingResult {
const [fading, setFading] = useState<Record<string, FadeState>>({});
const cancels = useRef<Map<string, () => void>>(new Map());
useEffect(() => () => {
@@ -69,7 +86,6 @@ export function useFading() {
cancels.current.get(id)?.();
}, []);
/** Cancel all in-progress animations (e.g. on page navigation). */
const cancelAll = useCallback(() => {
cancels.current.forEach((c) => c());
}, []);

View File

@@ -1,18 +1,37 @@
/**
* A feed's pages and scroll position, kept in sessionStorage so going back to
* it restores them.
*
* @module
*/
import { useCallback, useState } from "react";
import { useNavigationType } from "react-router";
/** How long a saved feed stays good: 10 minutes. */
const TTL = 10 * 60 * 1000; // 10 minutes
interface FeedCacheEntry<T> {
/** A saved feed. */
export interface FeedCacheEntry<T> {
/** Its items, every page loaded so far. */
items: T[];
/** The last page loaded. */
page: number;
/** Whether another page follows. */
hasMore: boolean;
/** The scroll position. */
scrollY: number;
/** When it was saved, in epoch ms. */
savedAt: number;
}
export interface FeedCacheResult<T> {
/** What {@link useFeedCache} returns. */
export interface UseFeedCacheResult<T> {
/**
* The saved feed, when the page was reached by going back and one is fresh.
*/
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
/** Saves the feed. */
saveState: (
items: T[],
page: number,
@@ -21,10 +40,15 @@ export interface FeedCacheResult<T> {
) => void;
}
/**
* Returns the feed saved under `key`, read once on mount and only on a back or
* forward navigation, its items passed through `hydrateItem`; and a way to save
* it. A full or blocked storage is ignored.
*/
export function useFeedCache<T>(
key: string,
hydrateItem: (raw: T) => T,
): FeedCacheResult<T> {
): UseFeedCacheResult<T> {
const navType = useNavigationType();
// useState initializer runs exactly once on mount — correct for a cache read.

View File

@@ -1,25 +1,35 @@
/**
* Loading a feed's next page as its end scrolls into view.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react";
/** Longest wait between two loads while the sentinel never left the view. */
const MAX_RETRY_DELAY_MS = 30_000;
/**
* Observe a sentinel element and call `onLoadMore` when it scrolls into view.
* Returns the ref of a sentinel element, and calls `onLoadMore` when it scrolls
* into view while `enabled`.
*
* The sentinel is tracked via a state-backed callback ref rather than a plain
* `useRef` so the observer re-attaches whenever the underlying DOM node
* changes — e.g. when the index page swaps the active feed tab and a fresh
* sentinel `<div>` mounts in place of the old one. A static ref would keep
* observing the now-detached node and silently stop loading more.
* `useRef` so the observer re-attaches whenever the underlying DOM node changes
* — e.g. when the index page swaps the active feed tab and a fresh sentinel
* `<div>` mounts in place of the old one. A static ref would keep observing the
* now-detached node and silently stop loading more.
*
* Each change of `enabled` makes a new observer, which reports the sentinel
* at once if it's still in view. After a load that added items that's what
* fills a tall screen; after one that failed it would ask again straight
* away, and again, as fast as the request can fail. So a sentinel that stays
* put in view waits longer each time — 1s, 2s, 4s… up to 30s — and goes back
* to asking at once when it leaves the view or is pushed down by new items.
* Each change of `enabled` makes a new observer, which reports the sentinel at
* once if it's still in view. After a load that added items that's what fills a
* tall screen; after one that failed it would ask again straight away, and
* again, as fast as the request can fail. So a sentinel that stays put in view
* waits longer each time — 1s, 2s, 4s… up to 30s — and goes back to asking at
* once when it leaves the view or is pushed down by new items.
*/
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
export function useInfiniteScroll(
onLoadMore: () => void,
enabled: boolean,
): (node: HTMLDivElement | null) => void {
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
// Loads asked for since the sentinel last left the view or moved.
const attemptsRef = useRef(0);

View File

@@ -1,20 +1,25 @@
/**
* Putting items that left a list back where they were.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Keeps track of item positions as items leave a list and re-inserts them
* at their original positions when they become visible again.
* Keeps track of item positions as items leave a list and re-inserts them at
* their original positions when they become visible again.
*
* `items` is the current list (pass [] when state is not loaded), `setItems`
* its setter (the same one passed to the sync hook), and `lastEvent` the latest
* changed item. `isRemoving` returns true when the item is about to leave the
* list (e.g. it just became private), so its index is saved; `shouldReinsert`
* returns true when it should come back at its original position (e.g. it just
* became public again).
*
* MUST be called BEFORE the corresponding sync hook (useDumpListSync /
* usePlaylistListSync) so the save-position effect fires before the hook's
* removal effect in the same render cycle.
*
* @param items The current list (pass [] when state is not loaded).
* @param setItems The list setter (same one passed to the sync hook).
* @param lastEvent The latest changed item (Dump | Playlist | null).
* @param isRemoving Return true when the item is about to leave the list
* (e.g. it just became private). Used to save its index.
* @param shouldReinsert Return true when the item should come back at its
* original position (e.g. it just became public again).
*/
export function usePositionAwareSync<T extends { id: string }>(
items: T[],

View File

@@ -1,3 +1,9 @@
/**
* Saving a feed as the page is left.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
/**
@@ -11,7 +17,7 @@ import { useEffect, useLayoutEffect, useRef } from "react";
export function useScrollSave(
enabled: boolean,
onSave: (scrollY: number) => void,
) {
): void {
const onSaveRef = useRef(onSave);
useLayoutEffect(() => {
onSaveRef.current = onSave;

View File

@@ -1,3 +1,9 @@
/**
* react-hook-form, with the shared lifecycle of a form that posts to the API.
*
* @module
*/
import { useCallback } from "react";
import {
type FieldValues,
@@ -9,7 +15,8 @@ import {
import { friendlyFetchError } from "../../utils/shared/api.ts";
export interface UseApiFormReturn<T extends FieldValues>
/** What {@link useApiForm} returns: the form, and `submit`. */
export interface UseApiFormResult<T extends FieldValues>
extends UseFormReturn<T> {
/**
* Wraps an async submit handler with the shared API-form lifecycle: clears the
@@ -24,9 +31,13 @@ export interface UseApiFormReturn<T extends FieldValues>
) => (e?: React.BaseSyntheticEvent) => Promise<void>;
}
/**
* Returns a react-hook-form form made with `options`, plus `submit`, which
* turns a failed request into the form's root error.
*/
export function useApiForm<T extends FieldValues>(
options?: UseFormProps<T>,
): UseApiFormReturn<T> {
): UseApiFormResult<T> {
const form = useForm<T>(options);
const { handleSubmit, setError, clearErrors } = form;

View File

@@ -1,3 +1,9 @@
/**
* Playing a link's preview in the player.
*
* @module
*/
import { useCallback, useContext, useState } from "react";
import {
PlayerContext,
@@ -6,10 +12,15 @@ import {
import { BANDCAMP_PLAYER } from "../../config/playerMode.ts";
import { resolveBandcampQueue } from "../../utils/player/bandcamp.ts";
/** The part of a link's preview the player needs. */
export interface PlayableRichContent {
/** The provider, as in `RichContent.type`. */
type: string;
/** The link. */
url: string;
/** The title, for the player header. */
title?: string;
/** The provider's embed. */
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
@@ -17,20 +28,35 @@ export interface PlayableRichContent {
siteName?: string;
}
/** What {@link usePlayRichContent} returns. */
export interface UsePlayRichContentResult {
/**
* Starts playback of `rc`, played from the dump page `dumpHref`; resolves to
* whether playback actually started.
*/
playRichContent: (
rc: PlayableRichContent,
dumpHref?: string,
) => Promise<boolean>;
/** True while a Bandcamp page is being resolved. */
pending: boolean;
}
/**
* Start playback for a rich-content item.
* Returns a way to start playback for a rich-content item.
*
* Everything except Bandcamp goes straight to the iframe embed, exactly as
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
* mode the page is resolved to its mp3 streams and played as a queue, which is
* what makes it autoplay and lets albums play through. Any failure — offline,
* preorder-only release, Bandcamp changing its markup — falls back to the embed.
* preorder-only release, Bandcamp changing its markup — falls back to the
* embed.
*
* Returns whether playback actually started. A native-mode page with no stored
* embedUrl (an artist root, a /music index, a preorder) has nothing to fall
* back to, and the caller has to be told so the click isn't swallowed.
* Playback may not start: a native-mode page with no stored embedUrl (an artist
* root, a /music index, a preorder) has nothing to fall back to, and the caller
* has to be told so the click isn't swallowed.
*/
export function usePlayRichContent() {
export function usePlayRichContent(): UsePlayRichContentResult {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
@@ -81,9 +107,9 @@ export function usePlayRichContent() {
}
/**
* Whether a rich-content item can be played at all. In native mode a Bandcamp
* page is playable even without a stored embedUrl, since the streams are
* resolved from the page itself.
* Checks whether `rc` can be played at all. In native mode a Bandcamp page is
* playable even without a stored embedUrl, since the streams are resolved from
* the page itself.
*/
export function canPlayRichContent(rc: PlayableRichContent): boolean {
if (rc.embedUrl) return true;

View File

@@ -1,3 +1,19 @@
/**
* Tracklist and transport for a YouTube playlist embed.
*
* A Bandcamp album becomes a real queue of streams, but a YouTube playlist
* stays inside the iframe: YouTube holds the list and advances through it. So
* the two halves come from two places — the titles and lengths from the server
* (the embed exposes neither), the current position and the controls from the
* IFrame Player API attached to the iframe we already render.
*
* Both halves are optional. Without the server's titles there is no list; with
* a list but no API (script blocked, embedding restricted) the rows are shown
* but inert, since nothing can be told to play.
*
* @module
*/
import {
type RefObject,
useCallback,
@@ -12,20 +28,7 @@ import {
} from "../../utils/player/youtube.ts";
import type { YouTubePlaylistTrack } from "../../../shared/player.ts";
/**
* Tracklist and transport for a YouTube playlist embed.
*
* A Bandcamp album becomes a real queue of streams, but a YouTube playlist
* stays inside the iframe: YouTube holds the list and advances through it. So
* the two halves come from two places — the titles and lengths from the server
* (the embed exposes neither), the current position and the controls from the
* IFrame Player API attached to the iframe we already render.
*
* Both halves are optional. Without the server's titles there is no list; with
* a list but no API (script blocked, embedding restricted) the rows are shown
* but inert, since nothing can be told to play.
*/
/** The part of the IFrame API's player used here. */
interface YTPlayer {
playVideo(): void;
playVideoAt(index: number): void;
@@ -36,11 +39,13 @@ interface YTPlayer {
destroy(): void;
}
/** A player state change. */
interface YTStateEvent {
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
data: number;
}
/** The IFrame API's `YT` global. */
interface YTNamespace {
Player: new (
el: HTMLIFrameElement,
@@ -53,20 +58,23 @@ interface YTNamespace {
) => YTPlayer;
}
/** YT.PlayerState's paused. */
const PAUSED = 2;
/**
* An embed that has `enablejsapi` on pauses itself when the page goes to the
* background — something a plain embed does not do, and nothing on our side
* asked for: it breaks listening while working in another tab. A pause landing
* in the moment the tab is hidden is therefore undone. The window is narrow
* and the retries few on purpose, so a pause that comes later (a media key, a
* phone call, the user coming back and hitting space) stands.
* How soon after the tab is hidden a pause is undone. An embed that has
* `enablejsapi` on pauses itself when the page goes to the background —
* something a plain embed does not do, and nothing on our side asked for: it
* breaks listening while working in another tab. A pause landing in the moment
* the tab is hidden is therefore undone. The window is narrow and the retries
* few on purpose, so a pause that comes later (a media key, a phone call, the
* user coming back and hitting space) stands.
*/
const RESUME_WINDOW_MS = 4000;
/** How many pauses are undone per hiding of the tab. */
const MAX_RESUMES = 3;
// The globals YouTube's IFrame API script sets and calls.
/** The globals YouTube's IFrame API script sets and calls. */
declare global {
interface Window {
YT?: YTNamespace;
@@ -74,11 +82,13 @@ declare global {
}
}
/** The IFrame API script. */
const API_SRC = "https://www.youtube.com/iframe_api";
/** The IFrame API, once asked for. */
let apiPromise: Promise<YTNamespace> | null = null;
/** Load YouTube's IFrame API once per document, whoever asks first. */
/** Loads YouTube's IFrame API once per document, whoever asks first. */
function loadPlayerApi(): Promise<YTNamespace> {
if (apiPromise) return apiPromise;
apiPromise = new Promise((resolve, reject) => {
@@ -104,27 +114,40 @@ function loadPlayerApi(): Promise<YTNamespace> {
return apiPromise;
}
/** A row of the tracklist. */
export interface PlaylistRow {
/** The video's id. */
videoId: string;
/** Its title, when the server's list has it. */
title?: string;
/** Its length in seconds, when the server's list has it. */
duration?: number;
}
export interface YouTubePlaylistControls {
/** What {@link useYouTubePlaylist} returns. */
export interface UseYouTubePlaylistResult {
/** The tracklist. */
rows: PlaylistRow[];
/** Position in `rows`, or -1 while the player hasn't reported one. */
index: number;
/** Whether the embed can be driven — rows are clickable only then. */
ready: boolean;
/** Plays the row at `index`. */
playAt(index: number): void;
/** Plays the next video. */
next(): void;
/** Plays the previous video. */
previous(): void;
}
/**
* Returns the tracklist and transport of the playlist `embedUrl` plays in the
* iframe `iframeRef`, when it plays one.
*/
export function useYouTubePlaylist(
embedUrl: string | null,
iframeRef: RefObject<HTMLIFrameElement | null>,
): YouTubePlaylistControls {
): UseYouTubePlaylistResult {
const listId = embedUrl ? embedListId(embedUrl) : null;
const [tracks, setTracks] = useState<YouTubePlaylistTrack[]>([]);

View File

@@ -1,8 +1,15 @@
/**
* Keeping a list of playlists in step with the socket.
*
* @module
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts";
import { useWS } from "../ws/useWS.ts";
interface PlaylistListSyncOptions {
/** How {@link usePlaylistListSync} treats the list. */
export interface PlaylistListSyncOptions {
/** Keep private playlists visible (caller is the owner). */
isOwner?: boolean;
/**
@@ -24,11 +31,13 @@ interface PlaylistListSyncOptions {
}
/**
* Keeps a playlist list in sync with real-time WS events:
* - lastPlaylistEvent "created" → prepend (unless noNewEntries)
* - lastPlaylistEvent "updated" → update in-place, or remove if now private
* - lastPlaylistEvent "deleted" → remove
* - deletedPlaylistIds growing → filter
* Keeps the playlist list set through `setPlaylists` in sync with real-time WS
* events:
*
* - a playlist created → prepended (unless `noNewEntries`)
* - a playlist updated → updated in place, or removed if now private
* - a playlist deleted → removed
* - a playlist's dumps changed → its `dumpCount` updated
*/
export function usePlaylistListSync(
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,

View File

@@ -1,3 +1,9 @@
/**
* Placing a floating panel against its anchor, clear of clipping ancestors.
*
* @module
*/
import {
type CSSProperties,
type RefObject,
@@ -10,7 +16,8 @@ const GAP = 4;
/** Space kept clear of the viewport edges. */
const MARGIN = 8;
interface AnchoredFloatOptions {
/** How {@link useAnchoredFloat} sizes the panel. */
export interface AnchoredFloatOptions {
/**
* Preferred width in px, or "anchor" to match the anchor element's own width.
* Shrunk when the viewport is narrower either way.
@@ -18,21 +25,24 @@ interface AnchoredFloatOptions {
width: number | "anchor";
/** Height of the panel's non-scrolling chrome (search row, borders…). */
chromeHeight: number;
/** The least height of the panel's scrolling part. */
minContentHeight: number;
/** The most height of the panel's scrolling part. */
maxContentHeight: number;
}
/**
* Positions a floating panel against an anchor element with `position: fixed`,
* so no ancestor's `overflow: hidden` / `overflow: auto` can clip it — cards,
* modal bodies and scrollers all cut off an absolutely positioned popup.
* Returns the style that positions a floating panel against `anchorRef` with
* `position: fixed`, so no ancestor's `overflow: hidden` / `overflow: auto` can
* clip it — cards, modal bodies and scrollers all cut off an absolutely
* positioned popup.
*
* Flips to whichever side of the anchor has the most room, clamps horizontally
* to the viewport, and publishes the room left for the scrolling part as the
* `--float-max-h` custom property.
*
* Returns `null` while closed; the first value lands in a layout effect, so the
* panel is never painted at the wrong spot.
* Returns `null` while not `open`; the first value lands in a layout effect, so
* the panel is never painted at the wrong spot.
*/
export function useAnchoredFloat(
anchorRef: RefObject<HTMLElement | null>,

View File

@@ -1,8 +1,17 @@
/**
* The tab's title: the page's, the site's name, and an unread badge.
*
* @module
*/
import { useEffect } from "react";
// The server injects the configured site name into both <title> and this meta
// tag (see api/lib/static.ts / api/middleware/og.ts). Read it once at startup.
// In the Vite dev server the placeholder isn't substituted, so fall back.
/**
* Returns the site's name. The server injects the configured site name into
* both <title> and a `site-name` meta tag (see api/lib/static.ts /
* api/middleware/og.ts). In the Vite dev server the placeholder isn't
* substituted, so it falls back to "gerbeur".
*/
function readSiteName(): string {
const meta = document.querySelector('meta[name="site-name"]')
?.getAttribute("content")?.trim();
@@ -10,10 +19,13 @@ function readSiteName(): string {
return "gerbeur";
}
export const SITE_NAME = readSiteName();
/** The site's name, read once at startup. */
export const SITE_NAME: string = readSiteName();
// The server injects the configured emoji into a <meta name="site-emoji"> tag.
// In the Vite dev server it's substituted too; fall back to the default truck.
/**
* Returns the site's emoji. The server injects the configured emoji into a
* `site-emoji` meta tag; it falls back to the default truck.
*/
function readSiteEmoji(): string {
const meta = document.querySelector('meta[name="site-emoji"]')
?.getAttribute("content")?.trim();
@@ -21,14 +33,20 @@ function readSiteEmoji(): string {
return "🚚";
}
export const SITE_EMOJI = readSiteEmoji();
/** The site's emoji, read once at startup. */
export const SITE_EMOJI: string = readSiteEmoji();
// The page title and the unread badge are set from different places (each
// page, and the live-updates provider), so both are kept here and composed.
// `null` base means no page has set one yet: keep the server-rendered title.
/**
* The page's title and the site's name. The page title and the unread badge are
* set from different places (each page, and the live-updates provider), so both
* are kept here and composed. `null` means no page has set one yet: keep the
* server-rendered title.
*/
let baseTitle: string | null = null;
/** The unread count. */
let badge = 0;
/** Sets the tab's title from {@link baseTitle} and {@link badge}. */
function applyTitle() {
const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, "");
document.title = badge > 0 ? `(${badge > 99 ? "99+" : badge}) ${base}` : base;
@@ -39,11 +57,11 @@ function applyTitle() {
* navigation. Without this the tab title only ever reflects the server-rendered
* <title> from the initial page load and goes stale on every SPA navigation.
*
* - Pass a non-empty string to show `<title> · <site name>`.
* - Pass a non-empty `title` to show `<title> · <site name>`.
* - Pass `null` to show just the site name (home / generic pages).
* - Pass `undefined` while the page-specific title is still loading: the title
* is left untouched, so a server-rendered OG title survives until data
* arrives (avoids a flash to the bare site name on direct loads).
* is left untouched, so a server-rendered OG title survives until data
* arrives (avoids a flash to the bare site name on direct loads).
*/
export function useDocumentTitle(title: string | null | undefined): void {
useEffect(() => {
@@ -53,7 +71,9 @@ export function useDocumentTitle(title: string | null | undefined): void {
}, [title]);
}
/** Prefixes the tab title with an unread count, e.g. "(3) Chat · gerbeur". */
/**
* Prefixes the tab title with the unread `count`, e.g. "(3) Chat · gerbeur".
*/
export function setTitleBadge(count: number): void {
if (count === badge) return;
badge = count;

View File

@@ -1,5 +1,12 @@
/**
* Easing an element's height when its content swaps.
*
* @module
*/
import { useCallback, useLayoutEffect, useRef } from "react";
/** The duration when `--dur-reveal` isn't set, in ms. */
const FALLBACK_MS = 250;
/**
@@ -7,11 +14,13 @@ const FALLBACK_MS = 250;
* swaps part of its content (a view for an edit form, a reply box opening…)
* moves the surrounding layout smoothly instead of jumping.
*
* Returns a callback ref for the element whose height should transition.
* Only the box is animated; give the content that appears the `animate-in`
* class so it fades in as the box grows. Duration follows `--dur-reveal`.
* Returns a callback ref for the element whose height should transition. Only
* the box is animated; give the content that appears the `animate-in` class so
* it fades in as the box grows. Duration follows `--dur-reveal`.
*/
export function useHeightTransition<T extends HTMLElement>(watch: unknown) {
export function useHeightTransition<T extends HTMLElement>(
watch: unknown,
): (el: T | null) => (() => void) | undefined {
const elRef = useRef<T | null>(null);
// Last rendered height, kept current through a ResizeObserver so that by
// the time the swap commits we still know where the box *was* — including

View File

@@ -1,21 +1,35 @@
/**
* Measuring the width an element has beside another in a shared row.
*
* @module
*/
import { useLayoutEffect, useState } from "react";
/** Where {@link useRoomBeside} measures. */
export interface RoomBesideOptions {
/** An ancestor selector: what bounds the row. */
within?: string;
/** A selector inside it: what comes first. */
beside?: string;
}
/**
* How much width an element has to itself in a row it shares with something
* Returns how much width `el` has to itself in a row it shares with something
* that takes precedence — in the feed's header, how much the tabs leave the
* presence avatars. `within` (an ancestor selector) is what bounds the row,
* `beside` (found inside it) is what comes first, measured at its content's
* natural width because how wide the tabs run depends on the theme's type and
* on the locale. The row's gap between the two is taken off too.
* presence avatars. `within` is what bounds the row, `beside` what comes first,
* measured at its content's natural width because how wide the tabs run depends
* on the theme's type and on the locale. The row's gap between the two is taken
* off too.
*
* Takes the element itself (from a callback ref) rather than a ref object,
* so the measuring starts whenever it mounts. Its own width doesn't enter
* into it, so it can size itself to the answer without feeding back into the
* next one. Null until measured.
* Takes the element itself (from a callback ref) rather than a ref object, so
* the measuring starts whenever it mounts. Its own width doesn't enter into it,
* so it can size itself to the answer without feeding back into the next one.
* Null until measured.
*/
export function useRoomBeside(
el: HTMLElement | null,
{ within, beside }: { within?: string; beside?: string },
{ within, beside }: RoomBesideOptions,
): number | null {
const [room, setRoom] = useState<number | null>(null);
@@ -59,10 +73,12 @@ export function useRoomBeside(
return room;
}
/** How wide an element's content runs, from its first child to its last plus
* its padding — neither its box, which a flex row may squeeze (the tabs then
* scroll) or stretch (filling the row under the header), nor its
* scrollWidth, which is at least the box. */
/**
* Returns how wide the content of `el` runs, from its first child to its last
* plus its padding — neither its box, which a flex row may squeeze (the tabs
* then scroll) or stretch (filling the row under the header), nor its
* scrollWidth, which is at least the box.
*/
function contentWidth(el: HTMLElement): number {
const first = el.firstElementChild, last = el.lastElementChild;
if (!first || !last) return 0;

View File

@@ -1,5 +1,12 @@
/**
* Freezing the page's scroll under a modal.
*
* @module
*/
import { useEffect } from "react";
/** How many components hold the lock. */
let locks = 0;
/**
@@ -8,11 +15,11 @@ let locks = 0;
*
* Hiding the viewport's overflow drops its scrollbar, which would widen the
* page and shift everything behind the backdrop. If a bar is showing, its
* gutter is held for the duration of the lock instead, so the layout stays
* put; no gutter is reserved the rest of the time. Locks are counted so a
* confirm stacked on a modal doesn't release the page when it closes first.
* gutter is held for the duration of the lock instead, so the layout stays put;
* no gutter is reserved the rest of the time. Locks are counted so a confirm
* stacked on a modal doesn't release the page when it closes first.
*/
export function useScrollLock() {
export function useScrollLock(): void {
useEffect(() => {
const root = document.documentElement;
if (locks++ === 0) {

View File

@@ -1,3 +1,9 @@
/**
* The sounds setting.
*
* @module
*/
import { useCallback, useState } from "react";
import {
readSoundsEnabled,
@@ -5,9 +11,9 @@ import {
} from "../../utils/shared/sound.ts";
/**
* User preference for notification and chat sounds, persisted to localStorage
* (on by default). The chime itself reads the stored value at play time, so
* this hook only drives the settings toggle.
* Returns the user's preference for notification and chat sounds, persisted to
* localStorage (on by default), and its setter. The chime itself reads the
* stored value at play time, so this hook only drives the settings toggle.
*/
export function useSoundsEnabled(): [boolean, (enabled: boolean) => void] {
const [enabled, setEnabledState] = useState(readSoundsEnabled);

View File

@@ -1,19 +1,26 @@
/**
* The active tab, kept in the URL.
*
* @module
*/
import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router";
/** What separates the base path from the tab. */
const SEP = "/~/";
/**
* Reflects the active tab in the URL path using a `/~/` delimiter, e.g.
* Returns the active tab, one of `validTabs` (falling back to `defaultTab`),
* and a setter that navigates to the matching path (replacing history).
*
* The tab is reflected in the URL path using a `/~/` delimiter, e.g.
* `/users/vincent/~/playlists` or `/~/new`. The segment before `/~/` is the
* base path and the segment after is the tab. When the base is non-empty the
* default tab collapses to the clean base path (`/users/vincent`); at the root
* the base is empty, so the default stays explicit (`/~/hot`). Generic and
* path-agnostic: the base is derived from the current location, so the same
* hook works for any route.
*
* Returns the validated active tab (falling back to `defaultTab`) and a setter
* that navigates to the matching path (replacing history).
*/
export function useTabParam<T extends string>(
validTabs: Iterable<T>,

View File

@@ -1,4 +1,16 @@
import { useContext } from "react";
import { FollowContext } from "../../contexts/social/FollowContext.ts";
/**
* The signed-in user's follows, from their context.
*
* @module
*/
export const useFollows = () => useContext(FollowContext);
import { useContext } from "react";
import {
FollowContext,
type FollowContextValue,
} from "../../contexts/social/FollowContext.ts";
/** Returns whom and what the signed-in user follows, and a way to change it. */
export function useFollows(): FollowContextValue {
return useContext(FollowContext);
}

View File

@@ -1,6 +1,16 @@
import { useContext } from "react";
import { ThemeContext } from "../../contexts/theme/ThemeContext.ts";
/**
* The theme, from its context.
*
* @module
*/
export function useTheme() {
import { useContext } from "react";
import {
ThemeContext,
type ThemeContextValue,
} from "../../contexts/theme/ThemeContext.ts";
/** Returns the style and color scheme, and a way to change them. */
export function useTheme(): ThemeContextValue {
return useContext(ThemeContext);
}

View File

@@ -1,21 +1,32 @@
/**
* The socket's live state, from its three contexts.
*
* @module
*/
import { useContext } from "react";
import {
WSChatContext,
type WSChatContextValue,
WSContext,
type WSContextValue,
WSPresenceContext,
type WSPresenceContextValue,
} from "../../contexts/ws/WSContext.ts";
/** Votes, likes, live dumps, playlists, comments and notifications. */
export function useWS() {
/**
* Returns the votes, likes, live dumps, playlists, comments and notifications.
*/
export function useWS(): WSContextValue {
return useContext(WSContext);
}
/** The chat room. Re-renders with every message and typing change. */
export function useWSChat() {
/** Returns the chat room. Re-renders with every message and typing change. */
export function useWSChat(): WSChatContextValue {
return useContext(WSChatContext);
}
/** Who's online. */
export function useWSPresence() {
/** Returns who's online. */
export function useWSPresence(): WSPresenceContextValue {
return useContext(WSPresenceContext);
}