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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}, []);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user