v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The session, and requests made with it.
|
||||
* Gives components the session, and the functions that sign in, sign out and
|
||||
* make requests with its token.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,16 +17,17 @@ import {
|
||||
storeSession,
|
||||
} from "../../utils/auth/storage.ts";
|
||||
|
||||
/** What {@link useAuth} returns. */
|
||||
/**
|
||||
* Returned by {@link useAuth} to any component that needs the session. Its
|
||||
* functions are stable until the session's token changes.
|
||||
*/
|
||||
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.
|
||||
* The token for file and thumbnail URLs, where it shows in the query string.
|
||||
* A session from before media tokens falls 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. */
|
||||
@@ -36,25 +38,25 @@ export interface UseAuthResult {
|
||||
* must not sign those out.
|
||||
*/
|
||||
logout: () => void;
|
||||
/** Merges `patch` into the current session, in memory and in storage. */
|
||||
/**
|
||||
* Merges `patch` into the session, in memory and in storage. The profile page
|
||||
* uses it for a new avatar or email, the password dialog for fresh tokens.
|
||||
*/
|
||||
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.
|
||||
* Calls `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. An expired
|
||||
* token answers a 401 without calling the server.
|
||||
*/
|
||||
authFetch: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
/**
|
||||
* `authFetch` for multipart uploads that need a progress bar.
|
||||
* Works like `authFetch` for a multipart upload that shows progress, as the
|
||||
* dump creation dialog's file upload does. It goes through XHR, since `fetch`
|
||||
* reports nothing until the whole body is sent. It still answers a
|
||||
* `Response`, so callers use `expectOk` as usual.
|
||||
*
|
||||
* `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).
|
||||
* `onProgress` receives a fraction from 0 to 1, or `null` once the body is
|
||||
* sent and the server is working, or when the length is unknown.
|
||||
*/
|
||||
authUpload: (
|
||||
url: string,
|
||||
@@ -64,7 +66,10 @@ export interface UseAuthResult {
|
||||
) => Promise<Response>;
|
||||
}
|
||||
|
||||
/** Returns the session and what acts on it. */
|
||||
/**
|
||||
* Returns the session and the functions that act on it. It must be called
|
||||
* under `AuthProvider`.
|
||||
*/
|
||||
export function useAuth(): UseAuthResult {
|
||||
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
||||
|
||||
@@ -73,9 +78,6 @@ export function useAuth(): UseAuthResult {
|
||||
storeSession(response);
|
||||
}, [setAuthResponse]);
|
||||
|
||||
// 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.
|
||||
const sessionToken = authResponse?.token;
|
||||
const logout = useCallback(() => {
|
||||
setAuthResponse((prev) => prev?.token === sessionToken ? null : prev);
|
||||
@@ -99,7 +101,7 @@ export function useAuth(): UseAuthResult {
|
||||
|
||||
if (token && isTokenExpired(token)) {
|
||||
logout();
|
||||
// Return a synthetic 401 so callers handle it consistently
|
||||
// Callers already handle a 401 from the server; this looks the same.
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -109,10 +111,8 @@ export function useAuth(): UseAuthResult {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
// Only attach a token when we actually have one — avoids sending a
|
||||
// `Bearer undefined` header that the server would reject as invalid.
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
// Let the browser set Content-Type for FormData (it includes the boundary)
|
||||
// The browser sets FormData's Content-Type, with its boundary.
|
||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
});
|
||||
@@ -141,7 +141,7 @@ export function useAuth(): UseAuthResult {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
// Content-Type is left to the browser so the multipart boundary is set.
|
||||
// The browser sets the Content-Type, with the multipart boundary.
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
onProgress(e.lengthComputable ? e.loaded / e.total : null);
|
||||
@@ -157,8 +157,8 @@ export function useAuth(): UseAuthResult {
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Mirrors what `fetch` throws on a network failure, so the shared
|
||||
// `friendlyFetchError` path renders it the same way.
|
||||
// The error `fetch` throws on a network failure, so that
|
||||
// `friendlyFetchError` reports it the same way.
|
||||
xhr.addEventListener(
|
||||
"error",
|
||||
() => reject(new TypeError("Failed to fetch")),
|
||||
@@ -184,16 +184,19 @@ export function useAuth(): UseAuthResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** What {@link useRequiredAuth} returns: {@link UseAuthResult} with a user. */
|
||||
/**
|
||||
* Returned by {@link useRequiredAuth}. It is {@link UseAuthResult} with a user
|
||||
* that can't be null.
|
||||
*/
|
||||
export interface UseRequiredAuthResult extends UseAuthResult {
|
||||
/** The signed-in user. */
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session, for a page behind a sign-in.
|
||||
* Returns the session, for a page that `RestrictedLoggedIn` guards, such as
|
||||
* `DumpEdit`, so it needn't check for a user.
|
||||
*
|
||||
* @throws {Error} When no one is signed in: the route should have guarded it.
|
||||
* @throws {Error} When no one is signed in, which means the route has no guard.
|
||||
*/
|
||||
export function useRequiredAuth(): UseRequiredAuthResult {
|
||||
const {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The categories, from their context.
|
||||
* Gives components the categories that `CategoriesProvider` loads once for the
|
||||
* whole app.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +11,10 @@ import {
|
||||
type CategoriesContextValue,
|
||||
} from "../../contexts/category/CategoriesContext.ts";
|
||||
|
||||
/** Returns every category, and a way to refresh them. */
|
||||
/**
|
||||
* Returns every category and a function that refetches them. Under no
|
||||
* `CategoriesProvider`, the list is empty.
|
||||
*/
|
||||
export function useCategories(): CategoriesContextValue {
|
||||
return useContext(CategoriesContext);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The chatbox's open state, from its context.
|
||||
* Reads `ChatContext`, for the buttons that open the chat.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type ChatContextValue,
|
||||
} from "../../contexts/chat/ChatContext.ts";
|
||||
|
||||
/** Returns whether the chatbox is open, and a way to open and close it. */
|
||||
/**
|
||||
* Returns the chat modal's open state, and the functions that open and close
|
||||
* it. Needs `ChatProvider` above, which `App` mounts around every page.
|
||||
*/
|
||||
export function useChat(): ChatContextValue {
|
||||
return useContext(ChatContext);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Link behaviour for a clickable feed card.
|
||||
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
|
||||
* behaviour: a middle or modified click opens the dump in a new tab.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,46 +11,40 @@ 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. */
|
||||
/** Checks whether `e` happened over an anchor, which keeps its own target. */
|
||||
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. */
|
||||
/**
|
||||
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
|
||||
* way it opens the dump marks it visited, which clears its unread dot.
|
||||
*/
|
||||
export interface UseDumpCardLinkResult {
|
||||
/** Navigates in place. */
|
||||
/** Opens the dump in this tab, for a handler that would rather not play. */
|
||||
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.
|
||||
* Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or
|
||||
* shift click away from any anchor. A click handler that plays media calls
|
||||
* it first, so that such a click opens the dump instead.
|
||||
*/
|
||||
handledAsNewTab: (e: MouseEvent) => boolean;
|
||||
/** The handlers to spread onto the card. */
|
||||
/** Spread onto the card element. */
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the handlers that make the card of `dump` open it: in this tab on a
|
||||
* click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors
|
||||
* inside the card, such as the title or a category, keep their own target.
|
||||
*/
|
||||
export function useDumpCardLink(
|
||||
dump: Pick<Dump, "id" | "slug">,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Keeping a list of dumps in step with the socket.
|
||||
* Keeps a page's list of dumps in step with the dump and comment events that
|
||||
* `WSContext` receives.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,30 +9,30 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
|
||||
/** How {@link useDumpListSync} treats the list. */
|
||||
/**
|
||||
* Passed to {@link useDumpListSync} by a profile's list of dumps. The feeds
|
||||
* pass none.
|
||||
*/
|
||||
export interface DumpListSyncOptions {
|
||||
/** Keep private dumps visible (caller is the owner). */
|
||||
/** Keeps an edited dump that is now private, for its owner's own list. */
|
||||
isOwner?: boolean;
|
||||
/**
|
||||
* Only re-insert dumps created by this user when they become visible again.
|
||||
* Leave undefined to never re-insert (caller handles it, or no re-insertion needed).
|
||||
* Prepends an edited dump missing from the list when `ownerId` posted it.
|
||||
* Without it, a missing dump is never added.
|
||||
*/
|
||||
ownerId?: string;
|
||||
/**
|
||||
* When true, don't re-add a dump that isn't in the list (idx === -1).
|
||||
* Use this when the caller handles re-insertion itself (e.g. with a position map).
|
||||
* Never adds a missing dump, whatever `ownerId`. A list that restores
|
||||
* dumps at their old position, with `usePositionAwareSync`, sets it.
|
||||
*/
|
||||
skipReinsert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Applies the socket's dump and comment events to the list that `setDumps`
|
||||
* updates. A deleted dump is removed, an edited one is replaced in place, and
|
||||
* a new or deleted comment moves its dump's `commentCount`. It doesn't add new
|
||||
* dumps: the feeds merge `WSContext`'s `recentDumps` themselves.
|
||||
*/
|
||||
export function useDumpListSync(
|
||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||
@@ -39,7 +40,7 @@ export function useDumpListSync(
|
||||
): void {
|
||||
const { deletedDumpIds, lastDumpEvent, lastCommentEvent } = useWS();
|
||||
|
||||
// Keep refs up-to-date so closures in effects are never stale.
|
||||
// Refs, so that the effects run on new events only, not on new callbacks.
|
||||
const setDumpsRef = useRef(setDumps);
|
||||
const optionsRef = useRef(options);
|
||||
useLayoutEffect(() => {
|
||||
@@ -63,7 +64,6 @@ export function useDumpListSync(
|
||||
const idx = prev.findIndex((d) => d.id === dump.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
// Remove if it became private and the viewer can't see private dumps.
|
||||
if (dump.isPrivate && !isOwner) {
|
||||
return prev.filter((d) => d.id !== dump.id);
|
||||
}
|
||||
@@ -72,7 +72,6 @@ export function useDumpListSync(
|
||||
return next;
|
||||
}
|
||||
|
||||
// Dump not in list: only re-insert when ownerId is set and matches.
|
||||
if (skipReinsert || !ownerId) return prev;
|
||||
if (dump.userId !== ownerId) return prev;
|
||||
if (dump.isPrivate && !isOwner) return prev;
|
||||
@@ -80,8 +79,8 @@ export function useDumpListSync(
|
||||
});
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
// commentCount counts non-deleted comments, so created → +1, deleted → −1.
|
||||
// Edits don't change the count. Events only fire for public dumps.
|
||||
// The server sends comment events for public dumps only, so a private
|
||||
// dump's count stays as fetched.
|
||||
useEffect(() => {
|
||||
if (!lastCommentEvent) return;
|
||||
const { type, dumpId } = lastCommentEvent;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A profile subpage's paginated list of dumps.
|
||||
* Loads a user's dumps or upvoted dumps, a page at a time, for the profile
|
||||
* subpages that list them in full.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -28,7 +29,11 @@ import { useFeedCache } from "../feed/useFeedCache.ts";
|
||||
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
|
||||
import { useScrollSave } from "../feed/useScrollSave.ts";
|
||||
|
||||
/** The feed's state: loading, failed, or loaded with a page of dumps. */
|
||||
/**
|
||||
* Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails
|
||||
* when the user doesn't exist, and holds an empty list when only the dumps
|
||||
* failed to load.
|
||||
*/
|
||||
export type UserDumpFeedState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -41,33 +46,30 @@ export type UserDumpFeedState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** How {@link useUserDumpFeed} reports. */
|
||||
/** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */
|
||||
export interface UseUserDumpFeedOptions {
|
||||
/** Called with newly appended items whenever a loadMore succeeds. */
|
||||
/** Called with the dumps of each page after the first, once appended. */
|
||||
onItemsAppended?: (items: Dump[]) => void;
|
||||
}
|
||||
|
||||
/** What {@link useUserDumpFeed} returns. */
|
||||
/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */
|
||||
export interface UseUserDumpFeedResult {
|
||||
/** The feed's state. */
|
||||
state: UserDumpFeedState;
|
||||
/** Replaces the state. */
|
||||
/** Lets `UserUpvoted` prepend a dump the user just upvoted. */
|
||||
setState: React.Dispatch<React.SetStateAction<UserDumpFeedState>>;
|
||||
/** Updates the loaded dumps, for live sync. */
|
||||
/** Does nothing until loaded. Passed to `useDumpListSync`. */
|
||||
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
||||
/**
|
||||
* The ref of the element that loads the next page when scrolled into view.
|
||||
*/
|
||||
/** From `useInfiniteScroll`, for an element after the last card. */
|
||||
sentinelRef: RefCallback<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* Returns the profile of `username`, from `GET /api/users/:username`, with its
|
||||
* dumps from `GET /api/users/:username/<endpoint>`, `endpoint` being "dumps"
|
||||
* or "votes". The dumps are fetched signed in, so the user sees their own
|
||||
* private ones. The next page loads as the sentinel scrolls into view. The
|
||||
* list and its scroll position are saved under `cacheKey` as the page is
|
||||
* left, and going back restores them, refetching only the profile.
|
||||
*/
|
||||
export function useUserDumpFeed(
|
||||
username: string | undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The editor's emoji picker state: opened by typing `:query` or from the
|
||||
* button.
|
||||
* Keeps the state of `TextEditor`'s emoji picker, which opens on a typed
|
||||
* `:query` or from the editor's button. `TextEditor` renders the picker and
|
||||
* handles its keys.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,71 +10,64 @@ 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.
|
||||
* Matches a `:query` ending at the caret. The `:` must not follow a letter, a
|
||||
* digit or another `:`, and the query takes at least two characters, so that
|
||||
* ":D", "note:" and "12:30" don't open the picker. Letters are Unicode, so a
|
||||
* French name ("cœur") searches 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.
|
||||
* Returned by {@link useEmojiTrigger} to `TextEditor`, which keys its focus and
|
||||
* key handling on it. An `inline` picker was opened by a typed `:query`: focus
|
||||
* stays in the textarea and a pick replaces the query. A `browse` picker was
|
||||
* opened from the button: focus goes to its 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: its start… */
|
||||
/** Starts the range of the textarea's value that a pick replaces. */
|
||||
start: number;
|
||||
/** …and its end. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** The closed picker. */
|
||||
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
|
||||
|
||||
/** What {@link useEmojiTrigger} returns. */
|
||||
/** Returned by {@link useEmojiTrigger} to `TextEditor`. */
|
||||
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.
|
||||
* Follows an inline `:query` and closes the picker once the caret leaves it.
|
||||
* The caller calls it on every change with `open` set, and on every caret
|
||||
* move without, since only typing may open the picker. It leaves a `browse`
|
||||
* picker alone.
|
||||
*/
|
||||
syncEmojiTrigger: (value: string, cursor: number, open: boolean) => void;
|
||||
/** Opens the picker from the button, a pick replacing `[start, end)`. */
|
||||
/** Opens a `browse` picker, whose pick will replace `[start, end)`. */
|
||||
openEmojiBrowser: (start: number, end: number) => void;
|
||||
/** Sets what the picker searches for. */
|
||||
/** Sets the query of the picker's search, in either mode. */
|
||||
setEmojiQuery: (query: string) => void;
|
||||
/** Inserts the emoji `native` and closes the picker. */
|
||||
/** Inserts the emoji `native` through `replace` 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).
|
||||
* Returns the picker's state and the functions that drive it. A pick goes
|
||||
* through `replace`, `TextEditor`'s function that puts the text over
|
||||
* `[start, end)` of the textarea's value, followed by a space when `word` is
|
||||
* set.
|
||||
*/
|
||||
export function useEmojiTrigger(
|
||||
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.
|
||||
// Mirrored synchronously, so that a change and a caret move in the same
|
||||
// event each see what the other just decided.
|
||||
const stateRef = useRef(state);
|
||||
const setState = useCallback((next: EmojiState) => {
|
||||
stateRef.current = next;
|
||||
@@ -114,8 +108,8 @@ export function useEmojiTrigger(
|
||||
const { mode, start, end } = stateRef.current;
|
||||
if (mode === "closed") return;
|
||||
setState(CLOSED);
|
||||
// An inline pick finishes a word, so it takes a space after it the way a
|
||||
// mention does; a browse pick goes in exactly where the caret was.
|
||||
// An inline pick finishes a word, so it takes a space after it as a
|
||||
// mention does. A browse pick goes in exactly where the caret was.
|
||||
replace(start, end, native, mode === "inline");
|
||||
}, [replace, setState]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The editor's @mention autocomplete: the `@query` under the caret, the users
|
||||
* it matches, and keyboard selection.
|
||||
* Keeps the state of `TextEditor`'s `@mention` suggestions, which it fetches
|
||||
* from `GET /api/users/search`. `TextEditor` renders them in a
|
||||
* `MentionDropdown`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,26 +17,16 @@ import { API_URL } from "../../config/api.ts";
|
||||
import { insideCode } from "../../utils/editor/text.ts";
|
||||
import type { UserSuggestion } from "../../../shared/user.ts";
|
||||
|
||||
/** 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. A pick replaces `[start, end)`. */
|
||||
start: number;
|
||||
/** The caret after the query. */
|
||||
end: number;
|
||||
/** The matching users. */
|
||||
results: UserSuggestion[];
|
||||
/** The highlighted match. */
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
/** The closed autocomplete. */
|
||||
const CLOSED: MentionState = {
|
||||
open: false,
|
||||
query: "",
|
||||
@@ -46,15 +37,14 @@ const CLOSED: MentionState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Matches the `@query` ending at the caret by the rule of shared/mention.ts,
|
||||
* which the server notifies by, so that what is offered 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).
|
||||
* none or it is inside code.
|
||||
*/
|
||||
function getMentionQuery(
|
||||
value: string,
|
||||
@@ -67,7 +57,10 @@ function getMentionQuery(
|
||||
return { query: match[1], start: match.index };
|
||||
}
|
||||
|
||||
/** The parts of a keydown the mention list reads. */
|
||||
/**
|
||||
* Passed to {@link UseMentionAutocompleteResult.handleMentionKeyDown} by
|
||||
* `TextEditor`. It is the part of a React keydown event that the list reads.
|
||||
*/
|
||||
export type MentionKey = Pick<
|
||||
KeyboardEvent,
|
||||
| "key"
|
||||
@@ -79,41 +72,40 @@ export type MentionKey = Pick<
|
||||
| "stopPropagation"
|
||||
>;
|
||||
|
||||
/** What {@link useMentionAutocomplete} returns. */
|
||||
/** Returned by {@link useMentionAutocomplete} to `TextEditor`. */
|
||||
export interface UseMentionAutocompleteResult {
|
||||
/** Whether the list is shown. */
|
||||
/** Set only once a search has found someone. */
|
||||
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.
|
||||
* Follows the `@query` under the caret, searches for it 150 ms after the
|
||||
* last keystroke, and closes the list once the caret leaves it. The caller
|
||||
* calls it on every change with `lookup` set, and on every caret move
|
||||
* without, since only typing may search.
|
||||
*/
|
||||
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.
|
||||
* Handles the arrows, Enter, Tab and Escape while the list is shown, and
|
||||
* returns whether it took the key. The caller must then leave the key alone.
|
||||
*/
|
||||
handleMentionKeyDown: (e: MentionKey) => boolean;
|
||||
/** Inserts `@username` and closes the list. */
|
||||
/** Inserts `@username` through `replace` 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).
|
||||
* Returns the suggestions' state and the functions that drive them. A pick
|
||||
* goes through `replace`, `TextEditor`'s function that puts the text over
|
||||
* `[start, end)` of the textarea's value, followed by a space when `word` is
|
||||
* set.
|
||||
*/
|
||||
export function useMentionAutocomplete(
|
||||
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
|
||||
// Mirrored synchronously, so that the key handler and a late response both
|
||||
// read what the last keystroke decided, not the last render.
|
||||
const stateRef = useRef(state);
|
||||
const setState = useCallback((next: MentionState) => {
|
||||
@@ -144,8 +136,8 @@ export function useMentionAutocomplete(
|
||||
return;
|
||||
}
|
||||
if (!lookup) {
|
||||
// A caret move within the same @query keeps the list; one that lands
|
||||
// on some other @word doesn't open anything.
|
||||
// A caret move within the same @query keeps the list, and one that
|
||||
// lands on another @word doesn't open anything.
|
||||
if (mention.start !== s.start || mention.query !== s.query) close();
|
||||
return;
|
||||
}
|
||||
@@ -172,7 +164,7 @@ export function useMentionAutocomplete(
|
||||
);
|
||||
const body = await res.json();
|
||||
const current = stateRef.current;
|
||||
// Only the lookup for what's typed now may open or close the list.
|
||||
// Only the search for what is typed now may open or close the list.
|
||||
if (current.query !== mention.query) return;
|
||||
if (body.success && body.data.length > 0) {
|
||||
setState({
|
||||
@@ -185,7 +177,7 @@ export function useMentionAutocomplete(
|
||||
setState({ ...current, open: false, results: [] });
|
||||
}
|
||||
} catch {
|
||||
// Aborted by a newer keystroke, or offline: nothing to offer.
|
||||
// Aborted by a newer keystroke, or offline, so nothing is offered.
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
@@ -209,7 +201,6 @@ export function useMentionAutocomplete(
|
||||
e.preventDefault();
|
||||
const n = s.results.length;
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
// Wraps, like any listbox.
|
||||
setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n });
|
||||
return true;
|
||||
}
|
||||
@@ -223,8 +214,8 @@ export function useMentionAutocomplete(
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Just the list: whatever Escape means to the editor's owner (cancel
|
||||
// an edit, close the chat) waits for a second press.
|
||||
// Closes just the list. Whatever Escape means to the editor's owner
|
||||
// (cancel an edit, close the chat) waits for a second press.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The feed tab the index opens on, as the user chose it.
|
||||
* Stores, in this browser, which feed tab the index opens on.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,13 +11,9 @@ 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.
|
||||
*/
|
||||
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */
|
||||
function readStored(): FeedTab {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored && (FEED_TABS as readonly string[]).includes(stored)
|
||||
@@ -26,10 +22,11 @@ function readStored(): FeedTab {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the feed tab the index opens on, and a setter that stores a new
|
||||
* choice. The profile's settings set it. The index's redirect, its tab bar and
|
||||
* `Index` read it, and each falls back to {@link DEFAULT_FEED_TAB} when it is
|
||||
* "followed" and nobody is signed in. Each call reads the stored tab once, so
|
||||
* a change shows in other components only once they remount.
|
||||
*/
|
||||
export function useDefaultFeedTab(): [FeedTab, (tab: FeedTab) => void] {
|
||||
const [tab, setTabState] = useState<FeedTab>(readStored);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The dismissal animation of items leaving a list: a grace period, then a
|
||||
* collapse.
|
||||
* Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn
|
||||
* by mistake can be cast again before the dump is gone.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,25 +8,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Where an item is in its dismissal: `cooldown` is the 2-second grace period
|
||||
* (darkened), `dismissing` the collapse (opacity 0 + grid-row collapse).
|
||||
* The phase of a fading item, which the list maps to a CSS class. `cooldown`
|
||||
* lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse.
|
||||
*/
|
||||
export type FadeState = "cooldown" | "dismissing";
|
||||
|
||||
/** What {@link useFading} returns. */
|
||||
/**
|
||||
* Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted
|
||||
* section. The list keeps showing an item while it is in `fading`, then drops
|
||||
* it.
|
||||
*/
|
||||
export interface UseFadingResult {
|
||||
/** The items being dismissed, by id, and where each is; for CSS classes. */
|
||||
fading: Record<string, FadeState>;
|
||||
/** Starts dismissing item `id`. */
|
||||
/** Does nothing when `id` is already fading. */
|
||||
startFading: (id: string) => void;
|
||||
/** Stops dismissing item `id` (e.g. the action was reversed). */
|
||||
/** Called when the vote is cast again: the item stays. */
|
||||
cancelFading: (id: string) => void;
|
||||
/** Cancels all in-progress animations (e.g. on page navigation). */
|
||||
/** Called when the list changes to another user's. */
|
||||
cancelAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 2-second cooldown + 350ms dismissal animation for a set of items.
|
||||
* Returns the fading state of a list's items and the calls that drive it. The
|
||||
* timers are cleared on unmount.
|
||||
*/
|
||||
export function useFading(): UseFadingResult {
|
||||
const [fading, setFading] = useState<Record<string, FadeState>>({});
|
||||
@@ -38,6 +42,8 @@ export function useFading(): UseFadingResult {
|
||||
|
||||
const startFading = useCallback((id: string) => {
|
||||
if (cancels.current.has(id)) return;
|
||||
// `kill` is replaced as each timer is set, so it always clears the one
|
||||
// running; `dead` stops a timer that fired before it was cleared.
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* A feed's pages and scroll position, kept in sessionStorage so going back to
|
||||
* it restores them.
|
||||
* Keeps a feed's loaded pages and scroll position in sessionStorage, so that
|
||||
* going back to a feed page shows it as it was left instead of refetching it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,30 +8,32 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigationType } from "react-router";
|
||||
|
||||
/** How long a saved feed stays good: 10 minutes. */
|
||||
/** Past this age, a saved feed is refetched rather than restored. */
|
||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/** A saved feed. */
|
||||
/**
|
||||
* Stored as JSON by {@link useFeedCache}'s `saveState`, and read back by the
|
||||
* hook when the page is next reached by going back.
|
||||
*/
|
||||
export interface FeedCacheEntry<T> {
|
||||
/** Its items, every page loaded so far. */
|
||||
/** Every page loaded so far. JSON turns their dates into strings. */
|
||||
items: T[];
|
||||
/** The last page loaded. */
|
||||
/** The last page loaded, from which loading more carries on. */
|
||||
page: number;
|
||||
/** Whether another page follows. */
|
||||
hasMore: boolean;
|
||||
/** The scroll position. */
|
||||
scrollY: number;
|
||||
/** When it was saved, in epoch ms. */
|
||||
/** In epoch milliseconds. */
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
/** What {@link useFeedCache} returns. */
|
||||
/** Returned by {@link useFeedCache} to a feed page. */
|
||||
export interface UseFeedCacheResult<T> {
|
||||
/**
|
||||
* The saved feed, when the page was reached by going back and one is fresh.
|
||||
* The saved feed, when the page was reached by going back or forward and the
|
||||
* feed was saved less than 10 minutes ago. It doesn't change after mount.
|
||||
*/
|
||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||
/** Saves the feed. */
|
||||
/** Usually called through `useScrollSave`, as the page is left. */
|
||||
saveState: (
|
||||
items: T[],
|
||||
page: number,
|
||||
@@ -41,9 +43,11 @@ export interface UseFeedCacheResult<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the feed saved under `key` and a way to save it. The feed pages and
|
||||
* `useUserDumpFeed` use it. `key` must tell apart every list whose items
|
||||
* differ, the viewer included when they see private items. `hydrateItem`
|
||||
* restores what JSON lost, such as dates. A full or blocked storage is
|
||||
* ignored.
|
||||
*/
|
||||
export function useFeedCache<T>(
|
||||
key: string,
|
||||
@@ -51,7 +55,7 @@ export function useFeedCache<T>(
|
||||
): UseFeedCacheResult<T> {
|
||||
const navType = useNavigationType();
|
||||
|
||||
// useState initializer runs exactly once on mount — correct for a cache read.
|
||||
// Read once: a new navigation to the page must not pick up what it saves.
|
||||
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||
if (navType !== "POP") return null;
|
||||
try {
|
||||
@@ -79,7 +83,7 @@ export function useFeedCache<T>(
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// QuotaExceededError or SecurityError — degrade silently
|
||||
// Full or blocked storage: the page just refetches next time.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
/**
|
||||
* Loading a feed's next page as its end scrolls into view.
|
||||
* Loads a feed's next page when the end of the feed scrolls into view.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Longest wait between two loads while the sentinel never left the view. */
|
||||
/** The longest wait between two loads while the sentinel stays in view. */
|
||||
const MAX_RETRY_DELAY_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Returns the ref of a sentinel element, and calls `onLoadMore` when it scrolls
|
||||
* into view while `enabled`.
|
||||
* Returns a callback ref for the sentinel element placed after a feed's last
|
||||
* item, and calls `onLoadMore` when the sentinel comes within 200px of the
|
||||
* view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and
|
||||
* not already loading".
|
||||
*
|
||||
* 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.
|
||||
* The ref is a callback so that the observer follows a new sentinel, as when
|
||||
* `Index` switches tabs and another feed mounts its own.
|
||||
*
|
||||
* 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.
|
||||
* A sentinel still in view after a load asks again, which fills a tall screen,
|
||||
* but would also retry a failing request as fast as it fails. So while it
|
||||
* stays in view the waits double, from 1s up to 30s, and they start over once
|
||||
* it leaves the view or new items push it down.
|
||||
*/
|
||||
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.
|
||||
// Loads asked for since the sentinel last left the view or moved down.
|
||||
const attemptsRef = useRef(0);
|
||||
const lastLoadAtRef = useRef(0);
|
||||
const lastTopRef = useRef<number | null>(null);
|
||||
@@ -58,8 +55,8 @@ export function useInfiniteScroll(
|
||||
lastLoadAtRef.current = Date.now();
|
||||
onLoadMore();
|
||||
};
|
||||
// Counted from the last load, not from now: the observer is remade on
|
||||
// every render that changes onLoadMore, which mustn't restart the wait.
|
||||
// Counted from the last load, not from now: the observer is remade
|
||||
// whenever `onLoadMore` changes, which mustn't restart the wait.
|
||||
const wait = attempts === 0 ? 0 : lastLoadAtRef.current +
|
||||
Math.min(1000 * 2 ** (attempts - 1), MAX_RETRY_DELAY_MS) - Date.now();
|
||||
if (wait <= 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Putting items that left a list back where they were.
|
||||
* Puts an item that left a profile's list, by turning private, back at its
|
||||
* old position when it turns public again.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,19 +8,17 @@
|
||||
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.
|
||||
* Remembers the index of an item that `lastEvent` removes from `items`, and
|
||||
* puts the item back there when a later event makes it visible again. The
|
||||
* profile pages use it with `WSContext`'s latest dump or playlist edit.
|
||||
* `items` is the loaded list, or `[]` before it loads, and `setItems` the
|
||||
* setter also passed to `useDumpListSync` or `usePlaylistListSync`.
|
||||
* `isRemoving` tells an event that takes the item out, and `shouldReinsert`
|
||||
* one that brings it back.
|
||||
*
|
||||
* `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.
|
||||
* It must be called before the sync hook, so that its effects run first: it
|
||||
* saves the index before the sync hook removes the item. The sync hook must
|
||||
* get `skipReinsert`, or it would add the item a second time.
|
||||
*/
|
||||
export function usePositionAwareSync<T extends { id: string }>(
|
||||
items: T[],
|
||||
@@ -41,7 +40,6 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
|
||||
const removedPositionsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Save position BEFORE the sync hook removes the item.
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
|
||||
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
|
||||
@@ -50,8 +48,6 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
}
|
||||
}, [lastEvent]);
|
||||
|
||||
// Re-insert at the saved position. Fires before the sync hook's effect for
|
||||
// the same event; skipReinsert on the sync hook prevents double-insertion.
|
||||
useEffect(() => {
|
||||
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
|
||||
const savedIdx = removedPositionsRef.current.get(lastEvent.id);
|
||||
@@ -61,6 +57,7 @@ export function usePositionAwareSync<T extends { id: string }>(
|
||||
setItemsRef.current((prev) => {
|
||||
if (prev.some((i) => i.id === item.id)) return prev;
|
||||
const next = [...prev];
|
||||
// The list may have shrunk since.
|
||||
next.splice(Math.min(savedIdx, next.length), 0, item);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Saving a feed as the page is left.
|
||||
* Saves a feed page's state, with its scroll position, as the page is left.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,17 +7,18 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Calls `onSave(scrollY)` when the page is left — the component unmounts (a
|
||||
* navigation within the app) or the tab goes away (`pagehide`) — as long as
|
||||
* `enabled` is true (e.g. the page is loaded).
|
||||
* Calls `onSave(scrollY)` when the component unmounts or the tab is hidden
|
||||
* (`pagehide`), while `enabled`. Feed pages pass `enabled` once their feed is
|
||||
* loaded, and an `onSave` that calls `useFeedCache`'s `saveState`.
|
||||
*
|
||||
* Scrolling itself only notes the position: saving serializes the whole feed
|
||||
* into sessionStorage, which on every pause in scrolling cost a long task.
|
||||
* It doesn't save on scroll, since serializing the whole feed there took a long
|
||||
* task at every pause.
|
||||
*/
|
||||
export function useScrollSave(
|
||||
enabled: boolean,
|
||||
onSave: (scrollY: number) => void,
|
||||
): void {
|
||||
// A ref, so a new `onSave` on every render doesn't re-attach the listeners.
|
||||
const onSaveRef = useRef(onSave);
|
||||
useLayoutEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
@@ -25,8 +26,8 @@ export function useScrollSave(
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Tracked as it happens: by the time the unmount runs, the next page may
|
||||
// already have scrolled the window back to the top.
|
||||
// Tracked on scroll: by the time the unmount runs, the next page may have
|
||||
// scrolled the window back to the top.
|
||||
let y = globalThis.scrollY;
|
||||
const onScroll = () => {
|
||||
y = globalThis.scrollY;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Playing a link's preview in the player.
|
||||
* Plays a link's preview in the global player, as the provider's embed or as a
|
||||
* queue of Bandcamp streams. RichContentCard and JournalCard call it when their
|
||||
* thumbnail is clicked.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,27 +14,30 @@ 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. */
|
||||
/**
|
||||
* Passed to `playRichContent` and {@link canPlayRichContent} by RichContentCard
|
||||
* and JournalCard. It is a dump's `RichContent`, its thumbnail replaced by the
|
||||
* dump's own when it has one.
|
||||
*/
|
||||
export interface PlayableRichContent {
|
||||
/** The provider, as in `RichContent.type`. */
|
||||
type: string;
|
||||
/** The link. */
|
||||
/** The page, which native Bandcamp playback resolves. */
|
||||
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. */
|
||||
/** Shown in the player's header, unless Bandcamp has album art of its own. */
|
||||
thumbnailUrl?: string;
|
||||
/** Header subtitle — "YouTube", "SoundCloud", the site's own name. */
|
||||
/** Shown under the title in the player's header. */
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
/** What {@link usePlayRichContent} returns. */
|
||||
/** Returned by {@link usePlayRichContent} to the cards that play a link. */
|
||||
export interface UsePlayRichContentResult {
|
||||
/**
|
||||
* Starts playback of `rc`, played from the dump page `dumpHref`; resolves to
|
||||
* whether playback actually started.
|
||||
* Plays `rc`, its title in the player linking to the dump page `dumpHref`.
|
||||
* Resolves to false when nothing could play, so the caller can open the link
|
||||
* instead.
|
||||
*/
|
||||
playRichContent: (
|
||||
rc: PlayableRichContent,
|
||||
@@ -43,18 +48,12 @@ export interface UsePlayRichContentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* Returns a function that plays a link's preview in its provider's embed. When
|
||||
* the deployment sets `GERBEUR_BANDCAMP_PLAYER=native`, a Bandcamp page is
|
||||
* resolved into its mp3 streams instead and played as a queue, which autoplays
|
||||
* and plays an album through. If that fails (offline, a preorder, Bandcamp
|
||||
* changing its markup), the embed plays, and a page without one (an artist's
|
||||
* root, a /music index) plays nothing.
|
||||
*/
|
||||
export function usePlayRichContent(): UsePlayRichContentResult {
|
||||
const { play, playQueue } = useContext(PlayerContext);
|
||||
@@ -107,9 +106,9 @@ export function usePlayRichContent(): UsePlayRichContentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Checks whether `rc` can be played, for the cards to offer a play button. In
|
||||
* native mode a Bandcamp page plays even without an embed, since its streams
|
||||
* are resolved from the page itself.
|
||||
*/
|
||||
export function canPlayRichContent(rc: PlayableRichContent): boolean {
|
||||
if (rc.embedUrl) return true;
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Gives GlobalPlayer the tracklist and transport of a YouTube playlist embed,
|
||||
* whose list stays inside the iframe. The titles and lengths come from the
|
||||
* server, since the embed exposes neither; the order, the position and the
|
||||
* controls come from the IFrame Player API. Without the API (script blocked,
|
||||
* embedding restricted) the rows are inert; without the server's list they
|
||||
* have no titles.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -41,7 +36,9 @@ interface YTPlayer {
|
||||
|
||||
/** A player state change. */
|
||||
interface YTStateEvent {
|
||||
/** YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering. */
|
||||
/**
|
||||
* YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering.
|
||||
*/
|
||||
data: number;
|
||||
}
|
||||
|
||||
@@ -62,13 +59,11 @@ interface YTNamespace {
|
||||
const PAUSED = 2;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* How soon after the tab is hidden a pause is undone. An embed with
|
||||
* `enablejsapi` pauses itself when the page goes to the background, which a
|
||||
* plain embed doesn't, and that stops the music while one works in another
|
||||
* tab. The window is short and the retries few, so that a later pause (a media
|
||||
* key, a phone call) stands.
|
||||
*/
|
||||
const RESUME_WINDOW_MS = 4000;
|
||||
/** How many pauses are undone per hiding of the tab. */
|
||||
@@ -82,10 +77,8 @@ 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;
|
||||
|
||||
/** Loads YouTube's IFrame API once per document, whoever asks first. */
|
||||
@@ -114,35 +107,36 @@ function loadPlayerApi(): Promise<YTNamespace> {
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
/** A row of the tracklist. */
|
||||
/**
|
||||
* A row of the tracklist GlobalPlayer shows. Its title and length come from
|
||||
* the server's list, and are missing for a video that list lacks.
|
||||
*/
|
||||
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. */
|
||||
/** In seconds. */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** What {@link useYouTubePlaylist} returns. */
|
||||
/**
|
||||
* Returned by {@link useYouTubePlaylist} to GlobalPlayer, which shows the
|
||||
* tracklist and its transport when there is more than one row.
|
||||
*/
|
||||
export interface UseYouTubePlaylistResult {
|
||||
/** The tracklist. */
|
||||
rows: PlaylistRow[];
|
||||
/** Position in `rows`, or -1 while the player hasn't reported one. */
|
||||
/** The playing row, or -1 while the player hasn't reported one. */
|
||||
index: number;
|
||||
/** Whether the embed can be driven — rows are clickable only then. */
|
||||
/** Whether the IFrame API drives the embed. 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.
|
||||
* iframe `iframeRef`, or no rows when it plays none. The iframe's URL must
|
||||
* carry `enablejsapi` (see `withJsApi`) for the API to drive it.
|
||||
*/
|
||||
export function useYouTubePlaylist(
|
||||
embedUrl: string | null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Keeping a list of playlists in step with the socket.
|
||||
* Keeps a page's list of playlists in step with the socket's `playlist_*`
|
||||
* messages. The user's profile and playlists pages call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,36 +9,34 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { useWS } from "../ws/useWS.ts";
|
||||
|
||||
/** How {@link usePlaylistListSync} treats the list. */
|
||||
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
|
||||
export interface PlaylistListSyncOptions {
|
||||
/** Keep private playlists visible (caller is the owner). */
|
||||
/** Keeps private playlists in the list, for their owner. */
|
||||
isOwner?: boolean;
|
||||
/**
|
||||
* Only act on created/updated events for playlists owned by this user.
|
||||
* Leave undefined to act on any playlist already in the list.
|
||||
* Adds only this user's playlists, when one is created or comes back into
|
||||
* view. Without it, anyone's are added.
|
||||
*/
|
||||
ownerId?: string;
|
||||
/**
|
||||
* Set true for "followed" lists: never prepend newly created playlists
|
||||
* (followed membership is managed separately), but still update/remove.
|
||||
* Adds no playlist, for a list of followed playlists, since the socket
|
||||
* doesn't say what the user follows. Playlists already listed still change
|
||||
* and go.
|
||||
*/
|
||||
noNewEntries?: boolean;
|
||||
/**
|
||||
* When true, don't re-add a playlist that isn't in the list (idx === -1).
|
||||
* Use this when the caller handles re-insertion itself (e.g. with a position
|
||||
* map, like the dumps/votes pattern in UserPublicProfile).
|
||||
* Leaves out a playlist that comes back into view, for a caller that puts it
|
||||
* back where it was with `usePositionAwareSync`.
|
||||
*/
|
||||
skipReinsert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Applies the socket's playlist events to the list set through
|
||||
* `setPlaylists`. A created playlist goes first, an updated one changes in
|
||||
* place or leaves when it turns private (unless `isOwner`), a deleted one
|
||||
* leaves, and a
|
||||
* playlist's `dumpCount` follows its dumps.
|
||||
*/
|
||||
export function usePlaylistListSync(
|
||||
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
||||
@@ -69,17 +68,14 @@ export function usePlaylistListSync(
|
||||
if (ev.type === "updated" && ev.playlist) {
|
||||
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
|
||||
if (idx === -1) {
|
||||
// Playlist was removed (went private). Re-add if it's now public and
|
||||
// visible to this list. noNewEntries lists (followed) skip this since
|
||||
// we can't verify the follow relationship is still active.
|
||||
// Left the list when it turned private, and may be back. A followed
|
||||
// list can't tell whether the user still follows it.
|
||||
if (noNewEntries) return prev;
|
||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||
// skipReinsert: caller handles re-insertion at the correct position.
|
||||
if (optionsRef.current?.skipReinsert) return prev;
|
||||
return [ev.playlist, ...prev];
|
||||
}
|
||||
// Remove if it became private and the viewer can't see private playlists
|
||||
if (!ev.playlist.isPublic && !isOwner) {
|
||||
return prev.filter((p) => p.id !== ev.playlist!.id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Placing a floating panel against its anchor, clear of clipping ancestors.
|
||||
* Places a floating panel against its anchor with `position: fixed`, for
|
||||
* `TextEditor`'s emoji picker and mention list.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,38 +12,36 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
/** Space between the anchor and the panel. */
|
||||
/** The space between the anchor and the panel, in pixels. */
|
||||
const GAP = 4;
|
||||
/** Space kept clear of the viewport edges. */
|
||||
/** The space kept clear of the viewport's edges, in pixels. */
|
||||
const MARGIN = 8;
|
||||
|
||||
/** How {@link useAnchoredFloat} sizes the panel. */
|
||||
/**
|
||||
* Passed to {@link useAnchoredFloat} by the component that owns the panel, in
|
||||
* pixels. The heights let the hook size the panel's scrolling part to the room
|
||||
* left.
|
||||
*/
|
||||
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.
|
||||
* A width, or "anchor" for the anchor's own. Either shrinks to fit a
|
||||
* narrower viewport.
|
||||
*/
|
||||
width: number | "anchor";
|
||||
/** Height of the panel's non-scrolling chrome (search row, borders…). */
|
||||
/** The height of the panel's parts that don't scroll, borders included. */
|
||||
chromeHeight: number;
|
||||
/** The least height of the panel's scrolling part. */
|
||||
minContentHeight: number;
|
||||
/** The most height of the panel's scrolling part. */
|
||||
maxContentHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns the style that places a panel above or below `anchorRef`, whichever
|
||||
* has more room, kept inside the viewport and following scrolls and resizes.
|
||||
* The panel must be portaled out of any clipping ancestor, and its scrolling
|
||||
* part must take its height from the `--float-max-h` property of the style.
|
||||
*
|
||||
* 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 not `open`; the first value lands in a layout effect, so
|
||||
* the panel is never painted at the wrong spot.
|
||||
* It returns null while `open` is false, and also on the first render after
|
||||
* opening, until the panel is measured. The caller hides the panel meanwhile.
|
||||
*/
|
||||
export function useAnchoredFloat(
|
||||
anchorRef: RefObject<HTMLElement | null>,
|
||||
@@ -62,9 +61,9 @@ export function useAnchoredFloat(
|
||||
const vw = globalThis.innerWidth;
|
||||
const vh = globalThis.innerHeight;
|
||||
|
||||
// Clamp the anchor's edges into the viewport first: an auto-growing
|
||||
// textarea can be taller than the screen, and an off-screen edge would
|
||||
// otherwise put the panel out of reach.
|
||||
// The anchor's edges are clamped into the viewport first: an
|
||||
// auto-growing textarea can be taller than the screen, and an off-screen
|
||||
// edge would put the panel out of reach.
|
||||
const anchorTop = Math.min(Math.max(r.top, MARGIN), vh - MARGIN);
|
||||
const anchorBottom = Math.min(Math.max(r.bottom, MARGIN), vh - MARGIN);
|
||||
const roomAbove = anchorTop - GAP - MARGIN;
|
||||
@@ -81,8 +80,8 @@ export function useAnchoredFloat(
|
||||
),
|
||||
);
|
||||
|
||||
// Anchoring by `bottom` when above means the panel's own height never
|
||||
// enters the computation, so it can't jump as the list loads.
|
||||
// Placing it by `bottom` when above keeps the panel's own height out of
|
||||
// the computation, so it can't jump as the list loads.
|
||||
const next: CSSProperties & Record<string, string | number> = {
|
||||
position: "fixed",
|
||||
width: w,
|
||||
@@ -103,7 +102,7 @@ export function useAnchoredFloat(
|
||||
};
|
||||
|
||||
place();
|
||||
// `true` catches scrolling in any ancestor scroller, not just the window.
|
||||
// The capture phase catches a scroll in any ancestor, not just the window.
|
||||
globalThis.addEventListener("scroll", place, true);
|
||||
globalThis.addEventListener("resize", place);
|
||||
// The anchor itself moves when an auto-resizing textarea grows.
|
||||
@@ -123,7 +122,7 @@ export function useAnchoredFloat(
|
||||
maxContentHeight,
|
||||
]);
|
||||
|
||||
// Gated on `open` rather than cleared on close: the stale value is never
|
||||
// painted, since the layout effect re-places the panel before the next paint.
|
||||
// Gated on `open` rather than cleared on close: on reopening, the stale
|
||||
// value is never painted, since the layout effect re-places the panel first.
|
||||
return open ? style : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The tab's title: the page's, the site's name, and an unread badge.
|
||||
* Sets the browser tab's title from the page's title, the site's name and the
|
||||
* unread count. Every page calls {@link useDocumentTitle}, and `WSProvider`
|
||||
* calls {@link setTitleBadge}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,10 +9,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* Returns the site's name, from the `site-name` meta tag that
|
||||
* api/lib/index-html.ts fills in. The Vite dev server leaves the placeholder,
|
||||
* so it falls back to "gerbeur".
|
||||
*/
|
||||
function readSiteName(): string {
|
||||
const meta = document.querySelector('meta[name="site-name"]')
|
||||
@@ -19,12 +20,12 @@ function readSiteName(): string {
|
||||
return "gerbeur";
|
||||
}
|
||||
|
||||
/** The site's name, read once at startup. */
|
||||
/** The site's name, read once at startup, as the header shows it. */
|
||||
export const SITE_NAME: string = readSiteName();
|
||||
|
||||
/**
|
||||
* Returns the site's emoji. The server injects the configured emoji into a
|
||||
* `site-emoji` meta tag; it falls back to the default truck.
|
||||
* Returns the site's emoji, from the `site-emoji` meta tag that the server
|
||||
* fills in, or the default truck when it is left unfilled.
|
||||
*/
|
||||
function readSiteEmoji(): string {
|
||||
const meta = document.querySelector('meta[name="site-emoji"]')
|
||||
@@ -33,35 +34,25 @@ function readSiteEmoji(): string {
|
||||
return "🚚";
|
||||
}
|
||||
|
||||
/** The site's emoji, read once at startup. */
|
||||
/** The site's emoji, read once at startup, as the header shows it. */
|
||||
export const SITE_EMOJI: string = readSiteEmoji();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// The page and `WSProvider` set these apart, so both are kept here and
|
||||
// composed. A null `baseTitle` keeps the title the server rendered.
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps document.title in sync with the current page during client-side
|
||||
* 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 `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).
|
||||
* Sets the tab's title to `title · site name` for the page that calls it, since
|
||||
* a client-side navigation leaves the server's `<title>` in place. A page
|
||||
* passes null for the bare site name, and undefined while its data loads,
|
||||
* which keeps the title the server rendered for that URL rather than flashing
|
||||
* the site name.
|
||||
*/
|
||||
export function useDocumentTitle(title: string | null | undefined): void {
|
||||
useEffect(() => {
|
||||
@@ -72,7 +63,8 @@ export function useDocumentTitle(title: string | null | undefined): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes the tab title with the unread `count`, e.g. "(3) Chat · gerbeur".
|
||||
* Prefixes the tab's title with the unread `count`, as in "(3) Chat · gerbeur",
|
||||
* or removes the prefix at 0. Past 99 it shows "99+".
|
||||
*/
|
||||
export function setTitleBadge(count: number): void {
|
||||
if (count === badge) return;
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
/**
|
||||
* Easing an element's height when its content swaps.
|
||||
* Eases an element's height when its content swaps, for the pages and cards
|
||||
* that open an edit form or a reply box in place.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/** The duration when `--dur-reveal` isn't set, in ms. */
|
||||
/** The duration when `--dur-reveal` isn't set, in milliseconds. */
|
||||
const FALLBACK_MS = 250;
|
||||
|
||||
/**
|
||||
* Eases an element's height whenever `watch` changes, so an interaction that
|
||||
* 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 eases whenever `watch`
|
||||
* changes, usually the flag that swaps its content. Only the box is animated,
|
||||
* so the content that appears should take the `animate-in` class to fade in.
|
||||
* The duration follows `--dur-reveal`, and nothing moves under
|
||||
* `prefers-reduced-motion`.
|
||||
*/
|
||||
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
|
||||
// mid-animation, when a second toggle should start from the current frame.
|
||||
// Kept current by a ResizeObserver, so that when the swap commits the box's
|
||||
// previous height is still known, even mid-animation, where a second toggle
|
||||
// must start from the current frame.
|
||||
const lastHeight = useRef<number | null>(null);
|
||||
const lastWatch = useRef(watch);
|
||||
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
/**
|
||||
* Measuring the width an element has beside another in a shared row.
|
||||
* Measures the width an element has beside another in a shared row, for the
|
||||
* presence avatars in the feed's header (`PresenceRow`).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/** Where {@link useRoomBeside} measures. */
|
||||
/**
|
||||
* Passed to {@link useRoomBeside} by `PresenceRow`, from its own props. Without
|
||||
* both selectors, the hook measures nothing and returns null.
|
||||
*/
|
||||
export interface RoomBesideOptions {
|
||||
/** An ancestor selector: what bounds the row. */
|
||||
/** Selects the ancestor that bounds the row. */
|
||||
within?: string;
|
||||
/** A selector inside it: what comes first. */
|
||||
/** Selects, inside that ancestor, the element that takes its room first. */
|
||||
beside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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` 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.
|
||||
* Returns the width `el` has to itself in a row it shares with an element that
|
||||
* comes first, such as the tabs that leave room for the presence avatars. The
|
||||
* first element counts at its content's natural width, since that depends on
|
||||
* the theme's fonts and the locale, and the row's gap is taken off too. It
|
||||
* returns 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.
|
||||
* It takes the element itself, from a callback ref, so that the measuring
|
||||
* starts whenever it mounts. The element's own width doesn't enter into it, so
|
||||
* it can size itself to the answer without feedback.
|
||||
*/
|
||||
export function useRoomBeside(
|
||||
el: HTMLElement | null,
|
||||
@@ -47,9 +49,9 @@ export function useRoomBeside(
|
||||
setRoom(Math.floor(width - gap - (kept ? contentWidth(kept) : 0)));
|
||||
}
|
||||
|
||||
// The container for its width; the tabs themselves, since they change
|
||||
// width without their box changing (a web font arriving, a tab added at
|
||||
// login) — re-bound when they come and go.
|
||||
// The tabs are observed too, since their width changes without their
|
||||
// box's (a web font arriving, a tab added at sign-in), and re-observed as
|
||||
// they come and go.
|
||||
const resize = new ResizeObserver(measure);
|
||||
const mutation = new MutationObserver(observe);
|
||||
function observe() {
|
||||
@@ -74,10 +76,10 @@ export function useRoomBeside(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns how wide the content of `el` runs, from its first child to its last,
|
||||
* plus its padding. Its box won't do, since a flex row may squeeze it (the tabs
|
||||
* then scroll) or stretch it, and neither will its scrollWidth, which is at
|
||||
* least the box.
|
||||
*/
|
||||
function contentWidth(el: HTMLElement): number {
|
||||
const first = el.firstElementChild, last = el.lastElementChild;
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/**
|
||||
* Freezing the page's scroll under a modal.
|
||||
* Freezes the page's scroll under `Modal` and `ConfirmModal`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** How many components hold the lock. */
|
||||
// Counted, so that a confirmation over a modal doesn't release the page when
|
||||
// it closes first.
|
||||
let locks = 0;
|
||||
|
||||
/**
|
||||
* Freezes the page's scroll while the calling component is mounted — for
|
||||
* modals, whose backdrop shouldn't let the feed slide underneath.
|
||||
*
|
||||
* 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.
|
||||
* Freezes the page's scroll while the calling component is mounted, so that
|
||||
* the page doesn't slide under a dialog's backdrop. Nested locks are counted.
|
||||
*/
|
||||
export function useScrollLock(): void {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (locks++ === 0) {
|
||||
// Hiding the overflow drops the scrollbar, which would widen the page
|
||||
// and shift it. A showing bar's gutter is held for the lock instead.
|
||||
if (globalThis.innerWidth > root.clientWidth) {
|
||||
root.style.scrollbarGutter = "stable";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The sounds setting.
|
||||
* Gives the profile page's settings the toggle for notification and chat
|
||||
* sounds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,9 +12,10 @@ import {
|
||||
} from "../../utils/shared/sound.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns whether sounds are on in this browser, and a setter that stores the
|
||||
* choice. `playChime` reads the stored value each time it plays, so the choice
|
||||
* applies at once without this hook, and another mounted copy of it isn't
|
||||
* updated.
|
||||
*/
|
||||
export function useSoundsEnabled(): [boolean, (enabled: boolean) => void] {
|
||||
const [enabled, setEnabledState] = useState(readSoundsEnabled);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The active tab, kept in the URL.
|
||||
* Keeps a page's active tab in its URL, for the profile and search pages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,20 +7,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
/** What separates the base path from the tab. */
|
||||
/** Separates the page's path from the tab, as App.tsx's routes expect. */
|
||||
const SEP = "/~/";
|
||||
|
||||
/**
|
||||
* 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 tab named after `/~/` in the current path, or `defaultTab` when
|
||||
* there is none or it isn't in `validTabs`, and a setter that navigates to a
|
||||
* tab in place of the current history entry. The page's route must take both
|
||||
* `<path>` and `<path>/~/:tab`, as `/users/:username/~/:profileTab` does. The
|
||||
* default tab's URL is the bare path, except at the root, where it stays
|
||||
* explicit.
|
||||
*/
|
||||
export function useTabParam<T extends string>(
|
||||
validTabs: Iterable<T>,
|
||||
@@ -34,7 +30,8 @@ export function useTabParam<T extends string>(
|
||||
const sepAt = path.indexOf(SEP);
|
||||
|
||||
let base = sepAt === -1 ? path : path.slice(0, sepAt);
|
||||
if (base.endsWith("/")) base = base.slice(0, -1); // root "/" → ""
|
||||
// The root "/" becomes "".
|
||||
if (base.endsWith("/")) base = base.slice(0, -1);
|
||||
|
||||
const raw = sepAt === -1
|
||||
? null
|
||||
@@ -44,9 +41,9 @@ export function useTabParam<T extends string>(
|
||||
const setTab = useCallback(
|
||||
(t: T) => {
|
||||
const target = t === defaultTab && base ? base : `${base}${SEP}${t}`;
|
||||
// Preserve any unrelated query string (e.g. the search page's `?q=`),
|
||||
// and the entry's state along with it — this is the same entry, and the
|
||||
// header's search reads where to go back to from there.
|
||||
// Keeps the query string (the search page's `?q=`) and the entry's
|
||||
// state, since this is the same entry and `AppHeader`'s search reads
|
||||
// where to go back to from it.
|
||||
navigate(`${target}${location.search}`, {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The signed-in user's follows, from their context.
|
||||
* Reads `FollowContext`, for the Follow buttons.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
type FollowContextValue,
|
||||
} from "../../contexts/social/FollowContext.ts";
|
||||
|
||||
/** Returns whom and what the signed-in user follows, and a way to change it. */
|
||||
/**
|
||||
* Returns the users and playlists the signed-in user follows, and the
|
||||
* functions that follow and unfollow them. Needs `FollowProvider` above, which
|
||||
* `App` mounts around every page.
|
||||
*/
|
||||
export function useFollows(): FollowContextValue {
|
||||
return useContext(FollowContext);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The theme, from its context.
|
||||
* Gives components the theme that `ThemeProvider` holds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
type ThemeContextValue,
|
||||
} from "../../contexts/theme/ThemeContext.ts";
|
||||
|
||||
/** Returns the style and color scheme, and a way to change them. */
|
||||
/**
|
||||
* Returns the style and color scheme, and their setters. The profile page's
|
||||
* settings are the only place that changes them.
|
||||
*/
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The socket's live state, from its three contexts.
|
||||
* Reads the live state that `WSProvider` keeps from the WebSocket. Each hook
|
||||
* reads one of its three contexts, so that a component re-renders only with
|
||||
* the part it uses. Outside `WSProvider`, they return inert defaults.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -15,18 +17,24 @@ import {
|
||||
} from "../../contexts/ws/WSContext.ts";
|
||||
|
||||
/**
|
||||
* Returns the votes, likes, live dumps, playlists, comments and notifications.
|
||||
* Returns the connection's status, the live votes, likes, dumps, playlists,
|
||||
* comments and notifications, and the functions that vote and like. A
|
||||
* component using it re-renders on every one of those events.
|
||||
*/
|
||||
export function useWS(): WSContextValue {
|
||||
return useContext(WSContext);
|
||||
}
|
||||
|
||||
/** Returns the chat room. Re-renders with every message and typing change. */
|
||||
/**
|
||||
* Returns the chat's messages, typing and read state. A component using it
|
||||
* re-renders with every message and typing change, so a component that needs
|
||||
* only the unread count still pays for them.
|
||||
*/
|
||||
export function useWSChat(): WSChatContextValue {
|
||||
return useContext(WSChatContext);
|
||||
}
|
||||
|
||||
/** Returns who's online. */
|
||||
/** Returns who is online, for the presence row. */
|
||||
export function useWSPresence(): WSPresenceContextValue {
|
||||
return useContext(WSPresenceContext);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user