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:
66
src/App.tsx
66
src/App.tsx
@@ -1,25 +1,29 @@
|
||||
/**
|
||||
* The app: its providers and its routes.
|
||||
* Renders the whole app, its providers around its routes. `main.tsx` mounts
|
||||
* it. The routes, in the order they match, are these.
|
||||
*
|
||||
* - `/` — redirects to the default feed tab.
|
||||
* - `/~/:feedTab` — the feed of every dump ({@link Index}).
|
||||
* - `/:categorySlug/:feedTab` — a category's feed; `/:categorySlug` redirects
|
||||
* to
|
||||
* its default tab.
|
||||
* - `/dumps/:selectedDump` — a dump ({@link Dump}); `/edit` — its editor
|
||||
* ({@link DumpEdit}), signed in.
|
||||
* - `/login`, `/register` — sign-in and sign-up ({@link UserLogin},
|
||||
* {@link UserRegister}), signed out.
|
||||
* - `/reset-password` — a new password from a reset link ({@link
|
||||
* ResetPassword}).
|
||||
* - `/users/:username` and `/users/:username/~/:profileTab` — a profile
|
||||
* ({@link UserPublicProfile}); `/dumps`, `/upvoted`, `/playlists` — its full
|
||||
* lists ({@link UserDumps}, {@link UserUpvoted}, {@link UserPlaylists}).
|
||||
* - `/playlists/:playlistId` — a playlist ({@link PlaylistDetail}).
|
||||
* - `/search` and `/search/~/:searchTab` — search results ({@link Search}).
|
||||
* - `/notifications` — the user's notifications ({@link Notifications}), signed
|
||||
* in.
|
||||
* - anything else — {@link NotFound}.
|
||||
* - `/` redirects to the viewer's default feed tab, keeping the query string.
|
||||
* - `/~/:feedTab` is the feed of every dump (`Index`).
|
||||
* - `/dumps/:selectedDump` is a dump (`Dump`), and its `/edit` the dump's
|
||||
* editor (`DumpEdit`), signed in only.
|
||||
* - `/register` and `/login` sign up and sign in (`UserRegister`,
|
||||
* `UserLogin`), signed out only.
|
||||
* - `/users/:username` and `/users/:username/~/:profileTab` are a profile
|
||||
* (`UserPublicProfile`). Its `/dumps`, `/upvoted` and `/playlists` are its
|
||||
* full lists (`UserDumps`, `UserUpvoted`, `UserPlaylists`).
|
||||
* - `/playlists/:playlistId` is a playlist (`PlaylistDetail`).
|
||||
* - `/search` and `/search/~/:searchTab` are the search results (`Search`).
|
||||
* - `/reset-password` sets a new password from an emailed link
|
||||
* (`ResetPassword`).
|
||||
* - `/notifications` lists the user's notifications (`Notifications`), signed
|
||||
* in only.
|
||||
* - `/:categorySlug/:feedTab` is a category's feed (`Index`), and
|
||||
* `/:categorySlug` redirects to its default tab.
|
||||
* - Any other path is `NotFound`.
|
||||
*
|
||||
* The pages not marked are open to anyone. Every page is loaded with its
|
||||
* route. Since category slugs share the root with the other routes, the server
|
||||
* reserves those routes' names as slugs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -53,7 +57,6 @@ import { ChatFab } from "./components/layout/ChatFab.tsx";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
/** The feed page, loaded with its route; so is every page below. */
|
||||
const Index = lazy(() =>
|
||||
import("./pages/Index.tsx").then((m) => ({ default: m.Index }))
|
||||
);
|
||||
@@ -108,8 +111,8 @@ const NotFound = lazy(() =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the default feed tab for the current viewer ("followed" needs a
|
||||
* session).
|
||||
* Returns the viewer's default feed tab, from their settings. A signed-out
|
||||
* viewer never gets "followed", which needs a session.
|
||||
*/
|
||||
function useResolvedDefaultTab() {
|
||||
const { user } = useAuth();
|
||||
@@ -118,9 +121,9 @@ function useResolvedDefaultTab() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects `/` to the default feed tab. The query string has to survive the
|
||||
* hop: the Web Share Target posts to "/", so an Android share arrives here as
|
||||
* `/?share_url=…` and the feed below is the only thing that can act on it.
|
||||
* Redirects `/` to the default feed tab. The query string must survive: the
|
||||
* web app manifest's share target opens `/?share_url=…` for an Android share,
|
||||
* and only the feed acts on it.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { search } = useLocation();
|
||||
@@ -140,9 +143,9 @@ function CategoryRedirect() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the feed of `/~/:feedTab` (all) and `/:categorySlug/:feedTab` alike.
|
||||
* Keying by scope remounts the feed when switching categories so it refetches,
|
||||
* while switching tabs within a scope keeps it mounted (client-side re-sort).
|
||||
* Renders the feed of `/~/:feedTab` and `/:categorySlug/:feedTab` alike. The
|
||||
* key remounts the feed on a change of category, so that it refetches, and
|
||||
* keeps it mounted on a change of tab, which sorts on the client.
|
||||
*/
|
||||
function ScopedIndex() {
|
||||
const { categorySlug } = useParams();
|
||||
@@ -217,8 +220,9 @@ function AppRoutes() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the app: the theme, session, player, socket, follow and category
|
||||
* providers around the router, the chat, the routes and the global player.
|
||||
* Renders the app. The player, socket, follow and chat providers read the
|
||||
* session, and `ChatProvider` reads the URL, so it sits inside the router.
|
||||
* `GlobalPlayer` sits outside the routes, so playback survives navigation.
|
||||
*/
|
||||
export function App(): React.JSX.Element {
|
||||
return (
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A dump form's category picker.
|
||||
* Renders the category picker of the dump creation dialog and the dump editor.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,20 +7,19 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useCategories } from "../../hooks/category/useCategories.ts";
|
||||
|
||||
/** The props of {@link CategorySelect}. */
|
||||
/**
|
||||
* Passed to {@link CategorySelect} by `DumpCreateModal` and `DumpEdit`, which
|
||||
* hold the selection and send it as the dump's `categoryIds`.
|
||||
*/
|
||||
export interface CategorySelectProps {
|
||||
/** The ids of the categories chosen. */
|
||||
selected: Set<string>;
|
||||
/** Called with the id of a category toggled. */
|
||||
onToggle: (id: string) => void;
|
||||
/** Whether the chips are disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a multi-select of categories as toggle chips. Renders nothing when
|
||||
* no categories are configured, so dump forms stay unchanged until an admin
|
||||
* adds the first category.
|
||||
* Renders the categories as toggle chips, any number of which may be on. It
|
||||
* renders nothing while the site has no categories.
|
||||
*/
|
||||
export function CategorySelect(
|
||||
{ selected, onToggle, disabled }: CategorySelectProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The feed's category switcher.
|
||||
* Renders the menu that switches the index page's feed between all dumps and a
|
||||
* category.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,14 +19,9 @@ import { useCategories } from "../../hooks/category/useCategories.ts";
|
||||
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
|
||||
|
||||
/**
|
||||
* Renders the header dropdown that switches the feed scope between "All" and a
|
||||
* category, preserving the active feed tab (e.g. /music/new → /cinema/new).
|
||||
* Rendered as the leading item of the feed-sort tab row so it reads as part of
|
||||
* the bar.
|
||||
*
|
||||
* A custom popover (not a native <select>) keeps it on-theme; the menu uses
|
||||
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
|
||||
* sentinel value for the all-categories feed. Hidden when no categories exist.
|
||||
* Renders the category menu at the head of `FeedTabBar`. Picking a category
|
||||
* keeps the feed tab, from `/music/new` to `/cinema/new`, and "All" leads to
|
||||
* `/~/new`. It renders nothing while the site has no categories.
|
||||
*/
|
||||
export function CategorySwitcher(): React.JSX.Element | null {
|
||||
const { categories } = useCategories();
|
||||
@@ -38,8 +34,8 @@ export function CategorySwitcher(): React.JSX.Element | null {
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Close on outside click / Escape. The menu is portaled out of the wrapper,
|
||||
// so check it separately or selecting an option would close before navigating.
|
||||
// The menu is portaled out of the wrapper, so a click in it would count as
|
||||
// outside and close it before the option's click could navigate.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
@@ -62,7 +58,8 @@ export function CategorySwitcher(): React.JSX.Element | null {
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Anchor the fixed-position menu to the trigger; keep it aligned on scroll.
|
||||
// The menu is fixed and portaled to <body>, so the tab row's
|
||||
// `overflow-x: auto` can't clip it. It must follow the button on scroll.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const update = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The inline editor of a chat message.
|
||||
* Renders the box that replaces a chat message's text while its author, or a
|
||||
* moderator, edits it in the chat modal.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,19 +11,21 @@ import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
/** The props of {@link ChatEditBox}. */
|
||||
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
|
||||
export interface ChatEditBoxProps {
|
||||
/** The message's current text. */
|
||||
initial: string;
|
||||
/** Called with the trimmed new text. */
|
||||
/**
|
||||
* Receives the trimmed text, only when it can be sent. It may be unchanged:
|
||||
* the caller decides whether to send an edit.
|
||||
*/
|
||||
onSave: (body: string) => void;
|
||||
/** Called to leave without saving. */
|
||||
/** Also called on Escape. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an editor over a chat message's text, with Save and Cancel; it takes
|
||||
* focus and scrolls itself into view once its box has settled.
|
||||
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and
|
||||
* Shift+Enter starts a new line, as in the composer.
|
||||
*/
|
||||
export function ChatEditBox(
|
||||
{ initial, onSave, onCancel }: ChatEditBoxProps,
|
||||
@@ -31,9 +34,8 @@ export function ChatEditBox(
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus without letting the browser scroll the list mid-transition (the
|
||||
// message box is still easing to its new height); once it has settled, bring
|
||||
// the whole edit box, Save included, into view.
|
||||
// The message is still easing to its new height, so a scroll on focus would
|
||||
// land short. Scroll once the transition is over, Save button included.
|
||||
useEffect(() => {
|
||||
editorRef.current?.focus({ atEnd: true, preventScroll: true });
|
||||
const box = boxRef.current;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A message in the chat.
|
||||
* Renders one message of the chat modal's list, with its reply, edit and
|
||||
* delete actions.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,41 +21,43 @@ import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
import { clockTime } from "../../utils/chat/time.ts";
|
||||
|
||||
/** The props of {@link ChatMessageItem}. */
|
||||
/**
|
||||
* Passed to {@link ChatMessageItem} by `ChatModal` for each message row. The
|
||||
* modal holds the edit, flash and permission state, and sends the edits and
|
||||
* deletions over the socket.
|
||||
*/
|
||||
export interface ChatMessageItemProps {
|
||||
/** The message. */
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
/**
|
||||
* Set when the message continues its author's run. It then has no avatar or
|
||||
* author line: its time shows in the avatar's place on hover, and its actions
|
||||
* float over its top.
|
||||
*/
|
||||
grouped: boolean;
|
||||
/** Whether it @mentions the user, which highlights it. */
|
||||
/** Highlights a message that @mentions the user or replies to them. */
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
/**
|
||||
* Set when the replied-to message was deleted live, after the server filled
|
||||
* in `replyToAuthor`.
|
||||
*/
|
||||
replyDeleted: boolean;
|
||||
/** Whether the user may edit it. */
|
||||
canEdit: boolean;
|
||||
/** Whether the user may delete it. */
|
||||
canDelete: boolean;
|
||||
/** Whether it is flashed, having just been jumped to. */
|
||||
flashed: boolean;
|
||||
/** Whether it is being edited. */
|
||||
editing: boolean;
|
||||
/** Starts editing the message `id`. */
|
||||
onStartEdit: (id: string) => void;
|
||||
/** Ends the editing. */
|
||||
onEndEdit: () => void;
|
||||
/** Saves `body` as the text of message `id`. */
|
||||
/** Called only when the text changed. */
|
||||
onEdit: (id: string, body: string) => void;
|
||||
/** Deletes message `id`. */
|
||||
/** Called once the user has confirmed. */
|
||||
onDelete: (id: string) => void;
|
||||
/** Replies to `message`. */
|
||||
onReply: (message: ChatMessage) => void;
|
||||
/** Scrolls to message `id`, for the reply reference. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a chat message: its author and time (unless grouped), the reply it
|
||||
* answers, its text or its editor, and its reply, edit and delete actions.
|
||||
* Renders a chat message, or the editor over its text while `editing`. Its
|
||||
* height eases between the two. Deleting asks for confirmation first.
|
||||
*/
|
||||
export function ChatMessageItem(
|
||||
{
|
||||
@@ -136,7 +139,7 @@ export function ChatMessageItem(
|
||||
>
|
||||
{grouped
|
||||
? (
|
||||
// The avatar column holds the time instead, shown on hover.
|
||||
// The avatar column holds the time instead.
|
||||
<span className="chat-message-gutter">
|
||||
<Tooltip text={message.createdAt.toLocaleString()}>
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The live chat, in a modal.
|
||||
* Renders the site-wide chat in a modal, for signed-in users. `ChatProvider`
|
||||
* mounts it while the chat is open. It reads the history over `GET /api/chat`
|
||||
* and the live stream from `WSChatContext`, and writes through the latter.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -37,41 +39,35 @@ import { COUNT_FROM, sendable, snippet } from "../../utils/chat/message.ts";
|
||||
import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
/**
|
||||
* Matches the server's default page size; a full page means there may be more.
|
||||
*/
|
||||
/** The history's page size. A full page means older messages may remain. */
|
||||
const PAGE_SIZE = 50;
|
||||
/**
|
||||
* Resync after a reconnect walks forward from the oldest loaded message in
|
||||
* pages of this size; past the cap it gives up and reloads the latest page.
|
||||
*/
|
||||
/** The page size of the resync after a reconnect, the server's maximum. */
|
||||
const RESYNC_PAGE = 100;
|
||||
/** How many pages a resync walks at most. */
|
||||
const RESYNC_MAX_PAGES = 5;
|
||||
/** Consecutive messages from one author within this window share a header. */
|
||||
const GROUP_WINDOW = 5 * 60_000;
|
||||
/** Relative timestamps ("2 minutes ago") are refreshed this often. */
|
||||
const TICK = 30_000;
|
||||
/** How long a failed edit/delete notice stays up. */
|
||||
const ERROR_TTL = 6_000;
|
||||
/** How close to the bottom still counts as "at the bottom" (px). */
|
||||
const BOTTOM_SLACK = 80;
|
||||
/**
|
||||
* How long the composer stays "typing" after the last keystroke. The server
|
||||
* drops a typer it hasn't heard from in its TYPING_TTL.
|
||||
* How many pages the resync reads before it gives up and reloads the latest
|
||||
* page instead.
|
||||
*/
|
||||
const RESYNC_MAX_PAGES = 5;
|
||||
/** How close in time two messages of one author must be to share a header. */
|
||||
const GROUP_WINDOW = 5 * 60_000;
|
||||
/** How often relative times and day labels are redrawn, in ms. */
|
||||
const TICK = 30_000;
|
||||
/** How long a failed edit or delete stays shown, in ms. */
|
||||
const ERROR_TTL = 6_000;
|
||||
/** How far from the bottom the list still counts as at the bottom, in px. */
|
||||
const BOTTOM_SLACK = 80;
|
||||
/** How long after the last keystroke the user stops "typing", in ms. */
|
||||
const TYPING_STOP_DELAY = 3_500;
|
||||
/**
|
||||
* How often the "typing" signal is refreshed while typing continuously; below
|
||||
* the server's TYPING_TTL, so a steady typist never flickers out.
|
||||
* How often "typing" is resent while the user keeps typing, in ms. It must stay
|
||||
* under the server's `TYPING_TTL` (6s), or a steady typist would flicker out.
|
||||
*/
|
||||
const TYPING_REFRESH = 3_000;
|
||||
|
||||
/** The session's `authFetch`. */
|
||||
type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Returns the chat messages `query` selects from `GET /api/chat`.
|
||||
* Returns the messages `GET /api/chat?<query>` selects, oldest first.
|
||||
*
|
||||
* @throws {Error} When the API answers a failure.
|
||||
*/
|
||||
@@ -85,18 +81,14 @@ async function fetchChat(
|
||||
return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders two messages as the stream does, matching the server's: by time, then
|
||||
* id to break ties.
|
||||
*/
|
||||
/** Orders two messages by time, then by id, as the server pages them. */
|
||||
function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() ||
|
||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the message list: a day separator, the "new" divider, a message, or
|
||||
* a pending message.
|
||||
* A row of the list, which is a day separator, the "new" divider or a message.
|
||||
*/
|
||||
type Row =
|
||||
| { kind: "sep"; key: string; label: string }
|
||||
@@ -109,19 +101,18 @@ type Row =
|
||||
grouped: boolean;
|
||||
};
|
||||
|
||||
/** The props of {@link ChatModal}. */
|
||||
/** Passed to {@link ChatModal} by `ChatProvider`. */
|
||||
export interface ChatModalProps {
|
||||
/** Called to close the chat. */
|
||||
/** Closes the chat, and drops the `?chat=open` deep link if there is one. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the live chat in a modal: the history, loaded a page at a time as it
|
||||
* is scrolled up and resynced after a reconnect, merged with the live stream
|
||||
* and the messages still on their way, grouped by author and split by day, a
|
||||
* "new" divider at the read marker, who is typing, and the composer, which
|
||||
* keeps its draft and reply target. The read marker follows what is scrolled
|
||||
* into view.
|
||||
* Renders the chat, opening on the "new" divider when the unread messages
|
||||
* don't fit, else at the bottom. Reaching the bottom of a visible tab moves the
|
||||
* read marker. The draft and its reply target survive closing and reloading
|
||||
* (`utils/chat/draft.ts`). While it is open in a visible tab, the server sends
|
||||
* the user no notification for the mentions they see live.
|
||||
*/
|
||||
export function ChatModal(
|
||||
{ onClose }: ChatModalProps,
|
||||
@@ -163,31 +154,27 @@ export function ChatModal(
|
||||
);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [flashId, setFlashId] = useState<string | null>(null);
|
||||
// False until the first placement decides: opening onto the "new" divider
|
||||
// must not count as having read down to the bottom.
|
||||
// False until the first placement: opening onto the "new" divider must not
|
||||
// mark everything below it read.
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<TextEditorHandle>(null);
|
||||
// Whether the view is pinned to the bottom — controls auto-scroll on new
|
||||
// messages so reading older history isn't yanked away.
|
||||
// Whether new content scrolls the list down. A ref, as the scroll handler
|
||||
// and the layout effects must see it at once, without a render.
|
||||
const stickToBottomRef = useRef(true);
|
||||
// Set once the first history load has been positioned, so the layout effect
|
||||
// does the initial placement exactly once.
|
||||
const didInitialScrollRef = useRef(false);
|
||||
// Where the view was before older messages were prepended, so the layout
|
||||
// effect can keep the same messages under the reader's eye.
|
||||
// Where the list was before older messages were prepended, so the layout
|
||||
// effect keeps the same messages in view.
|
||||
const prependAnchorRef = useRef<{ height: number; top: number } | null>(
|
||||
null,
|
||||
);
|
||||
// Typing-signal bookkeeping: timer that sends "stopped" after a pause, and
|
||||
// the timestamp of the last "typing" refresh we sent.
|
||||
// lastTypingSentRef is 0 while the server thinks the user isn't typing.
|
||||
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastTypingSentRef = useRef(0);
|
||||
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Relative timestamps and "Today"/"Yesterday" go stale while the chat sits
|
||||
// open; a periodic re-render keeps them honest.
|
||||
// Relative times and "Today" go stale while the chat sits open.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), TICK);
|
||||
@@ -198,13 +185,11 @@ export function ChatModal(
|
||||
writeDraft(userId, { draft, replyTarget });
|
||||
}, [userId, draft, replyTarget]);
|
||||
|
||||
// Focus the composer when the chat opens so you can type straight away.
|
||||
useEffect(() => {
|
||||
composerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Merge fetched history with the live buffer, drop deleted ones, dedupe by id
|
||||
// (live buffer wins so edits show), sort into stream order.
|
||||
// The live copy wins over the fetched one, since it carries later edits.
|
||||
const messages = useMemo(() => {
|
||||
const byId = new Map<string, ChatMessage>();
|
||||
for (const m of history) byId.set(m.id, m);
|
||||
@@ -218,13 +203,12 @@ export function ChatModal(
|
||||
messagesRef.current = messages;
|
||||
});
|
||||
|
||||
// Others (not me) currently composing — drives the typing indicator.
|
||||
// The server's list includes the user.
|
||||
const typingOthers = useMemo(
|
||||
() => typingUsers.filter((u) => u.userId !== userId),
|
||||
[typingUsers, userId],
|
||||
);
|
||||
|
||||
// Initial history load (and its retry).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -248,10 +232,10 @@ export function ChatModal(
|
||||
setLoadAttempt((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
// Whatever happened while the socket was down — new messages, edits,
|
||||
// deletions — never reached the live buffer. After a reconnect, re-read the
|
||||
// stream from the oldest loaded message onward and take it as the truth for
|
||||
// that stretch; if too much happened for that, start over from the latest.
|
||||
// Messages, edits and deletions made while the socket was down never reached
|
||||
// the live buffer. After a reconnect, the stream is read again from the
|
||||
// oldest loaded message on and taken as the truth for that stretch; when
|
||||
// too much happened, the list starts over from the latest page.
|
||||
const seenEpochRef = useRef(connectionEpoch);
|
||||
useEffect(() => {
|
||||
if (connectionEpoch === seenEpochRef.current) return;
|
||||
@@ -261,6 +245,7 @@ export function ChatModal(
|
||||
try {
|
||||
const from = messagesRef.current[0]?.createdAt ?? new Date(0);
|
||||
const collected: ChatMessage[] = [];
|
||||
// No `afterId` yet: the first page includes the message at `from`.
|
||||
let cursor = `after=${encodeURIComponent(from.toISOString())}`;
|
||||
let complete = false;
|
||||
for (let page = 0; page < RESYNC_MAX_PAGES; page++) {
|
||||
@@ -305,8 +290,8 @@ export function ChatModal(
|
||||
};
|
||||
}, [authFetch, connectionEpoch, reconcileChat]);
|
||||
|
||||
// Whether the tab is on screen: a chat left open in a background tab isn't
|
||||
// being read, so its messages should stay unread until the user comes back.
|
||||
// A chat left open in a background tab isn't being read: its messages stay
|
||||
// unread until the user comes back.
|
||||
const [tabVisible, setTabVisible] = useState(() => !document.hidden);
|
||||
useEffect(() => {
|
||||
const onVisibility = () => setTabVisible(!document.hidden);
|
||||
@@ -314,14 +299,12 @@ export function ChatModal(
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, []);
|
||||
|
||||
// Tell the server the chatbox is open so it skips redundant mention
|
||||
// notifications while the user is reading; clear the flag on close.
|
||||
useEffect(() => {
|
||||
setChatOpen(true);
|
||||
return () => setChatOpen(false);
|
||||
}, [setChatOpen]);
|
||||
|
||||
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
|
||||
// Closing mid-sentence must not leave the user "typing" for others.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
|
||||
@@ -330,16 +313,17 @@ export function ChatModal(
|
||||
};
|
||||
}, [setChatTyping]);
|
||||
|
||||
// A failed edit/delete notice clears itself after a while.
|
||||
useEffect(() => {
|
||||
if (!chatError) return;
|
||||
const id = setTimeout(dismissChatError, ERROR_TTL);
|
||||
return () => clearTimeout(id);
|
||||
}, [chatError, dismissChatError]);
|
||||
|
||||
// The read marker as it stood when the chat opened: the "new" divider goes
|
||||
// The read marker as it stood when the chat opened. The "new" divider goes
|
||||
// before the first message from someone else after it, and stays put while
|
||||
// the chat is open even as the marker moves on.
|
||||
// the chat is open, although the marker moves on. The marker may only come
|
||||
// after the chat opens, with the socket's `welcome`, hence the render-time
|
||||
// checks.
|
||||
const [openedReadAt, setOpenedReadAt] = useState(chatReadAt);
|
||||
if (!openedReadAt && chatReadAt) setOpenedReadAt(chatReadAt);
|
||||
const [dividerId, setDividerId] = useState<string | null | undefined>(
|
||||
@@ -352,15 +336,14 @@ export function ChatModal(
|
||||
setDividerId(first?.id ?? null);
|
||||
}
|
||||
|
||||
// What the reader has scrolled past at the bottom, for the "new messages"
|
||||
// pill. Starts at the read marker so unread messages below the divider count.
|
||||
// The newest time the user has seen at the bottom, for the "new messages"
|
||||
// pill. It starts at the read marker, so the unread messages count.
|
||||
const [seenUpTo, setSeenUpTo] = useState(0);
|
||||
if (seenUpTo === 0 && openedReadAt) setSeenUpTo(openedReadAt.getTime());
|
||||
const newest = messages.at(-1);
|
||||
if (atBottom && newest && newest.createdAt.getTime() > seenUpTo) {
|
||||
setSeenUpTo(newest.createdAt.getTime());
|
||||
}
|
||||
// At the bottom of a visible chat, everything is read.
|
||||
useEffect(() => {
|
||||
if (atBottom && newest && tabVisible) markChatRead(newest.createdAt);
|
||||
}, [atBottom, newest, tabVisible, markChatRead]);
|
||||
@@ -372,12 +355,10 @@ export function ChatModal(
|
||||
[messages, userId, seenUpTo],
|
||||
);
|
||||
|
||||
// Keep the view where it belongs as content changes: the first placement
|
||||
// lands on the "new" divider when there's more unread than fits (else the
|
||||
// bottom), prepending older history keeps the same messages in view, and
|
||||
// otherwise new content follows the bottom while pinned there. A layout
|
||||
// effect runs before paint so there's no visible jump, and a follow-up frame
|
||||
// catches late height changes (e.g. an image that loads after first paint).
|
||||
// Places the list before paint as content changes. The first placement waits
|
||||
// for the history and the divider. Prepended history keeps the same
|
||||
// messages in view. Otherwise the list follows the bottom while pinned
|
||||
// there, again one frame later for content that grows after the first paint.
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
@@ -412,9 +393,8 @@ export function ChatModal(
|
||||
const node = listRef.current;
|
||||
if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight;
|
||||
});
|
||||
// `replyTarget` and `chatError` are dependencies because their bars insert
|
||||
// themselves between the list and the composer, shrinking the list: when
|
||||
// pinned we must re-anchor so the last message isn't hidden behind them.
|
||||
// `replyTarget` and `chatError` are dependencies because their bars shrink
|
||||
// the list, which would hide the last message.
|
||||
}, [
|
||||
messages,
|
||||
pendingChatMessages,
|
||||
@@ -426,11 +406,9 @@ export function ChatModal(
|
||||
openedReadAt,
|
||||
]);
|
||||
|
||||
// Follow the bottom through any change in the content's height while pinned
|
||||
// there — not just new messages, but a message growing back after an edit
|
||||
// (frame by frame, through its height transition), an image loading, an
|
||||
// embed unfolding. Without this the list stays put and the end slides out
|
||||
// of view below.
|
||||
// Content also grows with no new message: an edited message easing back to
|
||||
// its height, an image loading, an embed unfolding. While pinned, the list
|
||||
// follows it frame by frame.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
@@ -483,14 +461,14 @@ export function ChatModal(
|
||||
setHistory((prev) => [...older, ...prev]);
|
||||
}
|
||||
} catch {
|
||||
// ignore — user can retry
|
||||
// The button stays, to try again.
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [authFetch, messages, loadingOlder]);
|
||||
|
||||
// Announce that the user is composing, throttled, and schedule a "stopped"
|
||||
// signal once they pause. Called on every keystroke via the composer.
|
||||
// Called on every keystroke: "typing" goes out at most every TYPING_REFRESH,
|
||||
// and "stopped" after a pause of TYPING_STOP_DELAY.
|
||||
const pingTyping = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastTypingSentRef.current > TYPING_REFRESH) {
|
||||
@@ -530,8 +508,8 @@ export function ChatModal(
|
||||
composerRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Scroll a referenced message into view and flash it briefly. No-op if it's
|
||||
// not currently loaded (older history the user hasn't paged in).
|
||||
// Does nothing when the message isn't loaded: older history isn't fetched to
|
||||
// reach it.
|
||||
const jumpToMessage = useCallback((id: string) => {
|
||||
const node = listRef.current?.querySelector<HTMLElement>(
|
||||
`[data-mid="${CSS.escape(id)}"]`,
|
||||
@@ -553,8 +531,8 @@ export function ChatModal(
|
||||
stickToBottomRef.current = true;
|
||||
}, [draft, replyTarget, sendChatMessage, stopTyping]);
|
||||
|
||||
// Take a failed message back into the composer to rework it. An unfinished
|
||||
// draft already there is kept, with the failed text after it.
|
||||
// An unfinished draft already in the composer is kept, the failed text after
|
||||
// it.
|
||||
const restorePending = useCallback((pending: PendingChatMessage) => {
|
||||
setDraft((prev) =>
|
||||
prev.trim() ? `${prev}\n\n${pending.body}` : pending.body
|
||||
@@ -564,9 +542,8 @@ export function ChatModal(
|
||||
composerRef.current?.focus();
|
||||
}, [discardChatMessage]);
|
||||
|
||||
// An edit swaps a message's text for a box of a different height, and the
|
||||
// edit box scrolls itself into view: whether the list was following the
|
||||
// bottom when the edit began is remembered, so ending it returns there.
|
||||
// The edit box changes the message's height and scrolls itself into view,
|
||||
// which can unpin the list. Ending the edit pins it again if it was pinned.
|
||||
const editPinnedRef = useRef(false);
|
||||
const startEdit = useCallback((id: string) => {
|
||||
editPinnedRef.current = stickToBottomRef.current;
|
||||
@@ -584,7 +561,7 @@ export function ChatModal(
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === "Escape" && replyTarget) {
|
||||
// Cancel the reply first; a second Escape closes the modal.
|
||||
// Only cancels the reply: a second Escape closes the modal.
|
||||
e.preventDefault();
|
||||
setReplyTarget(null);
|
||||
} else if (e.key === "ArrowUp" && !draft) {
|
||||
@@ -601,9 +578,8 @@ export function ChatModal(
|
||||
const deletedIds = deletedChatIds;
|
||||
const username = user?.username;
|
||||
|
||||
// Build the rendered stream: day separators whenever the calendar day
|
||||
// changes, the "new" divider, and consecutive messages from one author run
|
||||
// together under a single header. Pending sends go last.
|
||||
// A reply, a day separator or the "new" divider starts a new header. Pending
|
||||
// messages go last, after the ones the server has.
|
||||
const rows = useMemo(() => {
|
||||
const out: Row[] = [];
|
||||
let prev: { userId: string; at: Date } | null = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A chat message on its way.
|
||||
* Renders a message the user sent to the chat that the server hasn't echoed
|
||||
* back yet, at the end of the chat modal's list.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,27 +13,26 @@ import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
|
||||
/** The props of {@link PendingItem}. */
|
||||
/**
|
||||
* Passed to {@link PendingItem} by `ChatModal` for each of `WSChatContext`'s
|
||||
* `pendingChatMessages`.
|
||||
*/
|
||||
export interface PendingItemProps {
|
||||
/** The message. */
|
||||
pending: PendingChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
/** Set when it continues the user's run, which hides the avatar and name. */
|
||||
grouped: boolean;
|
||||
/** The sender: the user. */
|
||||
/** The signed-in user, who sent it. */
|
||||
user: PublicUser;
|
||||
/** Resends message `nonce`. */
|
||||
onRetry: (nonce: string) => void;
|
||||
/** Drops message `nonce`. */
|
||||
onDiscard: (nonce: string) => void;
|
||||
/** Takes the message back into the composer. */
|
||||
/** Takes the failed text back into the composer, and discards it. */
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
/** Scrolls to message `id`, for the reply reference. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a message on its way: dimmed while sending; if it fails, it says why
|
||||
* and offers to try again, take it back into the composer, or drop it.
|
||||
* Renders a pending message, dimmed while it is sending. Once it has failed,
|
||||
* it shows why and offers to retry it, edit it in the composer, or discard it.
|
||||
*/
|
||||
export function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A chat message's reference to the message it replies to.
|
||||
* Renders the line above a chat message, sent or pending, that quotes the
|
||||
* message it replies to.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,21 +8,23 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { snippet } from "../../utils/chat/message.ts";
|
||||
|
||||
/** The props of {@link ReplyRef}. */
|
||||
/**
|
||||
* Passed to {@link ReplyRef} by `ChatMessageItem`, from the message's
|
||||
* `replyTo*` fields, and by `PendingItem`, from its `replyTo`.
|
||||
*/
|
||||
export interface ReplyRefProps {
|
||||
/** The replied-to message's id. */
|
||||
replyToId: string;
|
||||
/** Its author; undefined when it was deleted. */
|
||||
/** Undefined when the replied-to message was deleted. */
|
||||
author?: string;
|
||||
/** Its text. */
|
||||
/** Possibly cut short by the server, as `replyToBody` is. */
|
||||
body?: string;
|
||||
/** Scrolls to message `id`. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a subtle one-line reference to the replied-to message; clicking jumps
|
||||
* to it. Falls back to a muted note when the target was deleted.
|
||||
* Renders the replied-to message's author and a one-line snippet of its text,
|
||||
* as a button that scrolls to it. When it was deleted, the button is disabled
|
||||
* and says so.
|
||||
*/
|
||||
export function ReplyRef(
|
||||
{ replyToId, author, body, onJumpTo }: ReplyRefProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The comment editor, for posting, replying and editing.
|
||||
* Renders the form of a dump's comment thread that posts a comment, a reply,
|
||||
* or an edit.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,37 +13,43 @@ import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The props of {@link CommentForm}. */
|
||||
/**
|
||||
* Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by
|
||||
* `CommentNode`, for a reply or an edit.
|
||||
*/
|
||||
export interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
/**
|
||||
* Sends the text. The form empties itself once it resolves. When it throws,
|
||||
* as `expectOk` does, the form keeps the text and shows the error under
|
||||
* `errorTitle`.
|
||||
*/
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
/** The submit button's label. */
|
||||
submitLabel: React.ReactNode;
|
||||
/** The submit button's label while submitting. */
|
||||
pendingLabel: React.ReactNode;
|
||||
/** The title of the error shown when the submit fails. */
|
||||
errorTitle: string;
|
||||
/** The editor's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The initial text; empty by default. */
|
||||
defaultValue?: string;
|
||||
/** Called by the Cancel button; no Cancel button without it. */
|
||||
/** Without it, the form has no Cancel button. */
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
/**
|
||||
* Shows Cancel only once there is text to throw away, and doesn't cancel on
|
||||
* Escape. For the new-comment form, which has nothing to close.
|
||||
*/
|
||||
cancelWhenEmpty?: boolean;
|
||||
/** Forwarded to the editor so callers can focus it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
/**
|
||||
* `top` puts `leading` beside the editor, for the new-comment form. `inline`
|
||||
* is for replies and edits, inside a comment.
|
||||
*/
|
||||
layout?: "inline" | "top";
|
||||
/** Shown before the editor in the `top` layout (the user's avatar). */
|
||||
leading?: React.ReactNode;
|
||||
/** An extra class for the form. */
|
||||
/** Applies to the `inline` layout only. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
* Renders a comment editor and its submit button, disabled while the text is
|
||||
* blank or longer than `VALIDATION.COMMENT_BODY_MAX`.
|
||||
*/
|
||||
export function CommentForm({
|
||||
onSubmit,
|
||||
@@ -84,8 +91,8 @@ export function CommentForm({
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void submit()}
|
||||
// Escape backs out of a reply or an edit; the top-level box has
|
||||
// nothing to back out of.
|
||||
// Escape closes a reply or an edit. The new-comment form has
|
||||
// nothing to close.
|
||||
onCancel={onCancel && !cancelWhenEmpty
|
||||
? () => {
|
||||
form.reset({ body: "" });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A comment in the thread, with its replies.
|
||||
* Renders a comment of a dump's thread and, recursively, its replies.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -31,31 +31,31 @@ import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
|
||||
/** The props of {@link CommentNode}. */
|
||||
/**
|
||||
* Passed to {@link CommentNode} by `CommentThread` for each top-level comment,
|
||||
* and by `CommentNode` for each reply. The callbacks are `CommentThread`'s,
|
||||
* passed down unchanged.
|
||||
*/
|
||||
export interface CommentNodeProps {
|
||||
/** The comment. */
|
||||
comment: Comment;
|
||||
/** Every comment's replies, by parent id. */
|
||||
/** The replies of every comment, by parent id, from `CommentThread`. */
|
||||
tree: Map<string, Comment[]>;
|
||||
/** How deep it is nested. */
|
||||
/** Sets the replies' `--depth`: the deeper, the narrower their indent. */
|
||||
depth: number;
|
||||
/** The id of the dump it is under. */
|
||||
dumpId: string;
|
||||
/** The signed-in user, null when signed out. */
|
||||
currentUser: SelfUser | null;
|
||||
/** The session token. */
|
||||
/** Only checked for being set before a delete. */
|
||||
token: string | null;
|
||||
/** Called with a reply posted. */
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
/** Called with the id of a comment deleted. */
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
/** Called with a comment edited. */
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment — its author, time, text and like button, with reply, edit
|
||||
* and delete for whoever may — and under it its replies, recursively.
|
||||
* Renders a comment with its like button, and the reply, edit and delete
|
||||
* actions for whoever may use them. Its author and moderators may edit and
|
||||
* delete it. A deleted comment shows as "[deleted]", with its replies. The
|
||||
* element's id, `comment-<id>`, is the anchor notifications link to.
|
||||
*/
|
||||
export function CommentNode({
|
||||
comment,
|
||||
@@ -109,7 +109,7 @@ export function CommentNode({
|
||||
}
|
||||
|
||||
async function handleEditSave(body: string) {
|
||||
// Saving it unchanged would only stamp it "edited".
|
||||
// Saving it unchanged would only mark it "edited".
|
||||
if (body === comment.body) {
|
||||
setEditOpen(false);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A dump's comment thread.
|
||||
* Renders the comments under a dump on its page, `/dumps/:dumpId`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,27 +18,28 @@ import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
import { CommentNode } from "./CommentNode.tsx";
|
||||
|
||||
/** The props of {@link CommentThread}. */
|
||||
/**
|
||||
* Passed to {@link CommentThread} by the `Dump` page, which holds the comments.
|
||||
* The page applies the changes this user makes through the callbacks, and the
|
||||
* ones others make from `WSContext`'s `lastCommentEvent`, so a change can come
|
||||
* both ways and must be applied once.
|
||||
*/
|
||||
export interface CommentThreadProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Every comment on the dump. */
|
||||
/**
|
||||
* Every comment on the dump, flat and oldest first, deleted ones included so
|
||||
* their replies keep their place.
|
||||
*/
|
||||
comments: Comment[];
|
||||
/** The signed-in user, null when signed out. */
|
||||
currentUser: SelfUser | null;
|
||||
/** The session token. */
|
||||
token: string | null;
|
||||
/** Called with a comment posted. */
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
/** Called with the id of a comment deleted. */
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
/** Called with a comment edited. */
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the replies of each comment of `comments`, by parent id (`root` for
|
||||
* the top level).
|
||||
* Returns each comment's replies by parent id, under `root` for the top level.
|
||||
*/
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
@@ -51,8 +52,8 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that posts a comment (for a signed-in user), and the thread
|
||||
* under it.
|
||||
* Renders the comment count, the form that posts a comment when someone is
|
||||
* signed in, and the tree of {@link CommentNode}.
|
||||
*/
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A dump, as a card in the hot and new feeds.
|
||||
* Renders the card that shows a dump in the feeds, search results, profiles
|
||||
* and playlists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,32 +26,28 @@ import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
/** The props of {@link DumpCard}. */
|
||||
/**
|
||||
* Passed to {@link DumpCard} by each list of dumps. The vote state comes from
|
||||
* the parent, usually `WSContext`'s passed down through the feed's props.
|
||||
*/
|
||||
export interface DumpCardProps {
|
||||
/** The dump. */
|
||||
dump: Dump;
|
||||
/** Its live vote count. */
|
||||
/** The socket's live count if it has one, else `dump.voteCount`. */
|
||||
voteCount: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether the user may vote (signed in). */
|
||||
canVote: boolean;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether the viewer posted it: no unread dot. */
|
||||
/** Hides the unread dot and shows the "private" badge. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dump as a card: its preview (playable where it can be), title,
|
||||
* note, categories, poster, age, comment count and vote button, with an unread
|
||||
* dot until it is opened. The card opens the dump. Memoized: a feed re-renders
|
||||
* on every live event (a vote anywhere, a new dump), and a card whose props
|
||||
* haven't changed has nothing to redraw.
|
||||
* Renders a dump as a card that opens the dump when clicked. A ctrl-click or
|
||||
* middle click opens it in a new tab (`useDumpCardLink`). The card is an
|
||||
* `<li>`, for a `<ul>`. It is memoized because feeds re-render on every live
|
||||
* event, so its vote callbacks must be stable (`WSContext`'s are).
|
||||
*/
|
||||
export const DumpCard: React.MemoExoticComponent<
|
||||
(props: DumpCardProps) => React.JSX.Element
|
||||
@@ -64,6 +61,7 @@ export const DumpCard: React.MemoExoticComponent<
|
||||
const dumpCategories = (dump.categoryIds ?? [])
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c) => c !== undefined);
|
||||
// Posted in the last 7 days and never opened on this device.
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
@@ -74,6 +72,7 @@ export const DumpCard: React.MemoExoticComponent<
|
||||
{...cardProps}
|
||||
>
|
||||
<div className="dump-card-left">
|
||||
{/* Play buttons must not open the dump, unless ctrl/middle-clicked. */}
|
||||
<div
|
||||
className="dump-card-preview"
|
||||
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
||||
@@ -95,6 +94,7 @@ export const DumpCard: React.MemoExoticComponent<
|
||||
: <ThumbnailPlaceholder url={dump.url} />}
|
||||
</div>
|
||||
|
||||
{/* Voting must not open the dump. */}
|
||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||
<VoteButton
|
||||
dumpId={dump.id}
|
||||
@@ -112,6 +112,7 @@ export const DumpCard: React.MemoExoticComponent<
|
||||
to={dumpUrl(dump)}
|
||||
className="dump-card-title"
|
||||
onClick={(e) => {
|
||||
// Or the card's handler navigates a second time.
|
||||
e.stopPropagation();
|
||||
markDumpVisited(dump.id);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The preview of an uploaded file: an image, a player, a PDF or a download
|
||||
* link.
|
||||
* Shows an uploaded file by its type, in a card or on a dump's page. Audio and
|
||||
* video play in the global player or inline.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -32,24 +32,26 @@ import {
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/** The props of {@link FilePreview}. */
|
||||
/**
|
||||
* Passed to {@link FilePreview} by `DumpCard` (`compact`), by `Dump` (`global`)
|
||||
* and by `DumpEdit` (neither).
|
||||
*/
|
||||
export interface FilePreviewProps {
|
||||
/** The file dump. */
|
||||
/**
|
||||
* Expected to be a file dump. A link dump has no `fileMime`, so it shows as a
|
||||
* download.
|
||||
*/
|
||||
dump: Dump;
|
||||
/** Whether to render the small thumbnail of a card. */
|
||||
/** Shows a card's thumbnail, whose play button uses the global player. */
|
||||
compact?: boolean;
|
||||
/** Whether audio and video play in the global player rather than inline. */
|
||||
/** Plays full-size audio and video in the global player, not inline. */
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the global-player item for an uploaded file, so every entry point
|
||||
* (compact cards, the detail page, the waveform) puts the same title, artwork
|
||||
* and subtitle in the player header.
|
||||
*
|
||||
* Artwork is only claimed when one actually exists: videos get their generated
|
||||
* still and any dump can carry an uploaded thumbnail, but a bare audio file has
|
||||
* neither, and pointing at the endpoint anyway would just render a placeholder.
|
||||
* Returns the global player's item for an uploaded file, the same from a card
|
||||
* and from the dump's page. A bare audio file gets no artwork URL, since the
|
||||
* thumbnail endpoint has nothing to serve for it.
|
||||
*/
|
||||
function filePlayerItem(
|
||||
dump: Dump,
|
||||
@@ -70,9 +72,8 @@ function filePlayerItem(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the waveform preview for the dump detail page — routes to the global
|
||||
* player, reflects live play state and position from PlayerContext and
|
||||
* PlayerTimeContext.
|
||||
* Renders an audio file on its dump's page as a waveform that plays in the
|
||||
* global player and shows its progress there.
|
||||
*/
|
||||
function AudioFilePreview(
|
||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||
@@ -112,8 +113,8 @@ function AudioFilePreview(
|
||||
if (isActive) {
|
||||
seekTo(ratio * duration);
|
||||
} else {
|
||||
// Start playing and seek once it loads — seekTo after play() is a no-op
|
||||
// until MediaPlayer mounts; the fraction is best-effort on first click
|
||||
// The player has no media to seek yet, so a first click plays from the
|
||||
// start and ignores where it landed.
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}
|
||||
};
|
||||
@@ -202,9 +203,10 @@ function mimeIcon(mime: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an uploaded file by its type: compact, a thumbnail or icon that plays
|
||||
* it; full size, the image, a player (or a play button for the global player),
|
||||
* the PDF, or a download link.
|
||||
* Renders an uploaded file by its MIME type. Compact, it is a thumbnail or an
|
||||
* icon. Full size, it is the image, a player, the embedded PDF, or a download
|
||||
* link for anything else. File URLs carry the viewer's media token, so private
|
||||
* files load too.
|
||||
*/
|
||||
export function FilePreview(
|
||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||
@@ -220,8 +222,7 @@ export function FilePreview(
|
||||
|
||||
if (compact) {
|
||||
if (mime.startsWith("image/")) {
|
||||
// The server's preview (or the custom thumbnail): a card doesn't need
|
||||
// the full upload.
|
||||
// A card needs the server's scaled-down copy, not the full upload.
|
||||
return (
|
||||
<img
|
||||
src={dumpThumbnailUrl(dump, token)}
|
||||
@@ -244,6 +245,7 @@ export function FilePreview(
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump, as a click on the card does.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
@@ -265,6 +267,7 @@ export function FilePreview(
|
||||
isPlaying ? " is-playing" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump, as a click on the card does.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A dump, as a tile of the journal's mosaic.
|
||||
* Renders a dump as a tile of the "journal" tab's mosaic.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -31,30 +31,29 @@ import {
|
||||
usePlayRichContent,
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
|
||||
/** The props of {@link JournalCard}. */
|
||||
/**
|
||||
* Passed to {@link JournalCard} by `JournalFeed`, with the vote state `Index`
|
||||
* takes from `WSContext`, as for `DumpCard`.
|
||||
*/
|
||||
export interface JournalCardProps {
|
||||
/** The dump. */
|
||||
dump: Dump;
|
||||
/** The tile's footprint in the mosaic. */
|
||||
/** From `composeJournal`. It sets the tile's grid span, not its look. */
|
||||
shape: JournalShape;
|
||||
/** Its live vote count. */
|
||||
/** The socket's live count if it has one, else `dump.voteCount`. */
|
||||
voteCount: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether the user may vote (signed in). */
|
||||
canVote: boolean;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** Whether the viewer posted it: no unread dot. */
|
||||
/** Hides the unread dot and shows the "private" badge. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dump as a mosaic tile — an image card, a pull-quote or a text card,
|
||||
* by its content. A playable card plays; one that can't opens the dump.
|
||||
* Memoized, like DumpCard: live events re-render the feed, not every card.
|
||||
* Renders a dump as an image, a pull-quote or a text tile, depending on
|
||||
* whether it has a thumbnail or a note. Clicking an image tile plays its media
|
||||
* when it can, and any other tile opens the dump. It is memoized like
|
||||
* `DumpCard`, so its vote callbacks must be stable.
|
||||
*/
|
||||
export const JournalCard: React.MemoExoticComponent<
|
||||
(props: JournalCardProps) => React.JSX.Element
|
||||
@@ -68,16 +67,14 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
||||
// page with no streams and no embed to fall back to — the card must still do
|
||||
// what an unplayable one does rather than swallowing the click.
|
||||
// Playback can still fail, on a Bandcamp page with no streams and no embed,
|
||||
// and the click must then open the dump rather than do nothing.
|
||||
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
||||
if (!await playRichContent(rc, dumpUrl(dump))) go();
|
||||
}
|
||||
|
||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||
// still here too, rather than degrading to a text card with a 🎬. An image
|
||||
// shows the server's scaled-down preview, not the full upload.
|
||||
// Like FilePreview's compact mode: a video shows the server's still and an
|
||||
// image its scaled-down copy, not the full upload.
|
||||
const thumbnailUrl = dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, mediaToken)
|
||||
: dump.kind === "file"
|
||||
@@ -87,9 +84,6 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
: null)
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
// Content mode is independent of grid footprint: a thumbnailed dump reads as
|
||||
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and
|
||||
// everything else falls back to a typographic text card.
|
||||
const mode: "image" | "quote" | "text" = hasThumbnail(dump) && thumbnailUrl
|
||||
? "image"
|
||||
: hasQuote(dump)
|
||||
@@ -106,9 +100,8 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
: "🔗";
|
||||
|
||||
const richContent = dump.richContent;
|
||||
// In native mode a Bandcamp page is playable even with no stored embedUrl.
|
||||
// The card's own thumbnail (dump upload or provider) becomes the player's
|
||||
// header artwork.
|
||||
// The tile's thumbnail, the dump's own before the provider's, becomes the
|
||||
// player's artwork.
|
||||
const playable = richContent && canPlayRichContent(richContent)
|
||||
? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined }
|
||||
: null;
|
||||
@@ -118,6 +111,7 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
to={dumpUrl(dump)}
|
||||
className="journal-card-title"
|
||||
onClick={(e) => {
|
||||
// Or the tile's own handler would navigate, or play, as well.
|
||||
e.stopPropagation();
|
||||
markDumpVisited(dump.id);
|
||||
}}
|
||||
@@ -152,6 +146,7 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
);
|
||||
|
||||
const vote = (
|
||||
// Voting must not open the dump.
|
||||
<div
|
||||
className="journal-card-vote"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -177,10 +172,9 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
const className = `journal-card journal-card--${shape} journal-card--${mode}`;
|
||||
|
||||
if (mode === "image") {
|
||||
// feature shows its note; tall/wide/square keep the image as the subject.
|
||||
// Only a feature tile has room for the note over its image.
|
||||
const showComment = shape === "feature" && !!dump.comment;
|
||||
// For playable media the whole card plays; the title link still navigates.
|
||||
// Keeping the handler on the <li> means the text overlay can't cover it.
|
||||
// The handler sits on the <li>, so the text overlay can't block it.
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
@@ -195,8 +189,8 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
<Thumbnail
|
||||
src={thumbnailUrl ?? undefined}
|
||||
placeholder={dump.kind === "file"
|
||||
// No ffmpeg on the host means no still — the mime glyph says more
|
||||
// about the dump than an initial taken from its filename.
|
||||
// A video still fails without ffmpeg on the server, and the MIME
|
||||
// glyph says more than an initial taken from the file name.
|
||||
? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
|
||||
: {
|
||||
url: dump.url,
|
||||
@@ -239,7 +233,6 @@ export const JournalCard: React.MemoExoticComponent<
|
||||
);
|
||||
}
|
||||
|
||||
// text
|
||||
return (
|
||||
<li className={className} {...cardProps}>
|
||||
<span className="journal-card-glyph" aria-hidden="true">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A link's preview card.
|
||||
* Shows a link's preview, as scraped by the server, in a card or on a dump's
|
||||
* page, and plays it in the global player when it can.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,22 +15,27 @@ import {
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
|
||||
/** The props of {@link RichContentCard}. */
|
||||
/**
|
||||
* Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`,
|
||||
* `DumpEdit` and `DumpCreateModal`, the last two without a dump to play from.
|
||||
*/
|
||||
export interface RichContentCardProps {
|
||||
/** The link's preview. */
|
||||
richContent: RichContent;
|
||||
/** Whether to render the small thumbnail of a card. */
|
||||
/** Shows a card's thumbnail alone, as a play button or a link out. */
|
||||
compact?: boolean;
|
||||
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
|
||||
/**
|
||||
* The dump's uploaded thumbnail, shown instead of the provider's and used as
|
||||
* the player's artwork.
|
||||
*/
|
||||
thumbnailOverrideUrl?: string;
|
||||
/** Link back to the owning dump, surfaced as the global player's title. */
|
||||
/** The dump's page, which the global player's title links to. */
|
||||
dumpHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a link's preview: its thumbnail (or a placeholder), which plays it in
|
||||
* the global player when it can be played and otherwise opens the link, and
|
||||
* full size its site, title and description.
|
||||
* Renders a link's preview. Its thumbnail plays the media in the global player
|
||||
* when it can be played, and otherwise opens the link in a new tab. Full size,
|
||||
* it also shows the site, title and description, linking out.
|
||||
*/
|
||||
export function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
@@ -40,10 +46,9 @@ export function RichContentCard(
|
||||
|
||||
const canPlay = canPlayRichContent(richContent);
|
||||
const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
|
||||
// The dump's own thumbnail overrides the provider's, in the player header too.
|
||||
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
|
||||
// Nothing to play and no embed to fall back to: the card body is a link to
|
||||
// the source, so the thumbnail behaves like one rather than going dead.
|
||||
// When playback fails, the thumbnail opens the source like the card's body
|
||||
// does, rather than doing nothing.
|
||||
const playOrOpen = async () => {
|
||||
if (!await playRichContent(playable, dumpHref)) {
|
||||
globalThis.open(richContent.url, "_blank", "noopener,noreferrer");
|
||||
@@ -85,6 +90,7 @@ export function RichContentCard(
|
||||
}${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={(e) => {
|
||||
// Playing must not open the dump the card links to.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// The button says "Pause" once this card is the one playing, so it
|
||||
@@ -110,6 +116,7 @@ export function RichContentCard(
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rich-content-compact"
|
||||
// Opens the source, not the dump the card links to.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={richContent.siteName
|
||||
? t`Open on ${richContent.siteName}`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The dump creation modal.
|
||||
* The modal that posts a dump from a link or a file, then offers to add it to
|
||||
* the poster's playlists. Every "new dump" button opens it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -57,16 +58,15 @@ import { LocalFilePreview } from "./LocalFilePreview.tsx";
|
||||
import { type Step, StepIndicator } from "./StepIndicator.tsx";
|
||||
import { TitleField } from "./TitleField.tsx";
|
||||
|
||||
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
||||
/** Kept stable so `DuplicateNotice` doesn't re-render on every keystroke. */
|
||||
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
||||
|
||||
/** The link's preview, as fetched for the form. */
|
||||
/** `reached` is false when the server couldn't fetch the page at all. */
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "done"; reached: boolean; richContent: RichContent | null };
|
||||
|
||||
/** The form's fields. */
|
||||
interface CreateValues {
|
||||
url: string;
|
||||
file: File | null;
|
||||
@@ -76,31 +76,34 @@ interface CreateValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the pointer is coarse — a touch device, where autofocusing the URL
|
||||
* input raises a keyboard that covers most of the modal before the user has
|
||||
* seen it.
|
||||
* Checks whether the pointer is coarse. On a touch device, focusing a field
|
||||
* raises a keyboard that covers most of the modal before the user has seen it.
|
||||
*/
|
||||
function prefersNoAutoFocus(): boolean {
|
||||
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
}
|
||||
|
||||
/** The props of {@link DumpCreateModal}. */
|
||||
/**
|
||||
* Passed to {@link DumpCreateModal} by the button that opens it. The parent
|
||||
* unmounts the modal on `onClose`, which the modal also calls after a
|
||||
* confirmed dismissal.
|
||||
*/
|
||||
export interface DumpCreateModalProps {
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A link to start from (the Android share sheet); it replaces any saved
|
||||
* draft.
|
||||
* A link shared from Android's share sheet, which `AppHeader` passes on from
|
||||
* `Index`. It replaces any saved draft.
|
||||
*/
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the modal that posts a dump, in three panels: the link (previewed as
|
||||
* it is typed, with a hint when it was already dumped) or the file, and the
|
||||
* title; the note, categories and visibility; then, once posted, the playlists
|
||||
* to add it to. Everything but the file is kept as a draft until it is posted,
|
||||
* and closing with unsaved work asks first.
|
||||
* Renders the dump creation modal. The first panel takes a link, previewed
|
||||
* through `GET /api/preview` and checked against `GET /api/dumps/by-url`, or a
|
||||
* file. The second takes the "why", categories and visibility, and posts to
|
||||
* `POST /api/dumps`. The third adds the new dump to playlists. The new dump is
|
||||
* handed to `WSContext`'s `injectDump` so the feeds show it at once. Everything
|
||||
* but the file is saved as a draft (`utils/dump/draft.ts`) until it is posted.
|
||||
*/
|
||||
export function DumpCreateModal(
|
||||
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
||||
@@ -109,7 +112,6 @@ export function DumpCreateModal(
|
||||
const { injectDump } = useWS();
|
||||
|
||||
const [step, setStep] = useState<Step>("what");
|
||||
// Which way the next panel should come in from. Purely presentational.
|
||||
const [direction, setDirection] = useState<"forward" | "back">("forward");
|
||||
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
||||
|
||||
@@ -128,17 +130,18 @@ export function DumpCreateModal(
|
||||
const draft = restoredDraft ?? EMPTY_DUMP_DRAFT;
|
||||
const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft);
|
||||
|
||||
// Composition state
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser);
|
||||
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
|
||||
status: "idle",
|
||||
});
|
||||
// Tagged with the URL they describe, so a result never lingers over the next
|
||||
// link during the debounce (and so clearing them needs no effect).
|
||||
// link during the debounce, and clearing them needs no effect.
|
||||
const [duplicateState, setDuplicateState] = useState<
|
||||
{ url: string; matches: DumpUrlMatch[] }
|
||||
>({ url: "", matches: [] });
|
||||
// Undefined when no upload runs, null once the body is sent and the server
|
||||
// is still processing it, else the fraction sent.
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>();
|
||||
const [confirmClose, setConfirmClose] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -177,7 +180,6 @@ export function DumpCreateModal(
|
||||
return next;
|
||||
});
|
||||
|
||||
// Playlist step state
|
||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
@@ -190,10 +192,9 @@ export function DumpCreateModal(
|
||||
setTitleEditing(false);
|
||||
}, [setValue]);
|
||||
|
||||
// Canonical form of `url`, used (rather than the raw input text) to decide
|
||||
// whether the preview actually needs to re-fetch — e.g. "example.com" and
|
||||
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
|
||||
// to the same value, so they shouldn't trigger two separate fetches.
|
||||
// The effects below key on this rather than on the raw input, so that
|
||||
// "example.com" and the "https://example.com" onBlur rewrites it to don't
|
||||
// fetch the preview twice.
|
||||
let canonicalUrl: string | null;
|
||||
try {
|
||||
const u = new URL(normalizeUrl(url));
|
||||
@@ -204,8 +205,8 @@ export function DumpCreateModal(
|
||||
canonicalUrl = null;
|
||||
}
|
||||
|
||||
// Idle state is derived below from canonicalUrl rather than set here, so
|
||||
// this effect only ever needs to set loading/done (inside the timeout).
|
||||
// Deriving "idle" here spares the effect below from resetting the state when
|
||||
// the link is cleared.
|
||||
const urlPreview: UrlPreview = canonicalUrl
|
||||
? urlFetchState
|
||||
: { status: "idle" };
|
||||
@@ -214,20 +215,18 @@ export function DumpCreateModal(
|
||||
? duplicateState.matches
|
||||
: NO_DUPLICATES;
|
||||
|
||||
// Debounced preview + "already dumped?" lookup. Both answer questions about
|
||||
// the same link and settle at the same time, so they share one debounce.
|
||||
// The preview and the duplicate lookup concern the same link, so they share
|
||||
// one debounce.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!canonicalUrl) return;
|
||||
|
||||
// Deferred to a microtask (rather than called directly) so the immediate
|
||||
// "loading" feedback doesn't fire synchronously within the effect body —
|
||||
// same timing for the user, just not in the same tick.
|
||||
// Deferred: the set-state-in-effect lint rule forbids setting state
|
||||
// synchronously in an effect.
|
||||
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
|
||||
|
||||
// A preview can take seconds (the server fetches the page), so the link
|
||||
// may have changed by the time one lands: the answer for the old link must
|
||||
// not show, or suggest its title, under the new one.
|
||||
// A preview can take seconds, since the server fetches the page. An answer
|
||||
// for a link that has since changed must not show, or set the title.
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
@@ -254,8 +253,7 @@ export function DumpCreateModal(
|
||||
});
|
||||
});
|
||||
|
||||
// Signed in, so that a private dump of the poster's own counts as a
|
||||
// duplicate for them and for nobody else.
|
||||
// Signed in, so that the poster's own private dumps count as duplicates.
|
||||
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
@@ -280,9 +278,8 @@ export function DumpCreateModal(
|
||||
};
|
||||
}, [canonicalUrl, authFetch]);
|
||||
|
||||
// Until the poster takes the field over, the title *is* the current page's
|
||||
// title — including when the new link suggests nothing, which is why this
|
||||
// writes an empty suggestion too rather than leaving the previous link's.
|
||||
// Until the poster edits it, the title follows the preview. An empty
|
||||
// suggestion is written too, or the previous link's title would stay.
|
||||
const suggestedTitle = urlPreview.status === "done"
|
||||
? urlPreview.richContent?.title ?? ""
|
||||
: "";
|
||||
@@ -291,8 +288,7 @@ export function DumpCreateModal(
|
||||
setValue("title", suggestedTitle);
|
||||
}, [mode, titleFromUser, suggestedTitle, setValue]);
|
||||
|
||||
// Mirror the draft on every change so a dismissal — deliberate or not — never
|
||||
// costs more than the attached file, which cannot be stored.
|
||||
// Once posted, the draft is cleared and must not be written back.
|
||||
useEffect(() => {
|
||||
if (step === "playlist") return;
|
||||
saveDumpDraft({
|
||||
@@ -328,34 +324,30 @@ export function DumpCreateModal(
|
||||
setDraftNoticeVisible(false);
|
||||
};
|
||||
|
||||
// Land the caret in the "why" box on arrival, so the second panel is
|
||||
// immediately typeable — except on touch, where it would raise a keyboard
|
||||
// over the categories this panel exists to show.
|
||||
// Not on touch, where the keyboard would cover the categories.
|
||||
useEffect(() => {
|
||||
if (step !== "details" || prefersNoAutoFocus()) return;
|
||||
commentEditorRef.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
// Only the attached file is unrecoverable, so it is the only thing worth
|
||||
// interrupting a dismissal for — the text comes back on its own.
|
||||
// Only the attached file is lost on close, since the draft keeps the rest,
|
||||
// so only a file makes the modal ask first.
|
||||
const handleBeforeClose = useCallback(() => {
|
||||
if (step === "playlist" || submitting || !file || confirmClose) return true;
|
||||
setConfirmClose(true);
|
||||
return false;
|
||||
}, [step, submitting, file, confirmClose]);
|
||||
|
||||
/** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */
|
||||
// Cancel asks first too, like Escape and the ✕.
|
||||
const requestClose = () => {
|
||||
if (handleBeforeClose()) onClose();
|
||||
};
|
||||
|
||||
// Paste handler
|
||||
// A paste anywhere in the page takes a file or a link.
|
||||
useEffect(() => {
|
||||
const handler = (e: ClipboardEvent) => {
|
||||
// Fields own their paste: the comment editor uploads pasted images as
|
||||
// inline attachments, and the URL input has its own file handler below.
|
||||
// Without this guard, pasting an image into the description would flip
|
||||
// the whole modal over to a file dump.
|
||||
// Fields handle their own paste: the "why" editor uploads a pasted image
|
||||
// as an attachment, and the link field has its own handler below.
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const pastedFile = e.clipboardData?.files[0];
|
||||
@@ -381,9 +373,8 @@ export function DumpCreateModal(
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// Dropping is accepted over the whole modal, not just the drop zone: once a
|
||||
// link is being typed the zone is hidden, and aiming at it was never the
|
||||
// point of dragging a file into a "new dump" dialog.
|
||||
// The whole modal takes a drop, since the drop zone is hidden while a link
|
||||
// is typed.
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
@@ -408,8 +399,8 @@ export function DumpCreateModal(
|
||||
setValue("url", normalizedUrl);
|
||||
const body: CreateUrlDumpRequest = {
|
||||
url: normalizedUrl,
|
||||
// Only when the poster typed it: otherwise the server would be pinned
|
||||
// to whatever the preview happened to scrape a moment ago.
|
||||
// Left to the server unless typed, so it takes the page's title
|
||||
// itself.
|
||||
title: titleFromUser && title.trim() ? title.trim() : undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
@@ -466,8 +457,7 @@ export function DumpCreateModal(
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
if (!createdDump) return;
|
||||
const { playlist, hasDump } = membership;
|
||||
// The box only changes once the server agrees; a refusal or a dropped
|
||||
// connection leaves it showing the truth.
|
||||
// Not optimistic: the box changes only once the server agrees.
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
||||
@@ -486,14 +476,12 @@ export function DumpCreateModal(
|
||||
|
||||
const canProceed = !!file || !!canonicalUrl;
|
||||
|
||||
// What the second panel's choices apply to, in one line.
|
||||
const recap = title.trim() || file?.name || normalizeUrl(url);
|
||||
const uploadPercentLabel = t`Uploading ${
|
||||
Math.round((uploadProgress ?? 0) * 100)
|
||||
}%`;
|
||||
|
||||
// Enter in the link field advances rather than posts, so the panels behave
|
||||
// like a form the whole way through.
|
||||
// Enter on the first panel moves on to the second rather than posting.
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
if (step === "what") {
|
||||
e.preventDefault();
|
||||
@@ -598,7 +586,7 @@ export function DumpCreateModal(
|
||||
<form onSubmit={onFormSubmit} className="form">
|
||||
<FormError title={t`Failed to post`} />
|
||||
|
||||
{/* Keyed so each panel re-enters from the side it came from. */}
|
||||
{/* Keyed so a new panel remounts and plays its slide-in. */}
|
||||
<div
|
||||
key={step}
|
||||
className={`dump-panel dump-panel--${direction}`}
|
||||
@@ -687,8 +675,7 @@ export function DumpCreateModal(
|
||||
)}
|
||||
|
||||
{
|
||||
/* Hidden only while a link is being written: an empty
|
||||
panel offers both ways in at once, no mode to pick. */
|
||||
/* An empty panel offers both a link and a file. */
|
||||
}
|
||||
{!(mode === "url" && url.trim()) && (
|
||||
<FileField<CreateValues>
|
||||
@@ -718,8 +705,7 @@ export function DumpCreateModal(
|
||||
: (
|
||||
<>
|
||||
{
|
||||
/* The link itself is a panel back, so name the thing
|
||||
these choices are about. */
|
||||
/* The link or file is a panel back, so recall it. */
|
||||
}
|
||||
<p className="dump-panel-recap" title={recap}>
|
||||
<span aria-hidden="true">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The create form's "already dumped" hint.
|
||||
* Tells the poster, in the dump creation modal, that the link they typed was
|
||||
* already dumped.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,17 +11,20 @@ import type { DumpUrlMatch } from "../../../../shared/dump.ts";
|
||||
import { dumpUrl } from "../../../utils/shared/urls.ts";
|
||||
import { relativeTime } from "../../../utils/shared/relativeTime.ts";
|
||||
|
||||
/** The props of {@link DuplicateNotice}. */
|
||||
/**
|
||||
* Passed to {@link DuplicateNotice} by `DumpCreateModal`, with the matches of
|
||||
* `GET /api/dumps/by-url`.
|
||||
*/
|
||||
export interface DuplicateNoticeProps {
|
||||
/** The dumps already pointing at the link, most recent first. */
|
||||
/** Non-empty, latest first: the notice links the first one. */
|
||||
matches: DumpUrlMatch[];
|
||||
/** Called when a link to one of them is followed. */
|
||||
/** Closes the modal when the poster follows the link. */
|
||||
onNavigate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Already dumped" hint — never blocking, since reposts are
|
||||
* sometimes on purpose.
|
||||
* Renders the "already dumped" hint. It doesn't block posting, since a repost
|
||||
* is sometimes on purpose.
|
||||
*/
|
||||
export function DuplicateNotice(
|
||||
{ matches, onNavigate }: DuplicateNoticeProps,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The preview of a picked file, before it is uploaded.
|
||||
* Previews the file picked in the dump creation modal, before it is uploaded.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,13 +7,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
|
||||
|
||||
/** The props of {@link LocalFilePreview}. */
|
||||
/** Passed to {@link LocalFilePreview} by `DumpCreateModal`. */
|
||||
export interface LocalFilePreviewProps {
|
||||
/** The file picked. */
|
||||
file: File;
|
||||
}
|
||||
|
||||
/** Renders what a picked file will look like, before it's uploaded. */
|
||||
/**
|
||||
* Renders an image, video or audio file from a blob URL, which it revokes when
|
||||
* `file` changes or it unmounts. Any other file renders nothing.
|
||||
*/
|
||||
export function LocalFilePreview(
|
||||
{ file }: LocalFilePreviewProps,
|
||||
): React.JSX.Element | null {
|
||||
@@ -21,7 +23,8 @@ export function LocalFilePreview(
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
// Blob URL lifecycle requires setState in effect — no synchronous alternative.
|
||||
// The URL must be created and revoked with the effect, so it can't be
|
||||
// derived during render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSrc(url);
|
||||
return () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The create modal's steps, and where the user is in them.
|
||||
* The panels of the dump creation modal, and the indicator showing which one
|
||||
* is open.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,30 +8,25 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* A panel of the create modal. Posting a dump walks three panels. The split is
|
||||
* deliberate: categories and visibility used to sit behind a disclosure, where
|
||||
* they read as optional and went untouched — they are neither, so "why & where"
|
||||
* is a stop on the way to posting rather than something to go looking for.
|
||||
* A panel of `DumpCreateModal`, in the order they are walked. Categories and
|
||||
* visibility have a panel of their own, "details", because behind a
|
||||
* disclosure they went untouched.
|
||||
*/
|
||||
export type Step = "what" | "details" | "playlist";
|
||||
|
||||
/** The panels, in order, with their labels. */
|
||||
// Labels are functions so that `t` runs at render, in the current locale.
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** The props of {@link StepIndicator}. */
|
||||
/** Passed to {@link StepIndicator} by `DumpCreateModal`. */
|
||||
export interface StepIndicatorProps {
|
||||
/** The panel shown. */
|
||||
current: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders where you are in the three panels — and, past the first, how far you
|
||||
* got.
|
||||
*/
|
||||
/** Renders the three panels as numbered steps, the passed ones ticked. */
|
||||
export function StepIndicator(
|
||||
{ current }: StepIndicatorProps,
|
||||
): React.JSX.Element {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The create form's title row.
|
||||
* The title field of the dump creation modal, read-only until the poster
|
||||
* chooses to edit it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,25 +11,23 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../../shared/validation.ts";
|
||||
|
||||
/** The props of {@link TitleField}. */
|
||||
/**
|
||||
* Passed to {@link TitleField} by `DumpCreateModal`, which owns the editing
|
||||
* state: it marks the title as the poster's own on `onEdit`.
|
||||
*/
|
||||
export interface TitleFieldProps {
|
||||
/** The title field's react-hook-form registration. */
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
/** Whether the title is being edited. */
|
||||
editing: boolean;
|
||||
/** Starts editing it. */
|
||||
onEdit: () => void;
|
||||
/** Puts the suggestion back; no reset button without it. */
|
||||
/** Puts the suggested title back. Without it, no reset button shows. */
|
||||
onReset?: () => void;
|
||||
/** Whether the row is disabled. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the title row shared by both kinds of dump: the suggested title
|
||||
* (scraped from the page, or derived from the filename) sits there read-only
|
||||
* until the ✎ is used, which is enough to stop stray typing while keeping a
|
||||
* correction one click away. Once edited, a reset offers the suggestion back.
|
||||
* Renders the title of a link or file dump. The suggested title, from the
|
||||
* link's preview or the file's name, stays read-only until the ✎ button is
|
||||
* pressed, so stray typing can't change it.
|
||||
*/
|
||||
export function TitleField({
|
||||
registration,
|
||||
@@ -63,6 +62,7 @@ export function TitleField({
|
||||
className="dump-create-title-edit-btn"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
// The input is still disabled until the parent re-renders.
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The editor's floating emoji picker.
|
||||
* Renders the emoji picker that `TextEditor` portals to `<body>`, built on
|
||||
* frimousse. `TextEditor` owns its state and its keyboard handling.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,7 +21,10 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { readSkinTone, SKIN_TONE_KEY } from "../../utils/editor/skinTone.ts";
|
||||
|
||||
/** Remembers the skin tone picked in the selector for the next picker. */
|
||||
/**
|
||||
* Saves the tone picked in the selector, which only frimousse's context holds,
|
||||
* so that the next picker opens with it (`readSkinTone`).
|
||||
*/
|
||||
function SkinTonePersist() {
|
||||
const [skinTone] = useSkinTone();
|
||||
useEffect(() => {
|
||||
@@ -33,34 +37,34 @@ function SkinTonePersist() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The props of {@link EmojiPickerPopup}. */
|
||||
/**
|
||||
* Passed to {@link EmojiPickerPopup} by `TextEditor`, which keeps the refs to
|
||||
* detect outside presses, focus the search and step the keyboard into the grid.
|
||||
*/
|
||||
export interface EmojiPickerPopupProps {
|
||||
/** The ref of the floating panel, for outside-click checks. */
|
||||
floatRef: Ref<HTMLDivElement>;
|
||||
/** The ref of the picker's search field, to focus it. */
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
/** The ref of the picker's scrolling grid. */
|
||||
/** Focused by `TextEditor` to hand the arrow keys to frimousse. */
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
|
||||
/** From `useAnchoredFloat`, or hidden until it has placed the picker. */
|
||||
style: CSSProperties;
|
||||
/** Inline, the query is typed in the textarea and the search only shows it;
|
||||
* browsing, the search field takes it. */
|
||||
/**
|
||||
* Set for a `:query` typed in the textarea. The search field then only
|
||||
* mirrors `query` and can't take focus.
|
||||
*/
|
||||
inline: boolean;
|
||||
/** What the picker searches for. */
|
||||
query: string;
|
||||
/** Called as the search field is typed in. */
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Called with the emoji picked. */
|
||||
onSelect: (emoji: string) => void;
|
||||
/** Called to close the picker. */
|
||||
onClose: () => void;
|
||||
/** The editor's key handling while focus is in the picker. */
|
||||
/** Runs before frimousse sees the key, so it can take Escape and Enter. */
|
||||
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the floating emoji picker the editor opens from its button or a
|
||||
* `:query`, in the current locale, remembering the skin tone.
|
||||
* Renders the picker in the current locale, with the skin tone last picked on
|
||||
* this device. It fetches its emoji data from the site itself, where the Vite
|
||||
* build copies it (`VITE_EMOJIBASE_URL`).
|
||||
*/
|
||||
export function EmojiPickerPopup(
|
||||
{
|
||||
@@ -113,8 +117,8 @@ export function EmojiPickerPopup(
|
||||
</div>
|
||||
<EmojiPicker.Viewport
|
||||
ref={viewportRef}
|
||||
// tabIndex={-1} makes the div programmatically focusable so
|
||||
// frimousse's onFocusCapture can detect it and arm arrow-key nav
|
||||
// Focusable from code only: frimousse listens for the arrows only
|
||||
// once focus is inside the viewport.
|
||||
tabIndex={-1}
|
||||
>
|
||||
<EmojiPicker.Loading>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Markdown, rendered: GFM, @mentions as profile links, line breaks kept, and
|
||||
* links into the app kept in the app.
|
||||
* Renders the Markdown that `TextEditor` writes, wherever a dump's note, a
|
||||
* comment, a chat message or a description is shown.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,24 +10,25 @@ import { Link } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
/** The props of {@link Markdown}. */
|
||||
/** Passed to {@link Markdown} by any component that shows a user's text. */
|
||||
export interface MarkdownProps {
|
||||
/** The Markdown source. */
|
||||
children: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether it is a line-clamped teaser inside a card: images are dropped. */
|
||||
/**
|
||||
* Drops the images, for a line-clamped teaser in a card, where an image
|
||||
* would break the layout. The full text is one click away.
|
||||
*/
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mention rule the server notifies by (see shared/mention.ts), on parsed
|
||||
* text: an `@` not preceded by a word character, so an email address isn't a
|
||||
* mention. Link syntax is already parsed away here.
|
||||
* The rule of shared/mention.ts, which the server notifies by, applied to
|
||||
* parsed text: link syntax is already parsed away here, so only a preceding
|
||||
* word character (an email address) rules a mention out.
|
||||
*/
|
||||
const MENTION_RE = /(?<!\w)@(\w+)/g;
|
||||
|
||||
/** A node of the Markdown syntax tree, the part the plugins read. */
|
||||
/** The part of a Markdown syntax tree node that the plugins below read. */
|
||||
interface MdNode {
|
||||
type: string;
|
||||
value?: string;
|
||||
@@ -36,8 +37,8 @@ interface MdNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns bare @username into a link to that user. Works on the parsed tree
|
||||
* rather than the source text, so code keeps its @s verbatim and a URL that
|
||||
* Turns a bare `@username` into a link to that user's profile. It works on the
|
||||
* parsed tree rather than the source, so code keeps its `@`s and a URL that
|
||||
* contains one (`https://mastodon.social/@bob`) isn't split in two.
|
||||
*/
|
||||
function remarkMentions() {
|
||||
@@ -74,10 +75,9 @@ function remarkMentions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a single newline into a line break, as in chat everywhere: markdown's
|
||||
* own rule folds it into a space, so a message typed on three lines with
|
||||
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
|
||||
* nodes.)
|
||||
* Turns a single newline into a line break, as chat apps do. Markdown folds it
|
||||
* into a space, so a message typed on three lines with Shift+Enter would show
|
||||
* as one. Code keeps its newlines, since it has no text nodes.
|
||||
*/
|
||||
function remarkBreaks() {
|
||||
const walk = (node: MdNode) => {
|
||||
@@ -97,17 +97,16 @@ function remarkBreaks() {
|
||||
return (tree: MdNode) => walk(tree);
|
||||
}
|
||||
|
||||
/** The remark plugins, in order. */
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
|
||||
|
||||
/**
|
||||
* Returns the path of a link into this app — relative, or absolute to this
|
||||
* origin — or null for anywhere else (the API's files included).
|
||||
* Returns the path of a link into this app, relative or absolute to this
|
||||
* origin, or null for anywhere else. The API's files and attachments count as
|
||||
* elsewhere, since they aren't routes of the app.
|
||||
*/
|
||||
function internalPath(href: string | undefined): string | null {
|
||||
if (!href) return null;
|
||||
if (href.startsWith("/") && !href.startsWith("//")) {
|
||||
// Files and attachments are the API's, not routes: a new tab, as before.
|
||||
return href.startsWith("/api/") ? null : href;
|
||||
}
|
||||
try {
|
||||
@@ -120,17 +119,15 @@ function internalPath(href: string | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** react-markdown's element overrides. */
|
||||
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||
|
||||
/**
|
||||
* The element overrides, defined once at module scope to avoid recreation on
|
||||
* every render. Mentions and links to other pages here stay in the app; the
|
||||
* rest open in a new tab; a wide GFM table scrolls inside its own box.
|
||||
* Overrides react-markdown's elements. It is built once, since a new object on
|
||||
* each render would re-render every element.
|
||||
*/
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
// Mentions and links to other pages here stay in the app; the rest open
|
||||
// in a new tab.
|
||||
// Mentions and links to the app navigate in place, without a reload. The
|
||||
// rest open in a new tab.
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
const path = internalPath(href);
|
||||
if (path) return <Link to={path}>{linkChildren}</Link>;
|
||||
@@ -151,20 +148,16 @@ const MARKDOWN_COMPONENTS: Components = {
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* The element overrides of an inline rendering. Inline renderings are
|
||||
* line-clamped teasers inside cards, where an embedded image blows the layout
|
||||
* apart; images are dropped there — the full description is one click away on
|
||||
* the detail page.
|
||||
*/
|
||||
/** Overrides react-markdown's elements for an `inline` rendering. */
|
||||
const INLINE_MARKDOWN_COMPONENTS: Components = {
|
||||
...MARKDOWN_COMPONENTS,
|
||||
img: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders Markdown. Memoized: parsing runs the whole remark pipeline, and the
|
||||
* text rarely changes when the component around it re-renders.
|
||||
* Renders GFM with `@mentions` as profile links and single newlines kept. It
|
||||
* is memoized because parsing runs the whole remark pipeline, and the text
|
||||
* rarely changes when its parent re-renders.
|
||||
*/
|
||||
export const Markdown: React.MemoExoticComponent<
|
||||
(props: MarkdownProps) => React.JSX.Element
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The editor's @mention suggestions.
|
||||
* Renders the `@mention` suggestions that `TextEditor` portals to `<body>`.
|
||||
* `useMentionAutocomplete` fetches them and tracks the highlighted one.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,30 +9,32 @@ import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { UserSuggestion } from "../../../shared/user.ts";
|
||||
|
||||
/** The props of {@link MentionDropdown}. */
|
||||
/**
|
||||
* Passed to {@link MentionDropdown} by `TextEditor`, from the state of
|
||||
* `useMentionAutocomplete`.
|
||||
*/
|
||||
export interface MentionDropdownProps {
|
||||
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||
/**
|
||||
* The textarea's `aria-controls`. Option `i` gets the id `${id}-${i}`, which
|
||||
* the textarea names as its `aria-activedescendant`.
|
||||
*/
|
||||
id: string;
|
||||
/** The matching users. */
|
||||
results: UserSuggestion[];
|
||||
/** The highlighted match. */
|
||||
selectedIndex: number;
|
||||
/** Called with the name picked. */
|
||||
onSelect: (username: string) => void;
|
||||
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
|
||||
/** From `useAnchoredFloat`, or hidden until it has placed the list. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the users matching the `@query` as a listbox, keeping the highlighted
|
||||
* one in view; nothing when none match.
|
||||
* Renders the matching users as a listbox, scrolled to keep the highlighted one
|
||||
* in view, or nothing when none match. The keyboard stays in the textarea.
|
||||
*/
|
||||
export function MentionDropdown(
|
||||
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
|
||||
): React.JSX.Element | null {
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Arrowing past the visible rows keeps the selection in view.
|
||||
useLayoutEffect(() => {
|
||||
listRef.current
|
||||
?.querySelector<HTMLElement>("[aria-selected='true']")
|
||||
@@ -50,7 +53,6 @@ export function MentionDropdown(
|
||||
{results.map((user, i) => (
|
||||
<li
|
||||
key={user.id}
|
||||
// TextEditor points aria-activedescendant at `${id}-${index}`.
|
||||
id={`${id}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === selectedIndex}
|
||||
@@ -58,7 +60,8 @@ export function MentionDropdown(
|
||||
i === selectedIndex ? " mention-dropdown-item--selected" : ""
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // keep textarea focused
|
||||
// A click would blur the textarea, which closes the list.
|
||||
e.preventDefault();
|
||||
onSelect(user.username);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The site's text editor: a Markdown textarea with @mention and emoji
|
||||
* autocompletes, image upload by paste or drop, and a character counter.
|
||||
* Provides the Markdown textarea behind every text a user writes. Forms reach
|
||||
* it through `RichTextField`, while the comment and chat composers and the
|
||||
* inline editors of the profile and playlist pages use it directly.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -27,58 +28,70 @@ import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
/** What a ref to {@link TextEditor} can do. */
|
||||
/**
|
||||
* Exposed through the ref of {@link TextEditor}, so that the component that
|
||||
* opens an editor (a reply, an edit, the chat composer) can focus it.
|
||||
*/
|
||||
export interface TextEditorHandle {
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
* there); `preventScroll` leaves the page and its scrollers where they are. */
|
||||
/**
|
||||
* Focuses the textarea. `atEnd` puts the caret after the last character and
|
||||
* scrolls the text there. `preventScroll` leaves the page and its scrollers
|
||||
* where they are.
|
||||
*/
|
||||
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
|
||||
}
|
||||
|
||||
/** The props of {@link TextEditor}. */
|
||||
/**
|
||||
* Passed to {@link TextEditor} by the component that owns the text, which keeps
|
||||
* it in its state (the editor is controlled).
|
||||
*/
|
||||
export interface TextEditorProps {
|
||||
/** The text. */
|
||||
value: string;
|
||||
/** Called with the new text. */
|
||||
/** Called on typing, and with the Markdown of the images once uploaded. */
|
||||
onChange: (value: string) => void;
|
||||
/** The textarea's placeholder. */
|
||||
placeholder?: string;
|
||||
/** Whether the textarea is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The textarea's height, in rows. */
|
||||
rows?: number;
|
||||
/** The textarea's id. */
|
||||
id?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether the textarea grows with its text. */
|
||||
/** Grows the textarea with its text instead of scrolling it. */
|
||||
autoResize?: boolean;
|
||||
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
|
||||
/** Called on Ctrl/Cmd+Enter, but not while an image is still uploading. */
|
||||
onSubmit?: () => void;
|
||||
/** Escape, once no popup of the editor's own is left to close it: for the
|
||||
* inline editors (an edit, a reply), where it means "never mind". */
|
||||
/**
|
||||
* Called on Escape when no popup of the editor is left to close. Inline
|
||||
* editors (an edit, a reply) pass it to mean "never mind".
|
||||
*/
|
||||
onCancel?: () => void;
|
||||
/** Runs after the editor's own keys (autocompletes, Ctrl+Enter) and never
|
||||
/**
|
||||
* Runs after the editor's own keys (autocompletes, Ctrl+Enter), and never
|
||||
* while an IME is composing, so an Enter that confirms a candidate isn't
|
||||
* taken for "send". */
|
||||
* taken for "send".
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
/** The most characters it takes, counted and warned about. */
|
||||
/**
|
||||
* Shows a counter and marks the textarea invalid past it. It doesn't stop
|
||||
* the typing, so the caller must still refuse a longer text.
|
||||
*/
|
||||
maxLength?: number;
|
||||
/** Show the character counter only from this length. Defaults to 80% of
|
||||
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
|
||||
* up well before the warning colour does. */
|
||||
/**
|
||||
* The length from which the counter shows, 80% of `maxLength` by default:
|
||||
* a standing "0 / 5000" is noise, but the counter should turn up well
|
||||
* before the warning colour does.
|
||||
*/
|
||||
countFrom?: number;
|
||||
/** Marks the textarea invalid. */
|
||||
"aria-invalid"?: boolean;
|
||||
/** The id of what describes it (its error). */
|
||||
/** Joined with the id of the editor's own upload notice when it shows one. */
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
/** Checks whether `dt` carries files. */
|
||||
function hasFiles(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
|
||||
/** Text to put in once the textarea can take it. */
|
||||
/**
|
||||
* Image Markdown held back until the upload ends and the textarea is writable.
|
||||
*/
|
||||
interface PendingInsert {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -87,11 +100,11 @@ interface PendingInsert {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor: a textarea that autocompletes @mentions and `:emoji`,
|
||||
* opens an emoji picker from its button, uploads pasted or dropped images as
|
||||
* attachments and inserts them as Markdown, and counts characters near the
|
||||
* limit. Both popups are portaled to <body> and anchored to the textarea, so
|
||||
* the cards, modals and scrollers it sits in can't clip them.
|
||||
* Renders a textarea that completes `@mentions` and `:emoji`, opens an emoji
|
||||
* picker from its button, and counts characters near `maxLength`. A pasted or
|
||||
* dropped image is uploaded to `POST /api/attachments` with the session's
|
||||
* token and inserted as Markdown, so the editor must sit under `AuthProvider`.
|
||||
* The textarea is read-only while an upload runs.
|
||||
*/
|
||||
export const TextEditor: React.ForwardRefExoticComponent<
|
||||
TextEditorProps & React.RefAttributes<TextEditorHandle>
|
||||
@@ -126,7 +139,7 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const pendingInsertRef = useRef<PendingInsert | null>(null);
|
||||
// Whether the highlighted emoji was chosen with the arrows, rather than
|
||||
// Set when the highlighted emoji was reached with the arrows, rather than
|
||||
// being frimousse's automatic first result for the query.
|
||||
const arrowedRef = useRef(false);
|
||||
const { authFetch } = useAuth();
|
||||
@@ -150,12 +163,11 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
}), []);
|
||||
|
||||
/**
|
||||
* Puts `text` over `[start, end)` of the textarea, then a space when
|
||||
* `word` is set and none follows already, and leaves the caret after it.
|
||||
* Goes through execCommand where it can, so the insertion joins the
|
||||
* browser's undo stack — Ctrl+Z takes back the emoji or mention itself,
|
||||
* not everything typed before it — and fires the input event onChange
|
||||
* listens for.
|
||||
* Puts `text` over `[start, end)`, followed by a space when `word` is set
|
||||
* and none follows already, and leaves the caret after it. It goes through
|
||||
* execCommand where it can, so the insertion joins the browser's undo
|
||||
* stack (Ctrl+Z takes back the emoji or mention, not everything typed
|
||||
* before it) and fires the input event `onChange` listens for.
|
||||
*/
|
||||
const replace = useCallback(
|
||||
(
|
||||
@@ -211,9 +223,9 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
} = useEmojiTrigger(replace);
|
||||
const emojiOpen = emojiMode !== "closed";
|
||||
|
||||
// A press anywhere outside the picker puts it away — except, for an
|
||||
// inline `:query`, a press in the textarea: that's a caret move, which
|
||||
// keeps or closes it depending on where the caret lands.
|
||||
// A press outside the picker closes it, except a press in the textarea
|
||||
// while a `:query` is open: that moves the caret, and `syncEmojiTrigger`
|
||||
// decides from where it lands.
|
||||
useEffect(() => {
|
||||
if (emojiMode === "closed") return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
@@ -230,20 +242,18 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [emojiMode, closeEmoji]);
|
||||
|
||||
// Both popups are portaled to <body> and anchored to the textarea: the
|
||||
// editor sits inside cards, modal bodies and scrollers that clip anything
|
||||
// absolutely positioned out of their box.
|
||||
// Both popups are portaled to <body> and anchored to the textarea, since
|
||||
// the editor sits in cards, modals and scrollers that would clip them.
|
||||
const emojiStyle = useAnchoredFloat(textareaRef, emojiOpen, {
|
||||
width: 320,
|
||||
chromeHeight: 40, // search row + borders
|
||||
chromeHeight: 40, // The search row and the borders.
|
||||
minContentHeight: 120,
|
||||
maxContentHeight: 220,
|
||||
});
|
||||
|
||||
// Browsing starts in the picker's search; an inline `:query` keeps typing
|
||||
// in the textarea and only lends the picker its query. Only once the
|
||||
// picker is placed: until then it's hidden, and a hidden field can't take
|
||||
// focus.
|
||||
// Browsing starts in the picker's search, while an inline `:query` keeps
|
||||
// the focus in the textarea. The search is focused only once the picker
|
||||
// is placed, since a hidden field can't take focus.
|
||||
const emojiPlaced = emojiStyle != null;
|
||||
useEffect(() => {
|
||||
arrowedRef.current = false;
|
||||
@@ -255,14 +265,14 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
}, [emojiMode, emojiPlaced]);
|
||||
|
||||
const mentionStyle = useAnchoredFloat(textareaRef, mentionOpen, {
|
||||
width: "anchor", // it used to be stretched edge to edge by left/right: 0
|
||||
chromeHeight: 10, // list padding + borders
|
||||
width: "anchor",
|
||||
chromeHeight: 10, // The list's padding and borders.
|
||||
minContentHeight: 90,
|
||||
maxContentHeight: 220,
|
||||
});
|
||||
|
||||
// Before paint, so the first frame — and anything measuring the layout in
|
||||
// its own layout effect, like a height transition — sees the final size.
|
||||
// Before paint, so that the first frame, and any layout effect measuring
|
||||
// it (such as a height transition), sees the final size.
|
||||
useLayoutEffect(() => {
|
||||
if (!autoResize) return;
|
||||
const el = textareaRef.current;
|
||||
@@ -271,8 +281,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value, autoResize]);
|
||||
|
||||
// The textarea is read-only while images upload (so the spot they're
|
||||
// going to can't move); once it isn't, the images go in.
|
||||
// The textarea is read-only while images upload, so the spot they go to
|
||||
// can't move. They go in once it is writable again.
|
||||
useLayoutEffect(() => {
|
||||
if (uploading) return;
|
||||
const pending = pendingInsertRef.current;
|
||||
@@ -323,8 +333,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Copying from a spreadsheet or a document puts a picture of the
|
||||
// selection on the clipboard next to its text: the text is what was
|
||||
// meant.
|
||||
// selection on the clipboard next to its text, and the text is what
|
||||
// was meant.
|
||||
if (e.clipboardData.types.includes("text/plain")) return;
|
||||
const images = Array.from(e.clipboardData.files).filter((f) =>
|
||||
f.type.startsWith("image/")
|
||||
@@ -339,7 +349,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
setDragOver(false);
|
||||
if (!hasFiles(e.dataTransfer)) return; // dragged text: the default
|
||||
// Dragged text is dropped by the browser.
|
||||
if (!hasFiles(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
if (disabled || uploading) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
@@ -352,9 +363,9 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
[uploadImages, disabled, uploading],
|
||||
);
|
||||
|
||||
// Any file is caught, not just images: a file dropped on a textarea that
|
||||
// doesn't take it is opened by the browser in place of the page, and a
|
||||
// half-written message goes with it.
|
||||
// Any file is caught, not just images: the browser opens a file dropped
|
||||
// on a textarea that doesn't take it in place of the page, and the
|
||||
// half-written text goes with it.
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
if (!hasFiles(e.dataTransfer)) return;
|
||||
@@ -368,11 +379,10 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
|
||||
const handleDragLeave = useCallback(() => setDragOver(false), []);
|
||||
|
||||
// The best match for the query among the results on screen. frimousse
|
||||
// lists matches in emoji order, so ":rocket" would lead with 🧑🚀; a name
|
||||
// that is the query, then one that starts with it, comes first. (It also
|
||||
// renders a hidden row of unlabelled stand-ins to measure rows with;
|
||||
// those aren't picks.)
|
||||
// Finds the best match among the results on screen. frimousse lists
|
||||
// matches in emoji order, so ":rocket" would lead with 🧑🚀: a name equal
|
||||
// to the query comes first, then one starting with it. The unlabelled
|
||||
// buttons are a hidden row frimousse measures rows with.
|
||||
const firstEmoji = () => {
|
||||
const buttons = Array.from(
|
||||
floatRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
@@ -387,9 +397,9 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
buttons[0];
|
||||
};
|
||||
|
||||
// Moves the keyboard into the picker's grid: frimousse only listens for
|
||||
// arrows while focus is inside it, and the arrow that got us there is
|
||||
// replayed so the first press already lands on an emoji.
|
||||
// Moves the focus into the picker's grid, since frimousse only listens for
|
||||
// the arrows there, and replays the arrow so that the first press already
|
||||
// lands on an emoji.
|
||||
const enterPicker = (key: string) => {
|
||||
const viewport = emojiViewportRef.current;
|
||||
if (!viewport) return;
|
||||
@@ -401,7 +411,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/** Keys for an inline `:query` picker, pressed in the textarea. */
|
||||
// Handles a key pressed in the textarea while a `:query` is open, and
|
||||
// returns whether it took it.
|
||||
const handleInlineEmojiKey = (
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
): boolean => {
|
||||
@@ -409,8 +420,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
if ((e.key === "Enter" || e.key === "Tab") && plain) {
|
||||
const first = firstEmoji();
|
||||
if (!first) {
|
||||
// Nothing to pick (no match, or the data is still loading): the
|
||||
// key does what it would have.
|
||||
// With no match, or the data still loading, the key does what it
|
||||
// would have.
|
||||
closeEmoji();
|
||||
return false;
|
||||
}
|
||||
@@ -418,9 +429,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
first.click();
|
||||
return true;
|
||||
}
|
||||
// Every arrow steps into the grid, left and right included: while a
|
||||
// `:query` is open they pick among its results, not move the caret
|
||||
// (Escape first to get the caret back).
|
||||
// Left and right step into the grid too: while a `:query` is open they
|
||||
// pick among its results, and Escape gives the caret back.
|
||||
if (e.key.startsWith("Arrow") && plain) {
|
||||
if (!firstEmoji()) return false;
|
||||
e.preventDefault();
|
||||
@@ -479,8 +489,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
}}
|
||||
onBlur={() => closeMention()}
|
||||
onKeyDown={(e) => {
|
||||
// Enter while an IME is composing confirms the candidate; it
|
||||
// mustn't also pick, send or save.
|
||||
// Enter while an IME is composing confirms the candidate, and
|
||||
// must not also pick, send or save.
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||
// Nothing is sent or saved with an image still on its way.
|
||||
if (uploading && e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -527,7 +537,7 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
className={`text-editor-emoji-btn${
|
||||
emojiMode === "browse" ? " text-editor-emoji-btn--open" : ""
|
||||
}`}
|
||||
// Keeps the textarea's caret (and focus) where it was.
|
||||
// Keeps the textarea's focus and caret where they were.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={openBrowser}
|
||||
disabled={disabled || uploading}
|
||||
@@ -601,8 +611,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
}
|
||||
const search = emojiSearchRef.current;
|
||||
if (e.key.startsWith("Arrow")) arrowedRef.current = true;
|
||||
// Enter takes the emoji arrowed to, or else the best match —
|
||||
// not the first result frimousse highlights on its own.
|
||||
// Enter takes the emoji arrowed to, or else the best match
|
||||
// rather than the first result frimousse highlights.
|
||||
if (e.key === "Enter" && !arrowedRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -617,8 +627,8 @@ export const TextEditor: React.ForwardRefExoticComponent<
|
||||
) return;
|
||||
e.preventDefault();
|
||||
if (emojiMode === "inline") {
|
||||
// Typing while arrowing through the grid goes on with the
|
||||
// `:query` in the textarea, which the search follows.
|
||||
// Typing in the grid goes on with the `:query` in the
|
||||
// textarea, which the search mirrors.
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: true });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The hot and new feeds, and the props every main feed of the index takes.
|
||||
* The "hot" and "new" tabs of the index page. `Index` fetches the feed and
|
||||
* passes it down as {@link MainFeedProps}.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,43 +14,41 @@ import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
import { hotScore } from "../../utils/feed/hotScore.ts";
|
||||
|
||||
/** What the index hands whichever main feed its tab shows. */
|
||||
/**
|
||||
* Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is
|
||||
* showing. The tabs share this one state, so switching tabs doesn't refetch.
|
||||
*/
|
||||
export interface MainFeedProps {
|
||||
/** The dumps loaded so far. */
|
||||
dumps: Dump[];
|
||||
/** Whether the first page is loading. */
|
||||
loading: boolean;
|
||||
/** Why loading failed, null when it didn't. */
|
||||
error: string | null;
|
||||
/** Whether another page follows. */
|
||||
hasMore: boolean;
|
||||
/** Whether the next page is loading. */
|
||||
loadingMore: boolean;
|
||||
/**
|
||||
* The ref of the element that loads the next page when scrolled into view.
|
||||
*/
|
||||
/** From `useInfiniteScroll`. The feed places it after the last card. */
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
/** Live vote counts, by dump id. */
|
||||
/**
|
||||
* Counts pushed by the socket. A dump missing here shows its fetched
|
||||
* `voteCount`.
|
||||
*/
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
myVotes: Set<string>;
|
||||
/** The signed-in user, null when signed out. */
|
||||
user: SelfUser | null;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
/** The props of {@link DumpFeed}. */
|
||||
/**
|
||||
* Passed to {@link DumpFeed} by `Index` for the "hot" and "new" tabs. It adds
|
||||
* the active tab, as `order`, to the state all tabs share.
|
||||
*/
|
||||
export interface DumpFeedProps extends MainFeedProps {
|
||||
/** Whether the list is ordered by hot score or by date. */
|
||||
order: "hot" | "new";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the hot and new tabs: the same list of dump cards, ordered by hot
|
||||
* score or by date, loading more at its end.
|
||||
* Renders the "hot" and "new" tabs as a list of {@link DumpCard}, sorted on the
|
||||
* client, followed by the infinite-scroll sentinel. It also renders the
|
||||
* loading, error and empty states. `Index` is its only user.
|
||||
*/
|
||||
export function DumpFeed(
|
||||
{
|
||||
@@ -67,6 +66,7 @@ export function DumpFeed(
|
||||
removeVote,
|
||||
}: DumpFeedProps,
|
||||
): React.JSX.Element {
|
||||
// The server pages by date, so "hot" only ranks the pages loaded so far.
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...dumps].sort((a, b) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The index's feed tabs.
|
||||
* The tab bar of the index page, with the category switcher before the tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,15 +17,17 @@ import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
|
||||
/**
|
||||
* Renders the feed sort tabs (hot/new/journal/followed). Navigation stays
|
||||
* within the current scope: `~` for all categories, or the active category slug
|
||||
* — so the tabs link to `/~/new`, `/music/new`, etc.
|
||||
* Renders the "hot", "new" and "journal" tabs, and "followed" for a signed-in
|
||||
* user. It reads the category and tab from the URL, so a tab leads to the same
|
||||
* scope: `/~/new` for every dump, `/music/new` for a category. `Index` renders
|
||||
* it twice, in the header and below it, and CSS shows one by screen width.
|
||||
*/
|
||||
export function FeedTabBar(): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
// The same fallback as Index's, so the highlighted tab is the one shown.
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The followed feed: dumps from the users and the playlists the user follows.
|
||||
* The "followed" tab of the index page, which shows the dumps of the users and
|
||||
* the playlists the signed-in user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,7 +26,6 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
|
||||
/** A section's state: loading, failed, or loaded with a page of dumps. */
|
||||
type FeedState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -38,10 +38,10 @@ type FeedState =
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a page of a followed feed from `res`.
|
||||
* Returns the page of dumps in the body of `res`.
|
||||
*
|
||||
* @throws {Error} The server's own message on an error envelope, so it isn't
|
||||
* mistaken for a lost connection.
|
||||
* @throws {Error} The server's own message on an error response, so that
|
||||
* `friendlyFetchError` doesn't report it as a lost connection.
|
||||
*/
|
||||
async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
|
||||
const body = await res.json();
|
||||
@@ -51,30 +51,24 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
|
||||
return body.data as PaginatedData<Wire<Dump>>;
|
||||
}
|
||||
|
||||
/** A section of the followed feed. */
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
/** The props of {@link FollowedFeed}. */
|
||||
/**
|
||||
* Passed to {@link FollowedFeed} by `Index`, with the vote state and deleted
|
||||
* dumps of `WSContext`. Unlike the other tabs, it loads its own dumps.
|
||||
*/
|
||||
export interface FollowedFeedProps {
|
||||
/** The category the feed is scoped to; all when undefined. */
|
||||
/** Scopes both sections to a category. */
|
||||
categorySlug?: string;
|
||||
/** Live vote counts, by dump id. */
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
myVotes: Set<string>;
|
||||
/** The signed-in user. */
|
||||
/** `Index` renders the tab for a signed-in user only. */
|
||||
user: SelfUser;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** Ids of dumps deleted this session, filtered out. */
|
||||
deletedDumpIds: Set<string>;
|
||||
}
|
||||
|
||||
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The props of {@link FollowedSubFeed}. */
|
||||
interface FollowedSubFeedProps {
|
||||
state: FeedState;
|
||||
voteCounts: Record<string, number>;
|
||||
@@ -87,7 +81,7 @@ interface FollowedSubFeedProps {
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/** Renders a section of the followed feed, loading more on demand. */
|
||||
/** Renders one section's list, with its own infinite-scroll sentinel. */
|
||||
function FollowedSubFeed({
|
||||
state,
|
||||
voteCounts,
|
||||
@@ -151,11 +145,11 @@ function FollowedSubFeed({
|
||||
);
|
||||
}
|
||||
|
||||
// ── FollowedFeed ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the followed feed in two sections, from people and from playlists,
|
||||
* each paginated and scoped to `categorySlug`.
|
||||
* Renders the "followed" tab in two sections, "from people" and "from
|
||||
* playlists", from `GET /api/follows/feed/users` and
|
||||
* `GET /api/follows/feed/playlists`. Both load at once, and each keeps its own
|
||||
* pages, cached like the index's feed.
|
||||
*/
|
||||
export function FollowedFeed({
|
||||
categorySlug,
|
||||
@@ -168,7 +162,6 @@ export function FollowedFeed({
|
||||
}: FollowedFeedProps): React.JSX.Element {
|
||||
const { token } = useAuth();
|
||||
|
||||
// Scope key + query suffix applied to every followed-feed request.
|
||||
const scope = categorySlug ?? "all";
|
||||
const catQuery = categorySlug
|
||||
? `&category=${encodeURIComponent(categorySlug)}`
|
||||
@@ -211,7 +204,6 @@ export function FollowedFeed({
|
||||
|
||||
const [section, setSection] = useState<FollowedSection>("users");
|
||||
|
||||
// WS sync
|
||||
const setUsersItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setUsersState((s) =>
|
||||
@@ -230,7 +222,7 @@ export function FollowedFeed({
|
||||
);
|
||||
useDumpListSync(setPlaylistsItems);
|
||||
|
||||
// Fetch on mount if not cached
|
||||
// A cached section starts loaded, so only the others are fetched.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
if (usersState.status === "loading") {
|
||||
@@ -276,7 +268,6 @@ export function FollowedFeed({
|
||||
}
|
||||
}, [token, usersState.status, playlistsState.status, catQuery]);
|
||||
|
||||
// Scroll save
|
||||
useScrollSave(
|
||||
usersState.status === "loaded",
|
||||
useCallback((y) => {
|
||||
@@ -297,7 +288,6 @@ export function FollowedFeed({
|
||||
}, [playlistsState, savePlaylists]),
|
||||
);
|
||||
|
||||
// Load-more callbacks
|
||||
const loadMoreUsers = useCallback(() => {
|
||||
if (
|
||||
usersState.status !== "loaded" || !usersState.hasMore ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The journal feed: the editorial mosaic.
|
||||
* The "journal" tab of the index page, which lays the feed out as a mosaic of
|
||||
* tiles of several sizes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,8 +14,9 @@ import { composeJournal } from "../../utils/feed/journalLayout.ts";
|
||||
import type { MainFeedProps } from "./DumpFeed.tsx";
|
||||
|
||||
/**
|
||||
* Renders the dumps as the journal's mosaic, block by block, loading more at
|
||||
* its end.
|
||||
* Renders the feed `Index` passes as blocks of {@link JournalCard}, laid out
|
||||
* by `composeJournal`, followed by the infinite-scroll sentinel. It also
|
||||
* renders the loading, error and empty states.
|
||||
*/
|
||||
export function JournalFeed(
|
||||
{
|
||||
@@ -52,10 +54,9 @@ export function JournalFeed(
|
||||
return (
|
||||
<>
|
||||
{
|
||||
/* Each sub-block is its own grid, with a themable <hr> between them.
|
||||
A break sits between two grids rather than inside one because a grid
|
||||
item would be stretched to the 9rem row track — out here its height
|
||||
is its own, down to nothing for a theme that wants no separation. */
|
||||
/* Each block is its own grid, with a themable <hr> between them. Inside
|
||||
a grid the <hr> would be stretched to a 9rem row, while out here a
|
||||
theme can shrink it to nothing. */
|
||||
}
|
||||
<div className="journal-feed">
|
||||
{blocks.map((block, index) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The row of who's online, in the feed's header.
|
||||
* Shows who is online, as a row of avatars next to the index page's tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,36 +11,30 @@ import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { useWSPresence } from "../../hooks/ws/useWS.ts";
|
||||
import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts";
|
||||
|
||||
/**
|
||||
* Past this many, the row ends in a "+N" counter rather than growing, so a busy
|
||||
* evening doesn't push the feed tabs out of the header.
|
||||
*/
|
||||
// A seat is an avatar or the "+N" counter. Past MAX_SEATS, the counter takes
|
||||
// the rest, so that a busy evening doesn't push the tabs out of the header.
|
||||
// Below MIN_SEATS, the tabs scroll instead.
|
||||
const MAX_SEATS = 6;
|
||||
/**
|
||||
* However tight the row, it keeps an avatar and the counter — past that, the
|
||||
* tabs beside it scroll instead.
|
||||
*/
|
||||
const MIN_SEATS = 2;
|
||||
/** Each seat (avatar or counter) is 32px wide… */
|
||||
const SEAT = 32;
|
||||
/**
|
||||
* …and overlaps the one before it by 10px (.index-presence-avatar's negative
|
||||
* margin), so adds 22px.
|
||||
*/
|
||||
// A seat overlaps the one before it by .index-presence-avatar's -10px margin.
|
||||
const SEAT_STEP = 22;
|
||||
|
||||
/** The props of {@link PresenceRow}. */
|
||||
/**
|
||||
* Passed to {@link PresenceRow} by `Index`, which renders one in the header
|
||||
* and one below it. Both selectors go to `useRoomBeside`.
|
||||
*/
|
||||
export interface PresenceRowProps {
|
||||
/** An ancestor selector bounding the row it shares. */
|
||||
/** The ancestor that bounds the row the avatars share. */
|
||||
fitWithin?: string;
|
||||
/** A selector for what comes first in that row: the tabs. */
|
||||
/** The tabs, which keep their width before the avatars get any. */
|
||||
fitBeside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders who's online, as a row of overlapping avatars. Given `fitWithin` and
|
||||
* `fitBeside`, it gives up seats to what's beside it — as many avatars as the
|
||||
* room left by the tabs allows, the rest folded into the counter.
|
||||
* Renders the online users from `WSContext` as overlapping avatars linking to
|
||||
* their profiles, as many as the tabs leave room for, and a "+N" counter for
|
||||
* the others. It renders nothing when nobody is online.
|
||||
*/
|
||||
export function PresenceRow(
|
||||
{ fitWithin, fitBeside }: PresenceRowProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A file drop zone, which is also a file picker.
|
||||
* Renders the zone that takes a dump's file, by drop or by picker. `FileField`
|
||||
* binds it to a form.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,7 +10,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
|
||||
/** Returns the icon for a file of type `mime`. */
|
||||
function fileIcon(mime: string): string {
|
||||
if (mime.startsWith("image/")) return "🖼";
|
||||
if (mime.startsWith("video/")) return "🎬";
|
||||
@@ -18,23 +18,30 @@ function fileIcon(mime: string): string {
|
||||
return "📎";
|
||||
}
|
||||
|
||||
/** The props of {@link FileDropZone}. */
|
||||
/**
|
||||
* Passed to {@link FileDropZone} by `FileField`, from the form's state. The
|
||||
* zone keeps no file of its own.
|
||||
*/
|
||||
export interface FileDropZoneProps {
|
||||
/** The chosen file, null without one. */
|
||||
file: File | null;
|
||||
/** Called with the file dropped or picked, or null when it is removed. */
|
||||
/**
|
||||
* Called with the file dropped or picked, or with null when it is removed.
|
||||
*/
|
||||
onChange: (file: File | null) => void;
|
||||
/** Whether it takes no file. */
|
||||
disabled?: boolean;
|
||||
/** The prompt shown while empty; "Drop a file here" by default. */
|
||||
/** Replaces the "Drop a file here" prompt. */
|
||||
hint?: string;
|
||||
/** Whether to state the size limit; true by default. */
|
||||
/**
|
||||
* Shows "Max 50 MB", which is written out rather than read from
|
||||
* `DUMP_MAX_FILE_SIZE_BYTES`. The zone doesn't enforce it.
|
||||
*/
|
||||
showLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a zone that takes a dropped file, or opens a file picker when
|
||||
* clicked, and shows the file once chosen, with a button to remove it.
|
||||
* Renders a zone that takes a dropped file, or opens the file picker on a click
|
||||
* or Enter. Once a file is chosen, it shows its name and size and a button to
|
||||
* remove it. Only the first of several dropped files is kept.
|
||||
*/
|
||||
export function FileDropZone({
|
||||
file,
|
||||
@@ -56,6 +63,7 @@ export function FileDropZone({
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
// Moving onto a child fires dragleave on the zone too.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setDragging(false);
|
||||
}
|
||||
@@ -78,6 +86,7 @@ export function FileDropZone({
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Or the zone, empty again, would open the picker.
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's file field.
|
||||
* Renders the file field of the dump forms, a {@link FileDropZone} bound to
|
||||
* react-hook-form.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,27 +15,27 @@ import {
|
||||
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
|
||||
/** The props of {@link FileField}. */
|
||||
/**
|
||||
* Passed to {@link FileField} by a dump form. The field it names holds the
|
||||
* `File` itself, or null.
|
||||
*/
|
||||
export interface FileFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** The drop zone's prompt. */
|
||||
hint?: string;
|
||||
/** Whether to state the size limit. */
|
||||
showLimit?: boolean;
|
||||
/** Whether the field takes no file. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Side effect run after the field value changes (e.g. derive a title). */
|
||||
/**
|
||||
* Called after the form's value is set, for a side effect such as deriving
|
||||
* the title from the file's name.
|
||||
*/
|
||||
onValueChange?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `Controller`-wrapped {@link FileDropZone} bound to react-hook-form,
|
||||
* with the field's error under it.
|
||||
* Renders the drop zone with its label and the field's error. It must sit under
|
||||
* a react-hook-form `FormProvider`, such as one given `useApiForm`'s result.
|
||||
*/
|
||||
export function FileField<T extends FieldValues>({
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's row of actions.
|
||||
* Renders the row of buttons that ends a form, in the pages and dialogs that
|
||||
* edit something.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,22 +8,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
/** The props of {@link FormActions}. */
|
||||
/** Passed to {@link FormActions} by a form, with its submit button as child. */
|
||||
export interface FormActionsProps {
|
||||
/** The submit control(s). */
|
||||
children: ReactNode;
|
||||
/** Called by the Cancel button; no Cancel button without it. */
|
||||
/** Adds a Cancel button, which calls it. */
|
||||
onCancel?: () => void;
|
||||
/** The Cancel button's label; "Cancel" by default. */
|
||||
/** Replaces the "Cancel" label. */
|
||||
cancelLabel?: ReactNode;
|
||||
/** Whether the Cancel button is disabled. */
|
||||
/** Disables the Cancel button only, not the children. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a right-aligned form action row: an optional Cancel button followed
|
||||
* by the submit control(s). Standardizes the `.form-actions >
|
||||
* .form-actions-right` markup duplicated across forms.
|
||||
* Renders the Cancel button, if any, then the children, aligned right. The
|
||||
* Cancel button is `type="button"`, so it never submits the form.
|
||||
*/
|
||||
export function FormActions({
|
||||
children,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's submit error.
|
||||
* Renders the error of a failed submit, for the forms built with
|
||||
* `useApiForm`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,16 +10,16 @@ import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
|
||||
/** The props of {@link FormError}. */
|
||||
/** Passed to {@link FormError} by a form, to say what failed. */
|
||||
export interface FormErrorProps {
|
||||
/** The card's title; "Something went wrong" by default. */
|
||||
/** Replaces the "Something went wrong" title. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by `useApiForm`'s `submit` on a
|
||||
* thrown/failed request) through the shared {@link ErrorCard}, and nothing
|
||||
* without one. One consistent place for server/submit errors across every form.
|
||||
* Renders the form's root error as an {@link ErrorCard}, or nothing without
|
||||
* one. `useApiForm`'s `submit` sets that error when the request fails. It must
|
||||
* sit under a react-hook-form `FormProvider`.
|
||||
*/
|
||||
export function FormError(
|
||||
{ title }: FormErrorProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's rich-text field.
|
||||
* Renders a {@link TextEditor} bound to react-hook-form, for the forms that
|
||||
* take a Markdown text (a dump's note, a playlist's description).
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,34 +17,29 @@ import {
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
|
||||
/** The props of {@link RichTextField}. */
|
||||
/** Passed to {@link RichTextField} by a form that holds a string field. */
|
||||
export interface RichTextFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** The editor's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The editor's height, in rows. */
|
||||
rows?: number;
|
||||
/** Counted in the editor and enforced on submit, with a message saying so. */
|
||||
/**
|
||||
* Counted in the editor and checked on submit, with a message. It overrides
|
||||
* a `maxLength` in `rules`.
|
||||
*/
|
||||
maxLength?: number;
|
||||
/** Whether the editor grows with its text. */
|
||||
autoResize?: boolean;
|
||||
/** Whether the editor is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Forwarded to the underlying TextEditor so callers can focus it. */
|
||||
/** The editor's ref, for a caller that focuses it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** Ctrl/Cmd+Enter handler (e.g. submit the comment). */
|
||||
/** Called on Ctrl/Cmd+Enter. */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a rich-text field: a `Controller`-wrapped {@link TextEditor}
|
||||
* (mentions, emoji, image upload, char count) bound to react-hook-form via the
|
||||
* surrounding `FormProvider`, with the field's error under it.
|
||||
* Renders the editor with its label and the field's error. It must sit under a
|
||||
* react-hook-form `FormProvider`, such as one given `useApiForm`'s result.
|
||||
*/
|
||||
export function RichTextField<T extends FieldValues>({
|
||||
name,
|
||||
@@ -61,8 +57,8 @@ export function RichTextField<T extends FieldValues>({
|
||||
const hintId = useId();
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
// A bare numeric rule fails without a message, which left a submit that did
|
||||
// nothing and said nothing.
|
||||
// A bare numeric rule fails without a message, and the submit would do
|
||||
// nothing and say nothing.
|
||||
const allRules = maxLength == null ? rules : {
|
||||
...rules,
|
||||
maxLength: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's segmented control, and the public/private toggle built on it.
|
||||
* Renders a form's choice among a few values as a row of buttons, and the
|
||||
* public/private toggle that the dump and playlist forms build on it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -13,30 +14,29 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
/** A choice of a {@link SegmentedField}. */
|
||||
/**
|
||||
* Passed in {@link SegmentedFieldProps.options}. Its `value` is compared to the
|
||||
* field's with `===` and must be unique as a string, since it keys the button.
|
||||
*/
|
||||
export interface SegmentedOption<V> {
|
||||
/** The value it sets. */
|
||||
value: V;
|
||||
/** What it shows. */
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
/** The props of {@link SegmentedField}. */
|
||||
/**
|
||||
* Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}.
|
||||
*/
|
||||
export interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The choices, in order. */
|
||||
options: SegmentedOption<V>[];
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** Whether the control is disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a segmented control (the `.visibility-toggle` style) bound to
|
||||
* react-hook-form. Generic over the field value, so it works for booleans,
|
||||
* string unions, etc.
|
||||
* Renders the options as buttons, the one equal to the field's value marked
|
||||
* active, in the `.visibility-toggle` style. It must sit under a
|
||||
* react-hook-form `FormProvider`.
|
||||
*/
|
||||
export function SegmentedField<T extends FieldValues, V>({
|
||||
name,
|
||||
@@ -72,20 +72,18 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link VisibilityToggle}. */
|
||||
/** Passed to {@link VisibilityToggle} by a dump or playlist form. */
|
||||
export interface VisibilityToggleProps<T extends FieldValues> {
|
||||
/** The boolean field's name in the form. */
|
||||
/** A boolean field, true for public. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** Whether the toggle is disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Public/Private toggle on a boolean field (default `isPublic`).
|
||||
* Forms that persist `isPrivate` should still bind this to an `isPublic` field
|
||||
* and invert at submit time.
|
||||
* Renders a Public/Private toggle on a boolean field that is true for public.
|
||||
* A dump's API takes `isPrivate` instead, so the dump forms bind the toggle to
|
||||
* an `isPublic` field and invert it on submit.
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's submit button.
|
||||
* Renders the submit button of the forms built with `useApiForm`, which shows
|
||||
* the submit in progress.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,24 +8,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
/** The props of {@link SubmitButton}. */
|
||||
/** Passed to {@link SubmitButton} by a form, usually inside `FormActions`. */
|
||||
export interface SubmitButtonProps {
|
||||
/** The button's label. */
|
||||
children: ReactNode;
|
||||
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
|
||||
/** Replaces the label while the form is submitting, such as "Saving…". */
|
||||
pendingLabel?: ReactNode;
|
||||
/** Whether it is disabled, besides while submitting. */
|
||||
/** Disables the button, which is disabled while submitting anyway. */
|
||||
disabled?: boolean;
|
||||
/** Its class; `btn-primary` by default. */
|
||||
/** Replaces the `btn-primary` class. */
|
||||
className?: string;
|
||||
/** For an icon-only button (e.g. "✓"), its accessible name. */
|
||||
/** Names an icon-only button, such as "✓". */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a submit button that reflects react-hook-form's `isSubmitting` from
|
||||
* the surrounding `FormProvider`: it disables itself while submitting and swaps
|
||||
* in `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
|
||||
* Renders a submit button that is disabled while the form submits, so that a
|
||||
* second click can't post twice. It reads the form's state, so it must sit
|
||||
* under a react-hook-form `FormProvider`.
|
||||
*/
|
||||
export function SubmitButton({
|
||||
children,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A form's text field.
|
||||
* Renders a one-line text field bound to react-hook-form, for the account
|
||||
* forms, a dump's title and a playlist's.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -15,37 +16,31 @@ import {
|
||||
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
/** The props of {@link TextField}. */
|
||||
/** Passed to {@link TextField} by a form that holds a string field. */
|
||||
export interface TextFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/**
|
||||
* Optional control rendered to the right of the label (e.g. a reset button).
|
||||
*/
|
||||
/** Shown at the right of the label, such as a reset button. */
|
||||
labelAction?: ReactNode;
|
||||
/** The input's type; `text` by default. */
|
||||
type?: "text" | "email" | "password" | "url" | "search";
|
||||
/** The input's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The input's autocomplete hint. */
|
||||
autoComplete?: string;
|
||||
/** Whether the input takes focus on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Whether the input is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Cap input length at `max` (native `maxLength`). */
|
||||
/**
|
||||
* Stops the typing at this length, through the input's own `maxLength`.
|
||||
* Unlike `RichTextField`'s, it adds no rule, so a value set from code isn't
|
||||
* checked.
|
||||
*/
|
||||
maxLength?: number;
|
||||
/** Show a live `n / maxLength` counter. Requires `maxLength`. */
|
||||
/** Shows an `n / maxLength` counter, only when `maxLength` is set. */
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a live `n / max` counter of field `name`; isolated so only counted
|
||||
* fields subscribe to the value.
|
||||
* Renders the counter of field `name`. It is a component of its own so that
|
||||
* only it, not the whole field, re-renders on every keystroke.
|
||||
*/
|
||||
function CharCounter<T extends FieldValues>(
|
||||
{ name, max }: { name: Path<T>; max: number },
|
||||
@@ -60,9 +55,8 @@ function CharCounter<T extends FieldValues>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a native text input wired to react-hook-form via the surrounding
|
||||
* `FormProvider`: a label, the input (with an optional character counter), and
|
||||
* a validation hint sourced from `formState.errors[name]`.
|
||||
* Renders the input with its label and the field's error. It must sit under a
|
||||
* react-hook-form `FormProvider`, such as one given `useApiForm`'s result.
|
||||
*/
|
||||
export function TextField<T extends FieldValues>({
|
||||
name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The site's header: the brand, the search, the user's links and buttons, and
|
||||
* the socket's status.
|
||||
* Renders the header at the top of every page, through `PageShell` or directly
|
||||
* from the index and search pages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,47 +17,52 @@ import { UserMenu } from "./UserMenu.tsx";
|
||||
import { SearchBar } from "./SearchBar.tsx";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
|
||||
/** The dump creation modal, loaded on first open. */
|
||||
/** Loaded on first open, so its editor stays out of every page's bundle. */
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Carried in a search entry's history state. */
|
||||
/** Stored in the history state of each search results entry. */
|
||||
interface SearchEntryState {
|
||||
/**
|
||||
* The history index of the page the search started from, so leaving search
|
||||
* returns there in one step however many searches (or result tabs) came in
|
||||
* The history index of the page the search started from. Closing the search
|
||||
* goes back there in one step, however many searches or result tabs came in
|
||||
* between.
|
||||
*/
|
||||
searchExit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns React Router's index of the current history entry — 0 for the first
|
||||
* page of the session, which has nothing in the app to go back to.
|
||||
* Returns React Router's index of the current history entry. It is 0 for the
|
||||
* first page of the session, which has nothing in the app to go back to.
|
||||
*/
|
||||
function historyIndex(): number {
|
||||
return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0;
|
||||
}
|
||||
|
||||
/** The props of {@link AppHeader}. */
|
||||
/**
|
||||
* Passed to {@link AppHeader} by the index page. The other pages leave these
|
||||
* props out.
|
||||
*/
|
||||
export interface AppHeaderProps {
|
||||
/** What the page shows in the header's centre, beside the search. */
|
||||
/** Shown beside the search. The index page puts its feed tabs there. */
|
||||
centerSlot?: ReactNode;
|
||||
/** Whether the new-dump button is disabled (the server is unreachable). */
|
||||
/** Set by the index page while its feed fails to load. */
|
||||
disableNew?: boolean;
|
||||
/** A link to open the dump creation modal with, at mount. */
|
||||
/**
|
||||
* Opens the dump creation dialog at mount, with this link. The index page
|
||||
* passes the link shared to the app from Android's share sheet.
|
||||
*/
|
||||
initialDumpUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the site's header: the brand; the search, which stays open on the
|
||||
* results page and slides open under the header on small viewports; for a
|
||||
* signed-in user their profile and playlists links (an avatar menu on small
|
||||
* viewports), the chat and notification buttons and the new-dump button, else a
|
||||
* login button; and a banner while live updates are down.
|
||||
* Renders the site's header. It holds the search, which stays open on the
|
||||
* results page and slides open under the header on small screens, the
|
||||
* signed-in user's links and buttons, and a banner while live updates are down.
|
||||
* It also hosts the dump creation dialog that its "+ Dump" button opens.
|
||||
*/
|
||||
export function AppHeader(
|
||||
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
||||
@@ -67,16 +72,17 @@ export function AppHeader(
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
// The results page and each of its tabs (/search/~/users…).
|
||||
// `/search` and its tabs, `/search/~/:searchTab`.
|
||||
const isSearchPage = location.pathname === "/search" ||
|
||||
location.pathname.startsWith("/search/");
|
||||
const query = isSearchPage ? searchParams.get("q") ?? "" : "";
|
||||
|
||||
// On the results page the search stays open, holding the query it shows.
|
||||
// On the results page the search stays open, showing the query.
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchExpanded = isSearchPage || searchOpen;
|
||||
const [draft, setDraft] = useState(query);
|
||||
const [prevQuery, setPrevQuery] = useState(query);
|
||||
// Reset during render when navigation changes the query, as with Back.
|
||||
if (prevQuery !== query) {
|
||||
setPrevQuery(query);
|
||||
setDraft(query);
|
||||
@@ -87,10 +93,10 @@ export function AppHeader(
|
||||
|
||||
function submitSearch(q: string) {
|
||||
const here = location.state as SearchEntryState | null;
|
||||
// A search from elsewhere remembers that page; one from the results page
|
||||
// passes on what it was given, or — for results reached by a link —
|
||||
// the page before them. Results that opened the session have nowhere to
|
||||
// return to, so a new search just replaces them.
|
||||
// A search from elsewhere remembers that page. One from the results page
|
||||
// passes on what it was given or, for results reached by a link, the page
|
||||
// before them. Results that opened the session have nowhere to return to,
|
||||
// so a new search replaces them.
|
||||
const searchExit = !isSearchPage
|
||||
? historyIndex()
|
||||
: here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null);
|
||||
@@ -112,8 +118,8 @@ export function AppHeader(
|
||||
}
|
||||
}
|
||||
|
||||
// Escape leaves an opened search, but only reverts an edited query on the
|
||||
// results page — leaving that page is the ✕'s job.
|
||||
// On the results page, Escape only reverts the edited query: leaving the
|
||||
// page is the ✕'s job.
|
||||
function escapeSearch() {
|
||||
if (isSearchPage) setDraft(query);
|
||||
else closeSearch();
|
||||
@@ -162,7 +168,6 @@ export function AppHeader(
|
||||
{user
|
||||
? (
|
||||
<>
|
||||
{/* Full text links — hidden below the compact breakpoint */}
|
||||
<div className="nav-links">
|
||||
<Link
|
||||
to={`/users/${user.username}`}
|
||||
@@ -178,7 +183,7 @@ export function AppHeader(
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Compact avatar menu — hidden above the compact breakpoint */}
|
||||
{/* CSS shows either the links above or this menu, by width. */}
|
||||
<div className="nav-compact">
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
@@ -209,7 +214,7 @@ export function AppHeader(
|
||||
</header>
|
||||
|
||||
{
|
||||
/* Stays mounted so the row can slide open and shut; `inert` keeps the
|
||||
/* Stays mounted so the row can slide open and shut. `inert` keeps the
|
||||
collapsed input out of the tab order. */
|
||||
}
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The header's chat button.
|
||||
* Renders the chat button of the header, for signed-in users.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,8 +9,8 @@ import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/**
|
||||
* Renders the header chat button with an unread-message badge. Opens the
|
||||
* app-wide chat.
|
||||
* Renders the button that opens the chat, with the socket's count of unread
|
||||
* messages. `ChatFab` does the same once the header scrolls away.
|
||||
*/
|
||||
export function ChatButton(): React.JSX.Element {
|
||||
const { unreadChatCount } = useWSChat();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The floating chat button.
|
||||
* Renders the floating chat button, which `App` mounts once over every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,9 +12,8 @@ import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/**
|
||||
* Renders the floating chat button shown once the header has scrolled out of
|
||||
* view, mirroring `DumpFab`, with an unread badge. Opens the single app-wide
|
||||
* chat modal (owned by ChatProvider). Only rendered for signed-in users.
|
||||
* Renders the chat button that floats above `DumpFab` once the header, and its
|
||||
* `ChatButton`, have scrolled out of view. It renders nothing when signed out.
|
||||
*/
|
||||
export function ChatFab(): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
@@ -23,9 +22,8 @@ export function ChatFab(): React.JSX.Element | null {
|
||||
const location = useLocation();
|
||||
const [headerHidden, setHeaderHidden] = useState(false);
|
||||
|
||||
// Reveal the button once the header's bottom edge scrolls above the viewport.
|
||||
// Re-reading the header on every tick keeps this correct across navigation
|
||||
// and lazily-mounted pages, where the header element is replaced.
|
||||
// Each page renders its own header, so it is queried anew on every check.
|
||||
// The ResizeObserver catches a lazy page that mounts without a scroll.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const update = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The floating new-dump button.
|
||||
* Renders the floating "new dump" button, which `App` mounts once over every
|
||||
* page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,7 +10,7 @@ import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The dump creation modal, loaded on first open. */
|
||||
/** Loaded on first open, so its editor stays out of every page's bundle. */
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
@@ -17,15 +18,9 @@ const DumpCreateModal = lazy(() =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the floating "new dump" button shown once the header has scrolled out
|
||||
* of view. Sits in the bottom-right corner and lifts above the global player
|
||||
* (which is full-width on mobile) so the two never overlap. Only rendered for
|
||||
* signed-in users — dump creation requires an account.
|
||||
*
|
||||
* Mounted once at the app root (not per-page) since some pages render their own
|
||||
* header instead of going through PageShell. Visibility is derived from a live
|
||||
* read of the current page's `.app-header`, so it survives client-side
|
||||
* navigation and lazily-mounted route headers.
|
||||
* Renders the button that opens the dump creation dialog once the header, and
|
||||
* its "+ Dump" button, have scrolled out of view. It renders nothing when
|
||||
* signed out. The stylesheet lifts it above the global player.
|
||||
*/
|
||||
export function DumpFab(): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
@@ -33,17 +28,14 @@ export function DumpFab(): React.JSX.Element | null {
|
||||
const [headerHidden, setHeaderHidden] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// Reserve clearance at the end of the scroll so the floating button never
|
||||
// hides the last content when scrolled all the way down.
|
||||
// `has-fab` pads the page's end, so the button never hides the last content.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
document.body.classList.add("has-fab");
|
||||
return () => document.body.classList.remove("has-fab");
|
||||
}, [user]);
|
||||
|
||||
// Reveal the button once the header's bottom edge scrolls above the viewport.
|
||||
// Re-reading the header on every tick keeps this correct across navigation
|
||||
// and lazily-mounted pages, where the header element is replaced.
|
||||
// Each page renders its own header, so it is queried anew on every check.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const update = () => {
|
||||
@@ -53,8 +45,8 @@ export function DumpFab(): React.JSX.Element | null {
|
||||
update();
|
||||
globalThis.addEventListener("scroll", update, { passive: true });
|
||||
globalThis.addEventListener("resize", update);
|
||||
// Body height changes when a lazy route finishes mounting — re-check then,
|
||||
// since no scroll/resize event fires in that case.
|
||||
// A lazy page that finishes mounting changes the body's height without a
|
||||
// scroll or resize event.
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(document.body);
|
||||
return () => {
|
||||
@@ -78,8 +70,7 @@ export function DumpFab(): React.JSX.Element | null {
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
{
|
||||
/* Symmetric SVG plus — centers exactly via flex, regardless of the
|
||||
theme's font metrics (a text "+" sits slightly off the box center). */
|
||||
/* An SVG, since a text "+" sits off center in some themes' fonts. */
|
||||
}
|
||||
<svg
|
||||
className="dump-fab-icon"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The header's notification bell.
|
||||
* Renders the notification bell of the header, for signed-in users.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,16 +10,16 @@ import { t } from "@lingui/core/macro";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
|
||||
/**
|
||||
* Renders the header's notification bell with an unread badge; it rings when a
|
||||
* notification arrives, and opens the notifications page.
|
||||
* Renders the bell that leads to `/notifications`, with the socket's count of
|
||||
* unread notifications. It rings when the socket pushes a new one.
|
||||
*/
|
||||
export function NotificationBell(): React.JSX.Element {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [ringing, setRinging] = useState(false);
|
||||
const animatingRef = useRef(false);
|
||||
// Initialised to the ID already in context — so the first effect run never
|
||||
// animates, regardless of whether lastNotification is null or stale.
|
||||
// Each page mounts its own header, so a notification already in context
|
||||
// must not ring the bell again after a navigation.
|
||||
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A page that failed to load.
|
||||
* Renders the error a page shows in place of its content when what it loads
|
||||
* fails or doesn't exist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,17 +10,19 @@ import { t } from "@lingui/core/macro";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
|
||||
/** The props of {@link PageError}. */
|
||||
/** Passed to {@link PageError} by a page whose load failed. */
|
||||
export interface PageErrorProps {
|
||||
/** What failed; "Something went wrong" by default. */
|
||||
/** Defaults to "Something went wrong". */
|
||||
title?: string;
|
||||
/** Why. */
|
||||
message: string;
|
||||
/** Buttons offered under it. */
|
||||
/** Buttons under the message, such as a retry. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders an error card as the whole page, under the header. */
|
||||
/**
|
||||
* Renders an error card as the whole page, under the header, so the user can
|
||||
* still navigate away.
|
||||
*/
|
||||
export function PageError(
|
||||
{ title, message, actions }: PageErrorProps,
|
||||
): React.JSX.Element {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A page's frame: the header, and the content under it.
|
||||
* Renders the frame of every page but the index and search pages, which
|
||||
* render `AppHeader` themselves.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,17 +8,16 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { AppHeader } from "./AppHeader.tsx";
|
||||
|
||||
/** The props of {@link PageShell}. */
|
||||
/** Passed to {@link PageShell} by a page, around its content. */
|
||||
export interface PageShellProps {
|
||||
/** The page's content. */
|
||||
children: ReactNode;
|
||||
/** Whether the content is centred. */
|
||||
/** Centers the content, for a lone card such as a form or an error. */
|
||||
centered?: boolean;
|
||||
/** What the page shows in the header's centre. */
|
||||
/** Passed on to `AppHeader`. */
|
||||
centerSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders the header and, under it, the page's content. */
|
||||
/** Renders {@link AppHeader}, then the page's content as the `<main>`. */
|
||||
export function PageShell(
|
||||
{ children, centered = false, centerSlot }: PageShellProps,
|
||||
): React.JSX.Element {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The header's search form.
|
||||
* Renders the search form of the header.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,24 +7,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** The props of {@link SearchBar}. */
|
||||
/**
|
||||
* Passed to {@link SearchBar} by `AppHeader`, which holds the query and decides
|
||||
* where a search leads.
|
||||
*/
|
||||
export interface SearchBarProps {
|
||||
/** Whether it is open. */
|
||||
expanded: boolean;
|
||||
/** The query as typed. */
|
||||
value: string;
|
||||
/** Called as the query is typed. */
|
||||
onValueChange: (v: string) => void;
|
||||
/** Called with the trimmed query on submit. */
|
||||
/** Called with the trimmed query, never an empty one. */
|
||||
onSubmit: (q: string) => void;
|
||||
/** Escape pressed in the input. */
|
||||
/** Called when Escape is pressed in the input. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the header's search form. The header renders two of these (one in its
|
||||
* centre column, one in the row that slides open under it on small viewports)
|
||||
* over one shared state, so the draft survives crossing the breakpoint.
|
||||
* Renders the search form. `AppHeader` renders two over one state, one in its
|
||||
* center and one in the row that slides open under it on small screens, so the
|
||||
* query survives crossing the breakpoint. Opening it focuses the input.
|
||||
*/
|
||||
export function SearchBar(
|
||||
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
|
||||
@@ -33,11 +33,11 @@ export function SearchBar(
|
||||
const wasExpanded = useRef(expanded);
|
||||
|
||||
useEffect(() => {
|
||||
// Only an opening takes focus: a page that mounts with search already
|
||||
// open (the results page) mustn't raise a phone's keyboard over its
|
||||
// results. No scroll-into-view either: the below-header row is still
|
||||
// clipped while it slides open, and scrolling the clip would offset its
|
||||
// content. Of the two bars, the hidden one just ignores the call.
|
||||
// Only an opening takes focus, so that the results page, which mounts with
|
||||
// the search open, doesn't raise a phone's keyboard over its results. No
|
||||
// scroll either: the row under the header is still clipped as it slides
|
||||
// open, and scrolling the clip would offset its content. Of the two bars,
|
||||
// the hidden one ignores the focus.
|
||||
if (expanded && !wasExpanded.current) {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
} else if (!expanded && inputRef.current === document.activeElement) {
|
||||
@@ -50,8 +50,7 @@ export function SearchBar(
|
||||
e.preventDefault();
|
||||
const q = value.trim();
|
||||
if (!q) return;
|
||||
// Submitting shows results — put a phone's keyboard away so they can be
|
||||
// seen.
|
||||
// Puts a phone's keyboard away, so that the results can be seen.
|
||||
inputRef.current?.blur();
|
||||
onSubmit(q);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The header's avatar menu, on small viewports.
|
||||
* Renders the avatar menu that replaces the header's profile and playlists
|
||||
* links on small screens.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,15 +12,14 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
|
||||
/** The props of {@link UserMenu}. */
|
||||
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
|
||||
export interface UserMenuProps {
|
||||
/** The signed-in user. */
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the signed-in user's avatar, which opens a menu with their profile
|
||||
* and playlists links.
|
||||
* Renders the signed-in user's avatar, which opens a menu with links to their
|
||||
* profile and playlists. A click outside or Escape closes it.
|
||||
*/
|
||||
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The global player, docked at the bottom of every page.
|
||||
* Renders the player docked at the bottom of every page. App mounts it once,
|
||||
* beside the routes, so playback goes on from page to page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,7 +20,6 @@ import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
|
||||
|
||||
/** An embed item of the player. */
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
/**
|
||||
@@ -54,17 +54,15 @@ function TrackRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL the iframe of `item` loads. The stored embedUrl is the
|
||||
* canonical, non-playing form — it is also rendered outside the player, so the
|
||||
* autoplay parameter is added here at playback time rather than baked into
|
||||
* rich_content. Only a fresh play() sets `autoplay`: a session restored from
|
||||
* localStorage has no user gesture behind it, so it stays paused, matching how
|
||||
* MediaPlayer treats file items. Bandcamp's EmbeddedPlayer has no autoplay
|
||||
* parameter — that is what the native playback path
|
||||
* (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
||||
* Returns the URL the iframe of `item` loads. The item keeps the stored
|
||||
* `embedUrl`, which RichContentCard matches to tell which card plays, so the
|
||||
* autoplay parameter is added only here, and only for a fresh play: a restored
|
||||
* session has no user gesture behind it. Bandcamp's embed has no autoplay
|
||||
* parameter, which is why native Bandcamp playback
|
||||
* (`GERBEUR_BANDCAMP_PLAYER=native`) exists.
|
||||
*/
|
||||
function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||
// A playlist embed is also driven from the outside — see useYouTubePlaylist.
|
||||
// useYouTubePlaylist drives a playlist embed through the IFrame API.
|
||||
const base = item.type === "youtube" && embedListId(item.embedUrl)
|
||||
? withJsApi(item.embedUrl)
|
||||
: item.embedUrl;
|
||||
@@ -77,15 +75,16 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||
} else return base;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return base; // malformed stored URL — hand it to the iframe as-is
|
||||
// A malformed stored URL goes to the iframe as it is.
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders what the player plays — a media player for files and streams, the
|
||||
* provider's iframe for embeds — with its artwork, title, link to the dump,
|
||||
* transport, and the tracklist of the queue or of a YouTube playlist; nothing
|
||||
* while nothing plays.
|
||||
* Renders what {@link PlayerContext} plays, with a link to its dump: a
|
||||
* MediaPlayer for a file or a stream, the provider's iframe for an embed, and
|
||||
* the tracklist of the queue or of a YouTube playlist. It renders nothing while
|
||||
* nothing plays.
|
||||
*/
|
||||
export function GlobalPlayer(): React.JSX.Element | null {
|
||||
const {
|
||||
@@ -123,12 +122,15 @@ export function GlobalPlayer(): React.JSX.Element | null {
|
||||
: null;
|
||||
const yt = useYouTubePlaylist(ytEmbedUrl, iframeRef);
|
||||
|
||||
// A new item opens the player expanded.
|
||||
const currentKey = current ? playerItemKey(current) : null;
|
||||
if (prevKey !== currentKey) {
|
||||
setPrevKey(currentKey);
|
||||
if (current) setReduced(false);
|
||||
}
|
||||
|
||||
// App.css pads the page's bottom by --player-height while `has-player` is
|
||||
// set, so the docked player never covers the end of a page.
|
||||
useEffect(() => {
|
||||
if (!current) {
|
||||
document.body.classList.remove("has-player");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The site's audio and video player.
|
||||
* Renders an audio or video file with the site's own controls. GlobalPlayer
|
||||
* plays files and streams in it; the file previews of the dump pages and of
|
||||
* the dump form play a file in it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,42 +22,47 @@ import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
|
||||
/** How long a playing video's controls stay after the pointer stops, in ms. */
|
||||
const HIDE_DELAY = 2500;
|
||||
|
||||
/** The props of {@link MediaPlayer}. */
|
||||
/**
|
||||
* Passed to {@link MediaPlayer} by GlobalPlayer, which wires the callbacks and
|
||||
* the handles to PlayerContext, and by the file previews, which pass only the
|
||||
* media.
|
||||
*/
|
||||
export interface MediaPlayerProps {
|
||||
/** The media's URL. */
|
||||
src: string;
|
||||
/** Whether it is audio or video. */
|
||||
kind: "audio" | "video";
|
||||
/** The media's type. */
|
||||
/** The `<source>` type, when known. */
|
||||
mime?: string;
|
||||
/** A video's still before it plays. */
|
||||
/** Shown before a video plays. */
|
||||
poster?: string;
|
||||
/** Whether it starts playing on mount. */
|
||||
autoplay?: boolean;
|
||||
/** Where it starts, in seconds. */
|
||||
/** In seconds. Read on mount only: later seeks go through `seekRef`. */
|
||||
startTime?: number;
|
||||
/** Called when it starts or stops playing. */
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
/** Called as it plays, with the playhead and the duration. */
|
||||
onTimeUpdate?: (time: number, duration: number) => void;
|
||||
/** Fired when the media reaches its end — the queue's advance hook. */
|
||||
/** Called at the media's end, where GlobalPlayer plays the next item. */
|
||||
onEnded?: () => void;
|
||||
/** Fired when the element fails to load or decode. For expiring stream URLs
|
||||
* this is the signal to re-resolve. */
|
||||
/**
|
||||
* Called when the media fails to load or decode, where a stream's URL is
|
||||
* re-resolved.
|
||||
*/
|
||||
onError?: () => void;
|
||||
/** "waveform" (default) decodes peaks from the source; "progress" draws a
|
||||
* slim seek bar and never fetches the media a second time. */
|
||||
/**
|
||||
* "waveform" (the default for audio) decodes peaks from the source;
|
||||
* "progress" draws a seek bar and never fetches the media a second time.
|
||||
*/
|
||||
trackStyle?: "waveform" | "progress";
|
||||
/** Receives its seek handle, for the global player's controls. */
|
||||
/** Receives its seek handle while mounted, for PlayerContext's `seekTo`. */
|
||||
seekRef?: { current: ((t: number) => void) | null };
|
||||
/** Receives its play/pause handle, for the global player's controls. */
|
||||
/** Receives its play/pause handle while mounted, for `togglePlay`. */
|
||||
toggleRef?: { current: (() => void) | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a media element with the site's own controls: play/pause, a waveform
|
||||
* or seek bar, time, volume (remembered across players) and, for video,
|
||||
* fullscreen.
|
||||
* Renders a media element with the site's own controls. Audio gets a waveform
|
||||
* to seek on, unless `trackStyle` asks for a seek bar; video gets a seek bar, a
|
||||
* fullscreen button, and controls that hide while it plays. The volume carries
|
||||
* over to the next MediaPlayer, which matters since GlobalPlayer mounts a new
|
||||
* one for each track.
|
||||
*/
|
||||
export function MediaPlayer(
|
||||
{
|
||||
@@ -84,16 +91,14 @@ export function MediaPlayer(
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Callback refs ─────────────────────────────────────────────────────────────
|
||||
// Updated via effects (after render) so the linter doesn't flag render-phase ref
|
||||
// mutation. No cleanup → no null window. Negligible stale-window between commit
|
||||
// and effect, acceptable since these are only called from async event handlers.
|
||||
// The media listeners call the latest callbacks through these refs, without
|
||||
// being attached again. They are written after render, as the linter wants;
|
||||
// the gap before the effect doesn't matter to handlers of async events.
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onEndedRef = useRef(onEnded);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
@@ -101,12 +106,12 @@ export function MediaPlayer(
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
// Stable function refs — updated via effects, indirected by the registration
|
||||
// effect below so external seekRef/toggleRef never see a null window.
|
||||
// seekRef and toggleRef get wrappers that call these, so the handles never go
|
||||
// null while the functions are replaced.
|
||||
const seekToFnRef = useRef((_t: number) => {});
|
||||
const toggleFnRef = useRef(() => {});
|
||||
|
||||
// seekTo: all captured values are stable (setCurrent from useState, mediaRef DOM ref)
|
||||
// Everything it uses is stable, so it is set once.
|
||||
useEffect(() => {
|
||||
seekToFnRef.current = (time: number) => {
|
||||
setCurrent(time);
|
||||
@@ -114,7 +119,7 @@ export function MediaPlayer(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// toggle: captures `playing` — re-synced whenever play state changes
|
||||
// It reads `playing`, so it is replaced when that changes.
|
||||
useEffect(() => {
|
||||
toggleFnRef.current = () => {
|
||||
const a = mediaRef.current!;
|
||||
@@ -133,14 +138,10 @@ export function MediaPlayer(
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
// Stable wrappers used everywhere inside the component
|
||||
const seekTo = (time: number) => seekToFnRef.current(time);
|
||||
const toggle = () => toggleFnRef.current();
|
||||
|
||||
// ── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Resume to a restored offset once metadata (and thus seekability) is available.
|
||||
// Runs only on mount for the initial offset — later seeks go through seekTo.
|
||||
// The element can't seek before its metadata loads.
|
||||
useEffect(() => {
|
||||
if (!startTime) return;
|
||||
const a = mediaRef.current!;
|
||||
@@ -154,8 +155,7 @@ export function MediaPlayer(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Apply the remembered volume to the freshly-mounted element, which always
|
||||
// starts at 1.0 regardless of what the last track was playing at.
|
||||
// A new element starts at full volume, whatever the last one played at.
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current;
|
||||
if (!a) return;
|
||||
@@ -165,7 +165,8 @@ export function MediaPlayer(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Autoplay on mount (e.g. triggered by play() in PlayerContext)
|
||||
// The browser may refuse to play without a user gesture, and the player then
|
||||
// just shows paused.
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
mediaRef.current?.play()
|
||||
@@ -176,8 +177,8 @@ export function MediaPlayer(
|
||||
.catch(() => {});
|
||||
}, [autoplay]);
|
||||
|
||||
// On unmount: pause and cut callbacks so stale timeupdate/ended events that fire
|
||||
// between React's commit and the listener-removal effect can't reach the provider.
|
||||
// Cutting the callbacks on unmount keeps a late timeupdate or ended event of
|
||||
// this element from reaching PlayerProvider once the next item is playing.
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
return () => {
|
||||
@@ -189,8 +190,7 @@ export function MediaPlayer(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Register imperative handles into provider refs. seekRef/toggleRef are stable
|
||||
// (created with useRef in PlayerProvider), so this effect runs exactly once.
|
||||
// The refs are PlayerProvider's, which never change, so this runs once.
|
||||
useEffect(() => {
|
||||
if (seekRef) seekRef.current = (t) => seekToFnRef.current(t);
|
||||
if (toggleRef) toggleRef.current = () => toggleFnRef.current();
|
||||
@@ -200,10 +200,10 @@ export function MediaPlayer(
|
||||
};
|
||||
}, [seekRef, toggleRef]);
|
||||
|
||||
// Media element event listeners
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
const onTime = () => {
|
||||
// While the seek bar is dragged, its thumb follows the pointer.
|
||||
if (!dragging) {
|
||||
setCurrent(a.currentTime);
|
||||
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||
@@ -249,7 +249,6 @@ export function MediaPlayer(
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// Hide video controls after inactivity
|
||||
useEffect(() => {
|
||||
if (kind !== "video") return;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
@@ -264,8 +263,6 @@ export function MediaPlayer(
|
||||
};
|
||||
}, [playing, kind]);
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const showingControls = kind !== "video" || !playing || controlsVisible;
|
||||
|
||||
const showControlsTemporarily = () => {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Player control icons, shared by MediaPlayer and FilePreview.
|
||||
* Draws the icons of the media controls, for MediaPlayer and the dump page's
|
||||
* audio preview (`FilePreview`). They take the text colour, so the buttons'
|
||||
* CSS colours them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Renders the play icon. */
|
||||
/** Renders the play icon, nudged right so the triangle looks centred. */
|
||||
export const IconPlay = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the pause icon. */
|
||||
/** Renders the pause icon, the play button's other state. */
|
||||
export const IconPause = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
@@ -19,13 +21,12 @@ export const IconPause = (): React.JSX.Element => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** The props of {@link IconVolume}. */
|
||||
/** Passed to {@link IconVolume} by MediaPlayer's mute button. */
|
||||
export interface IconVolumeProps {
|
||||
/** Whether the sound is off. */
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
/** Renders the volume icon, struck through when muted. */
|
||||
/** Renders the mute button's icon, crossed when `muted`. */
|
||||
export const IconVolume = (
|
||||
{ muted }: IconVolumeProps,
|
||||
): React.JSX.Element => (
|
||||
@@ -40,7 +41,7 @@ export const IconVolume = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the fullscreen icon. */
|
||||
/** Renders the icon of a video's fullscreen button. */
|
||||
export const IconFullscreen = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
/**
|
||||
* A slim seek bar.
|
||||
* Renders the slim seek bar MediaPlayer shows for video and for streams.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** The props of {@link SeekBar}. */
|
||||
/** Passed to {@link SeekBar} by MediaPlayer. Times are in seconds. */
|
||||
export interface SeekBarProps {
|
||||
/** The playhead, in seconds. */
|
||||
current: number;
|
||||
/** The media's length, in seconds. */
|
||||
duration: number;
|
||||
/** Called with the time sought to. */
|
||||
onSeek: (t: number) => void;
|
||||
/** Called when a drag starts and ends. */
|
||||
/**
|
||||
* Called as the thumb is grabbed and let go, so that MediaPlayer stops moving
|
||||
* it with the playhead in between.
|
||||
*/
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a slim progress + scrub bar. Used by video, and by audio sources
|
||||
* whose peaks can't be decoded (cross-origin streams) — where a waveform would
|
||||
* be a lie.
|
||||
* Renders a progress bar with a range input over it to seek. MediaPlayer uses
|
||||
* it for video, and for streams, whose bytes can't be read cross-origin to
|
||||
* draw a waveform.
|
||||
*/
|
||||
export function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: SeekBarProps,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* An audio file's waveform, which is also its seek bar.
|
||||
* Renders an audio file's waveform, which MediaPlayer shows as its seek bar.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,21 +14,19 @@ import {
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/player/waveform.ts";
|
||||
|
||||
/** The props of {@link Waveform}. */
|
||||
/** Passed to {@link Waveform} by MediaPlayer. Times are in seconds. */
|
||||
export interface WaveformProps {
|
||||
/** The audio's URL. */
|
||||
/** Fetched again, apart from the media element, to decode its peaks. */
|
||||
src: string;
|
||||
/** The playhead, in seconds. */
|
||||
current: number;
|
||||
/** The audio's length, in seconds. */
|
||||
duration: number;
|
||||
/** Called with the time clicked. */
|
||||
onSeek: (t: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the audio's peaks as bars, played ones highlighted, seeking where
|
||||
* clicked.
|
||||
* Renders the peaks of `src` as bars, the played ones highlighted, and seeks
|
||||
* where clicked. Until the peaks are decoded, or if they can't be, it shows a
|
||||
* plain progress bar that seeks all the same.
|
||||
*/
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: WaveformProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The modal that adds a dump to playlists.
|
||||
* Renders the modal in which the dump page's "add to playlist" button adds the
|
||||
* dump to the user's playlists or takes it out of them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,18 +15,16 @@ import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
|
||||
/** The props of {@link AddToPlaylistModal}. */
|
||||
/** Passed to {@link AddToPlaylistModal} by the dump page. */
|
||||
export interface AddToPlaylistModalProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a modal listing the user's playlists, each toggling whether it holds
|
||||
* the dump, with a form to make a new one. A toggle shows once the server
|
||||
* agrees.
|
||||
* Renders a {@link PlaylistMembershipPanel} in a modal, for the playlists
|
||||
* `GET /api/playlists/by-dump/:dumpId/memberships` answers. A toggle adds or
|
||||
* removes the dump on the server and shows only once the server agrees.
|
||||
*/
|
||||
export function AddToPlaylistModal(
|
||||
{ dumpId, onClose }: AddToPlaylistModalProps,
|
||||
@@ -52,8 +51,7 @@ export function AddToPlaylistModal(
|
||||
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
const { playlist, hasDump } = membership;
|
||||
// The box only changes once the server agrees; a refusal or a dropped
|
||||
// connection leaves it showing the truth.
|
||||
// A refusal or a dropped connection leaves the box showing the truth.
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The new-playlist button.
|
||||
* Renders the button that creates a playlist in a modal, on the user's own
|
||||
* profile and playlists pages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,17 +12,21 @@ import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
/** The props of {@link NewPlaylistButton}. */
|
||||
/**
|
||||
* Passed to {@link NewPlaylistButton} by the user's profile and playlists
|
||||
* pages, which add the new playlist to their list through `onCreated`.
|
||||
*/
|
||||
export interface NewPlaylistButtonProps {
|
||||
/** Called with the playlist created. */
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
/** The button's label; "+ New playlist" by default. */
|
||||
/** "+ New playlist" by default. */
|
||||
toggleLabel?: string;
|
||||
/** The button's class. */
|
||||
toggleClassName?: string;
|
||||
}
|
||||
|
||||
/** Renders a button that opens the playlist creation form in a modal. */
|
||||
/**
|
||||
* Renders a button that opens {@link PlaylistCreateForm} in a modal, which
|
||||
* closes once the playlist is created.
|
||||
*/
|
||||
export function NewPlaylistButton(
|
||||
{
|
||||
onCreated,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A playlist, as a card in a list.
|
||||
* Renders the card that shows a playlist in profiles, search results and the
|
||||
* dump page's "In collections" section.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,20 +19,22 @@ import {
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
/** The props of {@link PlaylistCard}. */
|
||||
/** Passed to {@link PlaylistCard} by each list of playlists. */
|
||||
export interface PlaylistCardProps {
|
||||
/** The playlist. */
|
||||
playlist: Playlist;
|
||||
/** Deletes it; no delete button without it. */
|
||||
/**
|
||||
* Shows a delete button, which calls it. The owner's playlists pages pass
|
||||
* one that asks for confirmation first.
|
||||
*/
|
||||
onDelete?: () => void;
|
||||
/** Whether the viewer owns it: no owner link, no unread dot. */
|
||||
/** Hides the owner link and the unread dot. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a playlist as a card: its cover, title, description, visibility,
|
||||
* owner, size and age, with an unread dot until it is opened. The card opens
|
||||
* the playlist.
|
||||
* Renders a playlist as a card that opens the playlist when clicked. Another
|
||||
* user's recent playlist shows an unread dot until it is opened on this
|
||||
* device. The card is an `<li>`, for a `<ul>`.
|
||||
*/
|
||||
export function PlaylistCard(
|
||||
{ playlist, onDelete, isOwner }: PlaylistCardProps,
|
||||
@@ -70,6 +73,7 @@ export function PlaylistCard(
|
||||
to={playlistUrl(playlist)}
|
||||
className="playlist-card-title"
|
||||
onClick={(e) => {
|
||||
// Or the card's handler navigates a second time.
|
||||
e.stopPropagation();
|
||||
markPlaylistVisited(playlist.id);
|
||||
}}
|
||||
@@ -121,6 +125,7 @@ export function PlaylistCard(
|
||||
type="button"
|
||||
className="playlist-card-delete-btn"
|
||||
onClick={(e) => {
|
||||
// Deleting must not open the playlist.
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The playlist creation form.
|
||||
* Renders the form that creates a playlist, in NewPlaylistButton's modal and in
|
||||
* PlaylistMembershipPanel.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,19 +26,18 @@ import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { VisibilityToggle } from "../form/SegmentedField.tsx";
|
||||
|
||||
/** The props of {@link PlaylistCreateForm}. */
|
||||
/**
|
||||
* Passed to {@link PlaylistCreateForm} by NewPlaylistButton and
|
||||
* PlaylistMembershipPanel.
|
||||
*/
|
||||
export interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
/** Added to the new playlist, which the submit button says. */
|
||||
dumpId?: string;
|
||||
/** Called with the playlist created. */
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
/** Called by the Cancel button. */
|
||||
onCancel: () => void;
|
||||
/** An extra class for the form. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -45,8 +45,8 @@ interface Values {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that creates a playlist — title, description, visibility —
|
||||
* and adds `dumpId` to it when given.
|
||||
* Renders the form that creates a playlist with `POST /api/playlists`, then
|
||||
* adds `dumpId` to it when given. A failure to add the dump goes unreported.
|
||||
*/
|
||||
export function PlaylistCreateForm(
|
||||
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The list of a user's playlists, each toggling whether it holds a dump.
|
||||
* Renders the user's playlists as toggles of whether each holds a dump, in
|
||||
* AddToPlaylistModal and in the last step of the dump form.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,23 +10,23 @@ import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistMembership } from "../../../shared/playlist.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
/** The props of {@link PlaylistMembershipPanel}. */
|
||||
/**
|
||||
* Passed to {@link PlaylistMembershipPanel} by AddToPlaylistModal and the dump
|
||||
* form, which fetch the memberships and call the server on a toggle. The panel
|
||||
* only shows them.
|
||||
*/
|
||||
export interface PlaylistMembershipPanelProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The user's playlists, and whether each holds the dump. */
|
||||
memberships: PlaylistMembership[];
|
||||
/** Whether the list is loading. */
|
||||
loading: boolean;
|
||||
/** Called with the playlist toggled. */
|
||||
onToggle: (membership: PlaylistMembership) => void;
|
||||
/** Called with a playlist created from the panel, which holds the dump. */
|
||||
onPlaylistCreated: (membership: PlaylistMembership) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the user's playlists as checkboxes of whether each holds the dump,
|
||||
* and a form to make a new one with it.
|
||||
* Renders the playlists as checkboxes, which the keyboard toggles too, and a
|
||||
* button that opens {@link PlaylistCreateForm} to make one holding the dump.
|
||||
*/
|
||||
export function PlaylistMembershipPanel({
|
||||
dumpId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The password change modal.
|
||||
* Renders the password change dialog, which the settings tab of one's own
|
||||
* profile opens.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,13 +20,11 @@ import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The props of {@link ChangePasswordModal}. */
|
||||
/** Passed to {@link ChangePasswordModal} by `UserPublicProfile`. */
|
||||
export interface ChangePasswordModalProps {
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
@@ -33,9 +32,8 @@ interface Values {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that changes the user's password, given the current one. The
|
||||
* change signs out every other session, and this one keeps the fresh tokens it
|
||||
* is handed.
|
||||
* Renders the form that changes the user's password, given the current one.
|
||||
* The change signs out every other session, in every browser.
|
||||
*/
|
||||
export function ChangePasswordModal(
|
||||
{ onClose }: ChangePasswordModalProps,
|
||||
@@ -52,8 +50,8 @@ export function ChangePasswordModal(
|
||||
});
|
||||
|
||||
const onSubmit = form.submit(async ({ currentPassword, newPassword }) => {
|
||||
// The change signs out every session opened with the old password, this
|
||||
// one included, so keep the fresh tokens it hands back.
|
||||
// The server revokes every token issued before the change, this session's
|
||||
// included, and hands back fresh ones. Other tabs read them from storage.
|
||||
const tokens = await expectOk<{ token: string; mediaToken: string }>(
|
||||
await authFetch(`${API_URL}/api/users/me/change-password`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The header of a profile's subpage.
|
||||
* Renders the header of a profile's full lists, `/users/:username/dumps`,
|
||||
* `/upvoted` and `/playlists`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,20 +10,21 @@ import { Link } from "react-router";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
|
||||
/** The props of {@link ProfileSubpageHeader}. */
|
||||
/**
|
||||
* Passed to {@link ProfileSubpageHeader} by each of those pages, once it has
|
||||
* loaded the profile's user.
|
||||
*/
|
||||
export interface ProfileSubpageHeaderProps {
|
||||
/** The name in the route, for the back link. */
|
||||
/** The `:username` of the route, which the back link leads to. */
|
||||
username: string;
|
||||
/** The profile's user. */
|
||||
profileUser: PublicUser;
|
||||
/** The subpage's title. */
|
||||
title: string;
|
||||
/** Buttons on the title row. */
|
||||
/** Buttons at the end of the title row. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a link back to the profile, and the subpage's title beside the user's
|
||||
* Renders a link back to the profile, then the page's title beside the user's
|
||||
* avatar.
|
||||
*/
|
||||
export function ProfileSubpageHeader(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's avatar, or their initial.
|
||||
* Renders a user's avatar, or their initial, wherever a user is named: cards,
|
||||
* comments, chat, profiles and mention suggestions.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,24 +9,26 @@ import { useContext, useState } from "react";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
|
||||
|
||||
/** The props of {@link Avatar}. */
|
||||
/**
|
||||
* Passed to {@link Avatar} from whatever data the caller has on the user, which
|
||||
* may be stale: a live override from `AvatarOverrideContext` wins over
|
||||
* `hasAvatar` and `version`.
|
||||
*/
|
||||
export interface AvatarProps {
|
||||
/** The user's id. */
|
||||
userId: string;
|
||||
/** The user's name, for the alt text and the initial. */
|
||||
username: string;
|
||||
/** Whether they have an avatar, as the surrounding data says. */
|
||||
hasAvatar: boolean;
|
||||
/** The side, in px; 36 by default. */
|
||||
/** Sets the side, in pixels. */
|
||||
size?: number;
|
||||
/** Cache-busts the avatar URL. */
|
||||
/** Added to the URL so that a changed avatar isn't served from cache. */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a user's avatar, or their initial when they have none or it fails to
|
||||
* load. A live `user_updated` override (avatar add/remove/change) wins over the
|
||||
* possibly-stale props the surrounding list was rendered with.
|
||||
* Renders the image from `GET /api/avatars/:userId`, or the user's initial
|
||||
* when they have none or it fails to load. An avatar added, changed or removed
|
||||
* since the caller's data was fetched shows at once, through the
|
||||
* `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`.
|
||||
*/
|
||||
export function Avatar(
|
||||
{ userId, username, hasAvatar, size = 36, version }: AvatarProps,
|
||||
@@ -33,14 +36,12 @@ export function Avatar(
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const sizeStyle = { width: size, height: size };
|
||||
|
||||
// A live `user_updated` override (avatar add/remove/change) wins over the
|
||||
// possibly-stale props the surrounding list was rendered with.
|
||||
const override = useContext(AvatarOverrideContext)[userId];
|
||||
const effectiveHasAvatar = override ? override.hasAvatar : hasAvatar;
|
||||
const effectiveVersion = override ? override.version : version;
|
||||
|
||||
// A new image (different version/presence) is a fresh thing to try — clear
|
||||
// any prior load failure during render rather than in an effect.
|
||||
// A new image deserves a new try, so a past load failure is cleared. It is
|
||||
// done during render, since an effect would show the initial for a frame.
|
||||
const imgKey = `${effectiveHasAvatar}:${effectiveVersion ?? ""}`;
|
||||
const [prevImgKey, setPrevImgKey] = useState(imgKey);
|
||||
if (prevImgKey !== imgKey) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A confirmation dialog, for destructive actions.
|
||||
* Renders the dialog that confirms a deletion, on the pages and in the dialogs
|
||||
* that delete something.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,21 +11,22 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
/** The props of {@link ConfirmModal}. */
|
||||
/**
|
||||
* Passed to {@link ConfirmModal} by the component that shows it. The caller
|
||||
* decides when it shows and hides it, on either callback.
|
||||
*/
|
||||
export interface ConfirmModalProps {
|
||||
/** The question asked. */
|
||||
message: string;
|
||||
/** The confirm button's label; "Delete" by default. */
|
||||
/** Replaces the "Delete" label. */
|
||||
confirmLabel?: string;
|
||||
/** Called on confirm. */
|
||||
onConfirm: () => void;
|
||||
/** Called on cancel, Escape or a backdrop click. */
|
||||
/** Called on Cancel, Escape or a click on the backdrop. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a confirmation dialog over the page. It takes Escape before a {@link
|
||||
* Modal} it sits on, so only the confirm goes.
|
||||
* Renders the dialog in a portal over the page, and locks the page's scroll.
|
||||
* Escape closes only the confirmation, even over a {@link Modal}.
|
||||
*/
|
||||
export function ConfirmModal(
|
||||
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
|
||||
@@ -34,10 +36,9 @@ export function ConfirmModal(
|
||||
useScrollLock();
|
||||
|
||||
useEffect(() => {
|
||||
// A confirm can sit on top of a Modal (deleting a chat message), whose
|
||||
// own Escape handler is also on the document and was added first. Take
|
||||
// the key in the capture phase and mark it handled, so the Modal leaves
|
||||
// it alone and only the confirm goes.
|
||||
// A confirmation can sit over a Modal (deleting a chat message), whose
|
||||
// Escape handler is on the document too and was added first. Taking the
|
||||
// key in the capture phase and marking it handled makes the Modal leave it.
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape" || e.defaultPrevented) return;
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A text input with a character counter.
|
||||
* Renders a controlled text input with a character counter. Unlike `TextField`,
|
||||
* it takes any input prop, for an inline editor such as a playlist's title.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,20 +8,20 @@
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
/**
|
||||
* The props of {@link CountedInput}: an input's, with a required value and
|
||||
* limit.
|
||||
* Passed to {@link CountedInput} by its caller, which holds the value. The rest
|
||||
* goes to the `<input>`.
|
||||
*/
|
||||
export interface CountedInputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
|
||||
/** The input's value. */
|
||||
value: string;
|
||||
/** The most characters it takes. */
|
||||
/** Stops the typing at this length, and is the counter's limit. */
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a text input with a counter that warns as the value nears its limit.
|
||||
* Renders the input and an `n / maxLength` counter, which changes colour as the
|
||||
* value nears its limit (`charCountClass`).
|
||||
*/
|
||||
export function CountedInput(
|
||||
{ value, maxLength, ...rest }: CountedInputProps,
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
/**
|
||||
* An error, as a card.
|
||||
* Renders an error as a card, in place of what failed to load or save.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** The props of {@link ErrorCard}. */
|
||||
/**
|
||||
* Passed to {@link ErrorCard} by a page or a form. The `message` usually comes
|
||||
* from `friendlyFetchError`.
|
||||
*/
|
||||
export interface ErrorCardProps {
|
||||
/** What failed. */
|
||||
/** Says what failed. */
|
||||
title: string;
|
||||
/** Why. */
|
||||
message: string;
|
||||
/** Buttons offered under it (retry, go back…). */
|
||||
/** Buttons shown under the card, such as "Retry". */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders an error as a card, with optional actions under it. */
|
||||
/**
|
||||
* Renders the card as an ARIA alert, so a screen reader announces it when it
|
||||
* appears.
|
||||
*/
|
||||
export function ErrorCard(
|
||||
{ title, message, actions }: ErrorCardProps,
|
||||
): React.JSX.Element {
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
/**
|
||||
* An image that is also its own upload button.
|
||||
* Renders an image that is also its own upload button, for a dump's thumbnail
|
||||
* and a playlist's cover.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
/** The props of {@link ImagePicker}. */
|
||||
/**
|
||||
* Passed to {@link ImagePicker} by an edit page, which uploads the file and
|
||||
* then passes the new image, or a local preview of it, as `src`.
|
||||
*/
|
||||
export interface ImagePickerProps {
|
||||
/** The current image, null without one. */
|
||||
/** Shows a "+" placeholder when null. */
|
||||
src: string | null;
|
||||
/** Its alt text. */
|
||||
alt?: string;
|
||||
/** The side, in px; 80 by default. */
|
||||
/** Sets the side, in pixels. */
|
||||
size?: number;
|
||||
/** The corner radius, in px; 8 by default. */
|
||||
borderRadius?: number;
|
||||
/** Called with the file picked. */
|
||||
/** Called with the file picked. The same file can be picked again. */
|
||||
onChange: (file: File) => void;
|
||||
/** Whether an upload is under way: the picker is disabled meanwhile. */
|
||||
/** Disables the picker and shows "…" while the caller uploads. */
|
||||
uploading?: boolean;
|
||||
/** The file types offered; JPEG, PNG, GIF and WebP by default. */
|
||||
/** Replaces the default types, which are JPEG, PNG, GIF and WebP. */
|
||||
accept?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an image (or a placeholder) that opens a file picker when clicked.
|
||||
* Renders the image, or a placeholder, which opens the file picker on a click,
|
||||
* Enter or Space. The picker only reads the file, and uploads nothing itself.
|
||||
*/
|
||||
export function ImagePicker({
|
||||
src,
|
||||
@@ -45,6 +47,7 @@ export function ImagePicker({
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onChange(file);
|
||||
// Or picking the same file again would fire no change.
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A modal dialog.
|
||||
* Renders the dialog frame of the app's dialogs: creating a dump, the chat,
|
||||
* adding to a playlist, changing a password.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,27 +10,27 @@ import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
/** The props of {@link Modal}. */
|
||||
/**
|
||||
* Passed to {@link Modal} by the component that shows it, which unmounts it in
|
||||
* `onClose`.
|
||||
*/
|
||||
export interface ModalProps {
|
||||
/** The dialog's title. */
|
||||
title: string;
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
/** The dialog's body. */
|
||||
children: ReactNode;
|
||||
/** Whether it takes the wide layout. */
|
||||
wide?: boolean;
|
||||
/**
|
||||
* Runs before every dismissal the user did not aim at the content — Escape,
|
||||
* the backdrop, the ✕. Returning `false` cancels it, which lets a modal
|
||||
* holding an unsaved draft ask first instead of throwing the work away.
|
||||
* Runs before a dismissal by Escape, the backdrop or the ✕. Returning
|
||||
* `false` cancels it, so that a dialog holding an unsaved draft can ask
|
||||
* first.
|
||||
*/
|
||||
onBeforeClose?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a modal dialog over the page, closed by Escape, the backdrop or its
|
||||
* ✕, and locks the page's scroll while it is open.
|
||||
* Renders the dialog in a portal over the page, closed by Escape, the backdrop
|
||||
* or its ✕, and locks the page's scroll while it is open. Content that handles
|
||||
* Escape itself must call `preventDefault` on it, or the dialog closes too.
|
||||
*/
|
||||
export function Modal(
|
||||
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* An external thumbnail, with fallbacks down to a placeholder.
|
||||
* Renders a dump's preview image from another site, in the cards and the
|
||||
* player, falling back to a generated placeholder.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -11,36 +12,29 @@ import {
|
||||
type ThumbnailPlaceholderProps,
|
||||
} from "./ThumbnailPlaceholder.tsx";
|
||||
|
||||
/** The props of {@link Thumbnail}. */
|
||||
/**
|
||||
* Passed to {@link Thumbnail} by a card or the player, from the dump's rich
|
||||
* content.
|
||||
*/
|
||||
export interface ThumbnailProps {
|
||||
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||
/** The image's own URL, not proxied, since the fallbacks decide when to. */
|
||||
src?: string;
|
||||
/** Its alt text. */
|
||||
alt?: string;
|
||||
/** The image's class. */
|
||||
/** Applies to the image only. */
|
||||
className?: string;
|
||||
/** When to load it; lazily by default. */
|
||||
loading?: "lazy" | "eager";
|
||||
/** Passed through to the placeholder when there's nothing to show. */
|
||||
/** Shown when there's no `src` or it fails to load. */
|
||||
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
|
||||
/** Extra class for the placeholder element only. */
|
||||
placeholderClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an external thumbnail, and always renders *something*.
|
||||
*
|
||||
* Three attempts, in order:
|
||||
*
|
||||
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
|
||||
* one click) answers a cross-site referrer with 403, and an empty one with
|
||||
* 200;
|
||||
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
|
||||
* that reject an empty referrer too;
|
||||
* 3. fall back to the generated placeholder.
|
||||
*
|
||||
* Before this existed each of these failures ended at `display: none`, leaving
|
||||
* an empty framed box and no signal anywhere that a load had failed.
|
||||
* Renders the image, and always renders something. It first loads the image
|
||||
* with no `Referer`, since hotlink protection (Cloudflare's is one click)
|
||||
* refuses a cross-site referrer but lets an empty one through. It then retries
|
||||
* through `GET /api/proxy-image` for the hosts that refuse an empty referrer
|
||||
* too, and ends with the {@link ThumbnailPlaceholder}. An HTTP image goes
|
||||
* through the proxy from the first try, to avoid mixed content.
|
||||
*/
|
||||
export function Thumbnail(
|
||||
{
|
||||
@@ -56,8 +50,8 @@ export function Thumbnail(
|
||||
"direct",
|
||||
);
|
||||
|
||||
// A new src is a fresh thing to try — reset during render rather than in an
|
||||
// effect (same shape as Avatar.tsx).
|
||||
// A new src starts over. It is reset during render, as in Avatar.tsx, since
|
||||
// an effect would show the placeholder for a frame.
|
||||
const [prevSrc, setPrevSrc] = useState(src);
|
||||
if (prevSrc !== src) {
|
||||
setPrevSrc(src);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Generated artwork for a page with no preview image.
|
||||
* Renders the artwork that stands in for a dump with no preview image, in the
|
||||
* cards and the player. `Thumbnail` falls back to it when its image fails.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,31 +9,29 @@ import { useState } from "react";
|
||||
import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
|
||||
|
||||
/** The props of {@link ThumbnailPlaceholder}. */
|
||||
/**
|
||||
* Passed to {@link ThumbnailPlaceholder} by a card, from the dump and its rich
|
||||
* content, or through `Thumbnail`'s `placeholder`.
|
||||
*/
|
||||
export interface ThumbnailPlaceholderProps {
|
||||
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||
/** The dumped URL, whose hostname seeds the hue and the initial. */
|
||||
url?: string;
|
||||
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
|
||||
/** Seeds the hue for a file dump, which has no URL to hash. */
|
||||
seed?: string;
|
||||
/** The target page's declared brand color, if it had one. */
|
||||
/** The dumped page's `theme-color`, used as the tint when it is valid. */
|
||||
accentColor?: string;
|
||||
/** The target page's icon, drawn contained over the tint. */
|
||||
faviconUrl?: string;
|
||||
/** Preferred source for the initial when there's no favicon. */
|
||||
/** Gives the initial ahead of the hostname. */
|
||||
siteName?: string;
|
||||
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
|
||||
/** Replaces the initial, since a file dump says more with 🎬 than with "T". */
|
||||
glyph?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders stand-in artwork for pages that offer no preview image: the site's
|
||||
* own color washed over the theme surface, with its favicon centered on top —
|
||||
* or its initial when the favicon is missing or fails to load.
|
||||
*
|
||||
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||
* Renders the site's colour washed over the theme's surface, with its favicon
|
||||
* on top, or its initial when the favicon is missing or fails to load. It fills
|
||||
* whatever box it is placed in, from a small thumbnail to a mosaic tile.
|
||||
*/
|
||||
export function ThumbnailPlaceholder(
|
||||
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
|
||||
@@ -40,8 +39,8 @@ export function ThumbnailPlaceholder(
|
||||
): React.JSX.Element {
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
|
||||
// A different icon is a fresh thing to try — clear the prior failure during
|
||||
// render rather than in an effect (same shape as Avatar.tsx).
|
||||
// A new icon deserves a new try. The failure is cleared during render, as in
|
||||
// Avatar.tsx, since an effect would show the initial for a frame.
|
||||
const [prevIcon, setPrevIcon] = useState(faviconUrl);
|
||||
if (prevIcon !== faviconUrl) {
|
||||
setPrevIcon(faviconUrl);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A tooltip.
|
||||
* Renders a tooltip on hover. Every caller uses it to give the full date over a
|
||||
* relative time or an "edited" mark.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,15 +8,18 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The props of {@link Tooltip}. */
|
||||
/** Passed to {@link Tooltip} by the component that wraps a label in it. */
|
||||
export interface TooltipProps {
|
||||
/** The tooltip's text. */
|
||||
text: string;
|
||||
/** What it is shown over. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Renders `children` with a tooltip above them on hover. */
|
||||
/**
|
||||
* Renders `children` in a `<span>`, with `text` above them while the mouse is
|
||||
* over them. The tooltip is portaled to `<body>`, so no container clips it,
|
||||
* and it stays where it opened if the page scrolls. It never shows on touch or
|
||||
* keyboard focus.
|
||||
*/
|
||||
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The follow buttons, for a user and for a playlist.
|
||||
* Renders the Follow buttons of a user's profile and of a playlist's page.
|
||||
* Both go through `FollowContext`, which follows at once and rolls back when
|
||||
* the server refuses.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,25 +11,24 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/social/useFollows.ts";
|
||||
|
||||
/** The props of {@link FollowUserButton}. */
|
||||
/** Passed to {@link FollowUserButton} by the `UserPublicProfile` page. */
|
||||
export interface FollowUserButtonProps {
|
||||
/** The user's id. */
|
||||
targetUserId: string;
|
||||
/** The user's name, for the button's label. */
|
||||
/** Names the user in the button's accessible label. */
|
||||
targetUsername: string;
|
||||
}
|
||||
|
||||
/** The props of {@link FollowPlaylistButton}. */
|
||||
/** Passed to {@link FollowPlaylistButton} by the `PlaylistDetail` page. */
|
||||
export interface FollowPlaylistButtonProps {
|
||||
/** The playlist's id. */
|
||||
targetPlaylistId: string;
|
||||
/** Whether it is public; only a public playlist can be followed. */
|
||||
/** Hides the button, since only a public playlist can be followed. */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button that follows or unfollows a user; nothing when signed out
|
||||
* or on one's own profile.
|
||||
* Renders the button that follows or unfollows a user. It renders nothing for
|
||||
* a visitor or on the user's own profile, and is disabled until the follows
|
||||
* have loaded.
|
||||
*/
|
||||
export function FollowUserButton(
|
||||
{ targetUserId, targetUsername }: FollowUserButtonProps,
|
||||
@@ -56,8 +57,10 @@ export function FollowUserButton(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button that follows or unfollows a playlist; nothing when signed
|
||||
* out or for a private playlist.
|
||||
* Renders the button that follows or unfollows a playlist. It renders nothing
|
||||
* for a visitor or a private playlist, and is disabled until the follows have
|
||||
* loaded. Unlike {@link FollowUserButton}, it shows on the user's own
|
||||
* playlists too.
|
||||
*/
|
||||
export function FollowPlaylistButton(
|
||||
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A comment's like button.
|
||||
* Renders the like button of a comment in a dump's thread.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,25 +9,27 @@ import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
/** The props of {@link LikeButton}. */
|
||||
/**
|
||||
* Passed to {@link LikeButton} by `CommentNode`, with the state and the
|
||||
* functions of `WSContext`.
|
||||
*/
|
||||
export interface LikeButtonProps {
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Its like count. */
|
||||
/** The socket's live count if it has one, else the comment's `likeCount`. */
|
||||
count: number;
|
||||
/** Whether the user likes it. */
|
||||
liked: boolean;
|
||||
/** Whether it is disabled (signed out). */
|
||||
/** Set for a visitor, whom the tooltip asks to log in. */
|
||||
disabled?: boolean;
|
||||
/** Likes the comment; `shownCount` is the count on screen. */
|
||||
/** Called with `count`, which `WSContext` needs until it has a live count. */
|
||||
onLike: (commentId: string, shownCount?: number) => void;
|
||||
/** Withdraws the like; `shownCount` is the count on screen. */
|
||||
/** Called with `count`, as `onLike` is. */
|
||||
onUnlike: (commentId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment's like button with its count, which opens the list of who
|
||||
* likes it.
|
||||
* Renders a comment's like button and its count. Clicking the count, when it
|
||||
* isn't zero, opens the list of likers (`GET /api/comments/:id/likers`)
|
||||
* instead.
|
||||
*/
|
||||
export function LikeButton(
|
||||
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
|
||||
@@ -54,6 +56,7 @@ export function LikeButton(
|
||||
ref={countRef}
|
||||
className="like-count-clickable"
|
||||
onClick={(e) => {
|
||||
// Or the button would toggle the like too.
|
||||
e.stopPropagation();
|
||||
setPopoverOpen((o) => !o);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A popover listing users: who voted for a dump, who likes a comment.
|
||||
* Renders the list of users behind a count. `VoteButton` opens it on a dump's
|
||||
* voters, and `LikeButton` on a comment's likers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,17 +18,21 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
|
||||
/** The props of {@link UserListPopover}. */
|
||||
/**
|
||||
* Passed to {@link UserListPopover} by the button whose count was clicked. The
|
||||
* button owns the open state and unmounts the popover in `onClose`.
|
||||
*/
|
||||
export interface UserListPopoverProps {
|
||||
/** The element it opens against. */
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Called to close it: an outside click, Escape, or a scroll. */
|
||||
/** Called on an outside click, on Escape, and on any scroll. */
|
||||
onClose: () => void;
|
||||
/** The paginated endpoint listing the users. */
|
||||
/**
|
||||
* A paginated endpoint answering `PaginatedData<PublicUser>`, without a query
|
||||
* string: the popover appends `page` and `limit`.
|
||||
*/
|
||||
fetchUrl: string;
|
||||
}
|
||||
|
||||
/** The list's state: loading, failed, or loaded with a page of users. */
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -39,7 +44,8 @@ type State =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** Where the popover sits, in viewport coordinates. */
|
||||
// In viewport coordinates. `bottom` replaces `top` when the popover opens above
|
||||
// the anchor, so that it grows upward.
|
||||
interface Placement {
|
||||
left: number;
|
||||
top?: number;
|
||||
@@ -47,17 +53,17 @@ interface Placement {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
/** The popover's preferred largest height, in px. */
|
||||
const PREFERRED_MAX_HEIGHT = 280;
|
||||
/** The space kept clear of the viewport's edges, in px. */
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
/** The least height worth opening below the anchor, in px. */
|
||||
// Below this much room under the anchor, the popover opens above it instead,
|
||||
// if there is more room there.
|
||||
const MIN_USABLE_HEIGHT = 100;
|
||||
|
||||
/**
|
||||
* Renders the users from `fetchUrl`, loading more on demand, in a popover
|
||||
* against `anchorRef`. It is fixed and portaled to <body> so a card's overflow
|
||||
* can't clip it, and flips above the anchor when there isn't enough room below.
|
||||
* Renders the users from `fetchUrl` in a popover next to `anchorRef`, with a
|
||||
* button that loads the next page. It is fixed and portaled to `<body>`, so a
|
||||
* card's `overflow` can't clip it. It renders nothing until it has measured
|
||||
* the anchor.
|
||||
*/
|
||||
export function UserListPopover(
|
||||
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
|
||||
@@ -67,11 +73,8 @@ export function UserListPopover(
|
||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Position via the anchor's viewport rect (mirrors Tooltip.tsx) — fixed +
|
||||
// portaled to <body> so the popover can't be clipped by a card's overflow.
|
||||
// Flips above the anchor (anchored from the viewport bottom edge, so it
|
||||
// grows upward) when there isn't enough room below, so a long list never
|
||||
// overflows past the bottom of the viewport.
|
||||
// Placed once, on open: any scroll closes the popover, so it never needs to
|
||||
// follow the anchor.
|
||||
useEffect(() => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect) {
|
||||
@@ -117,6 +120,7 @@ export function UserListPopover(
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
// Captured, so a scroll inside any container closes it too.
|
||||
function onScroll() {
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A dump's vote button.
|
||||
* Renders the upvote button of a dump, on its card and on its page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,24 +9,26 @@ import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
/** The props of {@link VoteButton}. */
|
||||
/**
|
||||
* Passed to {@link VoteButton} by `DumpCard`, `JournalCard` and the `Dump`
|
||||
* page, with the state and the functions of `WSContext`.
|
||||
*/
|
||||
export interface VoteButtonProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Its vote count. */
|
||||
/** The socket's live count if it has one, else the dump's `voteCount`. */
|
||||
count: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether it is disabled (signed out). */
|
||||
/** Set for a visitor, whom the tooltip asks to log in. */
|
||||
disabled?: boolean;
|
||||
/** Upvotes the dump; `shownCount` is the count on screen. */
|
||||
/** Called with `count`, which `WSContext` needs until it has a live count. */
|
||||
onCast: (dumpId: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote; `shownCount` is the count on screen. */
|
||||
/** Called with `count`, as `onCast` is. */
|
||||
onRemove: (dumpId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dump's vote button with its count, which opens the list of voters.
|
||||
* Renders a dump's upvote button and its count. Clicking the count, when it
|
||||
* isn't zero, opens the list of voters (`GET /api/dumps/:id/voters`) instead.
|
||||
*/
|
||||
export function VoteButton(
|
||||
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
|
||||
@@ -50,6 +52,7 @@ export function VoteButton(
|
||||
ref={countRef}
|
||||
className="vote-count-clickable"
|
||||
onClick={(e) => {
|
||||
// Or the button would toggle the vote too.
|
||||
e.stopPropagation();
|
||||
setPopoverOpen((o) => !o);
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Where the API and the socket live, and the page sizes the feeds ask for.
|
||||
* Gives every request of the frontend its base URL, and the feeds their page
|
||||
* sizes.
|
||||
*
|
||||
* In dev (Vite dev server), the frontend and API run on different ports, so the
|
||||
* URLs are absolute; `VITE_API_*` variables can override the defaults. In prod
|
||||
* (same container), the frontend is served by the API server itself, so both
|
||||
* share the same origin and the URLs are relative (""): no build-time
|
||||
* configuration is needed and the image works on any domain.
|
||||
* In development, Vite serves the frontend on its own port, so the URLs are
|
||||
* absolute, and `VITE_API_*` variables can point elsewhere. In production the
|
||||
* API server serves the frontend, so the URLs are relative and the same build
|
||||
* works on any domain.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
const apiHostname = import.meta.env.VITE_API_HOSTNAME;
|
||||
|
||||
/** The API's origin, empty when it is the page's own. */
|
||||
/**
|
||||
* The API's origin, which every `fetch` of the frontend prefixes to its path.
|
||||
* It is empty in production, where the API is the page's own origin.
|
||||
*/
|
||||
export const API_URL: string = apiHostname
|
||||
? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${
|
||||
import.meta.env.VITE_API_PORT || "8000"
|
||||
@@ -23,14 +26,17 @@ export const API_URL: string = apiHostname
|
||||
? "http://localhost:8000"
|
||||
: "";
|
||||
|
||||
/** The socket's origin, always absolute. */
|
||||
/**
|
||||
* The socket's origin, for `WSProvider`. It is always absolute, since
|
||||
* `WebSocket` takes no relative URL.
|
||||
*/
|
||||
export const WS_URL: string = API_URL
|
||||
? API_URL.replace(/^http/, "ws")
|
||||
: `${location.protocol.replace("http", "ws")}//${location.host}`;
|
||||
|
||||
/** The search endpoint. */
|
||||
/** The search endpoint, for the search page. */
|
||||
export const SEARCH_URL = `${API_URL}/api/search`;
|
||||
/** How many items a feed page asks for. */
|
||||
/** How many items a page of a feed or list asks the API for. */
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
/** How many notifications a page asks for. */
|
||||
/** How many notifications a page of `/notifications` asks the API for. */
|
||||
export const NOTIFICATIONS_PAGE_SIZE = 30;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* The index's feed tabs, and the one it opens on.
|
||||
* Lists the feed tabs of the index page, shared by the tab bar, the routes and
|
||||
* the default tab setting.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The index's feed tabs, in the tab bar's order. */
|
||||
/**
|
||||
* The feed tabs, in the tab bar's order. Each is the `:feedTab` of
|
||||
* `/~/:feedTab` and `/:categorySlug/:feedTab`.
|
||||
*/
|
||||
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
|
||||
/** One of the {@link FEED_TABS}. */
|
||||
export type FeedTab = (typeof FEED_TABS)[number];
|
||||
|
||||
/**
|
||||
* The tab the index opens on for visitors and for users who haven't picked one.
|
||||
* The tab `/` redirects to for visitors, and for users who haven't picked one
|
||||
* in their settings.
|
||||
*/
|
||||
export const DEFAULT_FEED_TAB: FeedTab = "journal";
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
/**
|
||||
* Which Bandcamp playback path this deployment uses.
|
||||
* Reads which Bandcamp player this deployment uses, for `usePlayRichContent`.
|
||||
*
|
||||
* The server injects `GERBEUR_BANDCAMP_PLAYER` into a `<meta>` tag at request
|
||||
* time (api/lib/static.ts and api/middleware/og.ts; mirrored by the Vite dev
|
||||
* plugin), the same mechanism as site-name/site-emoji. Read once at startup.
|
||||
*
|
||||
* Defaults to "embed" whenever the value is missing, unsubstituted or
|
||||
* unrecognised, so a misconfigured deployment keeps the iframe it has today.
|
||||
* The server writes `GERBEUR_BANDCAMP_PLAYER` into index.html's
|
||||
* `bandcamp-player` `<meta>` tag (`api/lib/index-html.ts`, and the Vite config
|
||||
* in development). A missing or unknown value means "embed", so a
|
||||
* misconfigured deployment keeps Bandcamp's iframe.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Bandcamp playback path: Bandcamp's own iframe, or the site's player on the
|
||||
* resolved streams.
|
||||
* A Bandcamp player. "embed" is Bandcamp's own iframe, "native" is the site's
|
||||
* player on the resolved streams.
|
||||
*/
|
||||
export type BandcampPlayer = "embed" | "native";
|
||||
|
||||
/** Reads the playback path from the page's `<meta>` tag. */
|
||||
function readBandcampPlayer(): BandcampPlayer {
|
||||
const meta = document.querySelector('meta[name="bandcamp-player"]')
|
||||
?.getAttribute("content")?.trim();
|
||||
return meta === "native" ? "native" : "embed";
|
||||
}
|
||||
|
||||
/** This deployment's Bandcamp playback path. */
|
||||
/** This deployment's Bandcamp player, read once when the app loads. */
|
||||
export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The session context: the signed-in session and its setter, provided by
|
||||
* AuthProvider and read through `useAuth`.
|
||||
* Declares the session's React context. `AuthProvider` fills it and `useAuth`
|
||||
* reads it; nothing else should touch it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -9,15 +9,23 @@ import { createContext, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
/** What {@link AuthContext} holds. */
|
||||
/**
|
||||
* Provided by `AuthProvider` to `useAuth`, which builds the session's API on
|
||||
* it.
|
||||
*/
|
||||
export interface AuthContextValue {
|
||||
/** The signed-in session, null when signed out. */
|
||||
authResponse: Session | null;
|
||||
/** Replaces the session; AuthProvider keeps the stored one in step. */
|
||||
/**
|
||||
* Replaces the session in memory only. Its callers store the new session
|
||||
* themselves, with `storeSession`, so other tabs follow.
|
||||
*/
|
||||
setAuthResponse: Dispatch<SetStateAction<Session | null>>;
|
||||
}
|
||||
|
||||
/** The session context. */
|
||||
/**
|
||||
* The session's context. Its default, a signed-out session whose setter does
|
||||
* nothing, is only seen outside `AuthProvider`, as in some tests.
|
||||
*/
|
||||
export const AuthContext: React.Context<AuthContextValue> = createContext<
|
||||
AuthContextValue
|
||||
>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The session's provider: holds it, keeps it in step with the other tabs and
|
||||
* the server.
|
||||
* Holds the session for the whole app and keeps it in step with the other tabs
|
||||
* and with the server. `App` mounts it near the root.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,18 +19,16 @@ import {
|
||||
} from "../../utils/auth/storage.ts";
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
/** The props of {@link AuthProvider}. */
|
||||
/** Passed to {@link AuthProvider} by `App`. */
|
||||
export interface AuthProviderProps {
|
||||
/** The app, which reads the session. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link AuthContext}. The session starts as the stored one, follows
|
||||
* the stored one when another tab signs in, out or changes the password, is
|
||||
* checked with the server on mount and whenever the tab regains focus (a 401
|
||||
* ends it; a changed role is picked up), and gets a media token when it
|
||||
* predates them.
|
||||
* Provides {@link AuthContext}. The session starts as the stored one and
|
||||
* follows it when another tab changes it. The server checks it on mount and
|
||||
* whenever the tab regains focus: a 401 ends it, and a changed role replaces
|
||||
* the stored user. A session from before media tokens gets one.
|
||||
*/
|
||||
export function AuthProvider(
|
||||
{ children }: AuthProviderProps,
|
||||
@@ -39,9 +37,9 @@ export function AuthProvider(
|
||||
readStoredSession,
|
||||
);
|
||||
|
||||
// Every tab shares the stored session, so follow it: a login, logout or
|
||||
// password change in another tab applies here too, rather than this tab
|
||||
// carrying on with a token the server has since revoked.
|
||||
// A login, logout or password change in another tab applies here too, so
|
||||
// this tab doesn't carry on with a token the server has revoked. A null key
|
||||
// means localStorage was cleared.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== AUTH_STORAGE_KEY && e.key !== null) return;
|
||||
@@ -51,12 +49,10 @@ export function AuthProvider(
|
||||
return () => globalThis.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
// The `exp` pre-check above only catches expiry; it cannot detect a token the
|
||||
// server rejects for another reason (e.g. a changed GERBEUR_JWT_SECRET, which
|
||||
// leaves the signature invalid but the token unexpired). Ask the server — the
|
||||
// only authority on validity — and clear the session if it returns 401, so the
|
||||
// UI never shows a false "logged in" state. Re-check when the tab regains
|
||||
// focus to catch tokens invalidated by a server restart while we were away.
|
||||
// readStoredSession only drops expired tokens. The server also rejects
|
||||
// tokens revoked by a password change and tokens signed with a former
|
||||
// GERBEUR_JWT_SECRET, so ask it, and again when the tab comes back, since
|
||||
// either can happen while it sits in the background.
|
||||
const token = authResponse?.token;
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -68,16 +64,15 @@ export function AuthProvider(
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Only this token is dead; the session may have moved on to a
|
||||
// fresh one (another tab's password change) while we asked.
|
||||
// Only this token is dead. The session may have moved on to fresh
|
||||
// tokens while we asked (a password change, here or in another tab).
|
||||
forgetStoredSession(token);
|
||||
setAuthResponse((prev) => prev?.token === token ? null : prev);
|
||||
return;
|
||||
}
|
||||
// The stored user is a snapshot from login time; the role (and other
|
||||
// profile fields) can change server-side — e.g. an admin promoting this
|
||||
// user. Sync from the authoritative /me response so the UI reflects role
|
||||
// changes without forcing a logout/login cycle.
|
||||
// The stored user dates from sign-in, and an admin may have changed
|
||||
// the role since. Only a role change replaces it, so an unchanged
|
||||
// session keeps its identity and doesn't re-render the app.
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json?.success && json.data) {
|
||||
@@ -91,7 +86,7 @@ export function AuthProvider(
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network error — don't log out an offline user; only an explicit 401 clears the session.
|
||||
// Offline is not signed out: only a 401 ends the session.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,8 +102,8 @@ export function AuthProvider(
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// Sessions from before media tokens existed have none, and would keep putting
|
||||
// the full session token in file and thumbnail URLs; fetch one once.
|
||||
// Without a media token, file and thumbnail URLs carry the session token in
|
||||
// their query string, where it leaks into logs and history.
|
||||
const needsMediaToken = !!token && !authResponse?.mediaToken;
|
||||
useEffect(() => {
|
||||
if (!token || !needsMediaToken) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The categories context: every category, loaded once by CategoriesProvider.
|
||||
* Declares the categories' React context. `CategoriesProvider` fills it and
|
||||
* `useCategories` reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,19 +8,28 @@
|
||||
import { createContext } from "react";
|
||||
import type { Category } from "../../../shared/category.ts";
|
||||
|
||||
/** What {@link CategoriesContext} holds. */
|
||||
/**
|
||||
* Provided by `CategoriesProvider` to `useCategories`, for the category
|
||||
* switcher and picker, the dump pages and cards, and the category manager.
|
||||
*/
|
||||
export interface CategoriesContextValue {
|
||||
/** Every category, in order. */
|
||||
/** Every category, in the server's order, by position then slug. */
|
||||
categories: Category[];
|
||||
/** The categories by id, for resolving dump category chips. */
|
||||
/** Resolves a dump's `categoryIds` to its category chips. */
|
||||
byId: Map<string, Category>;
|
||||
/** Whether the list has been fetched. */
|
||||
/**
|
||||
* Whether the first fetch has ended. It is also true after a network error,
|
||||
* with no categories.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
/** Re-fetches the list (used after admin create/update/delete). */
|
||||
/** Refetches the list. The category manager calls it after each change. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The categories context. */
|
||||
/**
|
||||
* The categories' context. Its default, an empty list never loaded, is only
|
||||
* seen outside `CategoriesProvider`.
|
||||
*/
|
||||
export const CategoriesContext: React.Context<CategoriesContextValue> =
|
||||
createContext<
|
||||
CategoriesContextValue
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The categories' provider: fetches the list once, and again on request.
|
||||
* Loads the site's categories once for the whole app, and again when the
|
||||
* category manager changes them. `App` mounts it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -23,7 +24,7 @@ import type { Wire } from "../../../shared/api.ts";
|
||||
/**
|
||||
* Returns every category, or null when the API answers a failure.
|
||||
*
|
||||
* @throws {Error} When the request fails.
|
||||
* @throws {Error} When the request fails or answers an error status.
|
||||
*/
|
||||
async function fetchCategories(
|
||||
signal?: AbortSignal,
|
||||
@@ -35,13 +36,15 @@ async function fetchCategories(
|
||||
return (body.data as Wire<Category>[]).map(deserializeCategory);
|
||||
}
|
||||
|
||||
/** The props of {@link CategoriesProvider}. */
|
||||
/** Passed to {@link CategoriesProvider} by `App`. */
|
||||
export interface CategoriesProviderProps {
|
||||
/** The app, which reads the categories. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Provides {@link CategoriesContext}, fetching the categories on mount. */
|
||||
/**
|
||||
* Provides {@link CategoriesContext}, fetching the categories on mount. A
|
||||
* failed fetch keeps the previous list.
|
||||
*/
|
||||
export function CategoriesProvider(
|
||||
{ children }: CategoriesProviderProps,
|
||||
): React.JSX.Element {
|
||||
@@ -55,6 +58,8 @@ export function CategoriesProvider(
|
||||
setCategories(data);
|
||||
setIsLoaded(true);
|
||||
})
|
||||
// A failed fetch still ends the loading, so that pages waiting on it
|
||||
// don't wait forever.
|
||||
.catch((err) => {
|
||||
if ((err as Error).name !== "AbortError") setIsLoaded(true);
|
||||
}), []);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
/**
|
||||
* The chat context: whether the chatbox is open, provided by ChatProvider.
|
||||
* Declares the context through which the header's chat button and the floating
|
||||
* chat button open the one chat modal, which `ChatProvider` owns. Read it with
|
||||
* `useChat`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** What {@link ChatContext} holds. */
|
||||
/** Provided by `ChatProvider` through {@link ChatContext}. */
|
||||
export interface ChatContextValue {
|
||||
/** Whether the chatbox is open. */
|
||||
/** Never true for a visitor, whatever `openChat` or the URL asked. */
|
||||
isChatOpen: boolean;
|
||||
/** Opens the chatbox. */
|
||||
openChat: () => void;
|
||||
/** Closes the chatbox. */
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
/** The chat context. */
|
||||
/**
|
||||
* Holds the chat modal's open state. Outside `ChatProvider` it is closed and
|
||||
* its functions do nothing.
|
||||
*/
|
||||
export const ChatContext: React.Context<ChatContextValue> = createContext<
|
||||
ChatContextValue
|
||||
>({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The chat's provider, which owns the one chat modal.
|
||||
* Provides `ChatContext` and renders the chat modal while it is open. `App`
|
||||
* mounts it inside the router, around every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,25 +17,23 @@ import { useSearchParams } from "react-router";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
|
||||
|
||||
/** The chat modal, loaded on first open. */
|
||||
/** Loaded on first open, so visitors never download the chat. */
|
||||
const ChatModal = lazy(() =>
|
||||
import("../../components/chat/ChatModal.tsx").then((m) => ({
|
||||
default: m.ChatModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** The props of {@link ChatProvider}. */
|
||||
/** Passed to {@link ChatProvider} by `App`. */
|
||||
export interface ChatProviderProps {
|
||||
/** The app, which opens and closes the chat. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link ChatContext} and owns the single live-chat modal instance so
|
||||
* the header button, the floating button, and notification deep-links all drive
|
||||
* one modal. Open state is derived (no effects): a manual open, or a
|
||||
* `?chat=open` query param (used by mention notifications), opens it — and it's
|
||||
* only ever open for signed-in users.
|
||||
* Provides {@link ChatContext} and renders the one chat modal while it is
|
||||
* open. `openChat` opens it, and so does a `?chat=open` query parameter on any
|
||||
* page, which is where a chat mention notification links. It stays closed for
|
||||
* visitors.
|
||||
*/
|
||||
export function ChatProvider(
|
||||
{ children }: ChatProviderProps,
|
||||
@@ -49,7 +48,7 @@ export function ChatProvider(
|
||||
const openChat = useCallback(() => setManuallyOpen(true), []);
|
||||
const closeChat = useCallback(() => {
|
||||
setManuallyOpen(false);
|
||||
// Strip the deep-link param so a reload/back-nav doesn't reopen the modal.
|
||||
// Or a reload would open the chat again.
|
||||
if (searchParams.get("chat") === "open") {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("chat");
|
||||
|
||||
@@ -1,85 +1,81 @@
|
||||
/**
|
||||
* The player contexts: what plays, the queue and the controls, and apart from
|
||||
* them the playhead. PlayerProvider provides both; GlobalPlayer draws them.
|
||||
* Defines the player's two contexts, one for the queue and the controls and one
|
||||
* for the playhead alone. PlayerProvider provides both around the whole app;
|
||||
* GlobalPlayer draws the player, and the cards and previews that start playback
|
||||
* read and drive it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** Fields every playable item shares, whatever its source. */
|
||||
/**
|
||||
* The fields of every {@link PlayerItem}, which GlobalPlayer's header shows.
|
||||
*/
|
||||
export interface PlayerItemBase {
|
||||
/** The item's title. */
|
||||
title?: string;
|
||||
/** The page of the dump it was played from. */
|
||||
/** The dump page the header's title links to. */
|
||||
dumpHref?: string;
|
||||
/**
|
||||
* Square image for the player header — provider thumbnail, album art, or a
|
||||
* video still. Raw URL: Thumbnail decides whether to proxy it.
|
||||
*/
|
||||
/** A raw URL, which `Thumbnail` proxies when it must. */
|
||||
artworkUrl?: string;
|
||||
/**
|
||||
* Secondary line under the title: the artist, the site name, the filename —
|
||||
* whatever identifies the source at a glance.
|
||||
* Shown under the title. It holds the artist, the site's name or the file's
|
||||
* name.
|
||||
*/
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Something the player can play: a provider's embed, an uploaded file, or an
|
||||
* expiring stream.
|
||||
* Built by the cards and previews that start playback, and held in
|
||||
* PlayerProvider's queue, which localStorage keeps across reloads. An `embed`
|
||||
* plays in the provider's iframe; a `file` (an upload) and a `stream` play in
|
||||
* MediaPlayer.
|
||||
*/
|
||||
export type PlayerItem =
|
||||
| (PlayerItemBase & {
|
||||
/** A provider's iframe player. */
|
||||
kind: "embed";
|
||||
/** The embed's URL. */
|
||||
embedUrl: string;
|
||||
/** The provider, e.g. "youtube". */
|
||||
/** The provider, as in `RichContent.type`. */
|
||||
type: string;
|
||||
})
|
||||
| (PlayerItemBase & {
|
||||
/** An uploaded file, played by a media element. */
|
||||
kind: "file";
|
||||
/** The file's URL. */
|
||||
/** Carries the listener's media token when they are signed in. */
|
||||
fileUrl: string;
|
||||
/** The file's type. */
|
||||
/** Decides between the audio and the video player. */
|
||||
mimeType: string;
|
||||
})
|
||||
/**
|
||||
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
|
||||
*
|
||||
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
|
||||
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
|
||||
* came from and `resolveIndex` its position in that page's tracklist. That
|
||||
* pair is also the item's stable identity, since `streamUrl` changes on every
|
||||
* re-resolve.
|
||||
* A track streamed from another site, today a Bandcamp mp3. Its URL is
|
||||
* signed and dies after about 24 hours, so the item keeps the page it came
|
||||
* from (`resolveUrl`) and its place in that page's tracklist (`resolveIndex`)
|
||||
* to fetch a fresh one. That pair is also its identity, since `streamUrl`
|
||||
* changes on every re-resolve.
|
||||
*/
|
||||
| (PlayerItemBase & {
|
||||
/** An expiring stream. */
|
||||
kind: "stream";
|
||||
/** The signed stream URL. */
|
||||
streamUrl: string;
|
||||
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
|
||||
/**
|
||||
* The service, which also styles the player (`global-player--bandcamp`).
|
||||
*/
|
||||
type: string;
|
||||
/** Known before playback from the tracklist, so rows can show a length. */
|
||||
/** Known from the tracklist before playback, for the tracklist's rows. */
|
||||
duration?: number;
|
||||
/** The track's number on its release. */
|
||||
/** Numbers the tracklist's row, in place of the queue position. */
|
||||
trackNum?: number;
|
||||
/** The page the stream was resolved from. */
|
||||
resolveUrl: string;
|
||||
/** The track's position in that page's tracklist. */
|
||||
resolveIndex: number;
|
||||
/** Epoch ms of the resolution that produced `streamUrl`. */
|
||||
/** When `streamUrl` was signed, in epoch ms, for `isStreamStale`. */
|
||||
resolvedAt: number;
|
||||
/** Iframe embed to fall back to if native playback stops working. */
|
||||
/** The iframe PlayerProvider falls back to if the stream stays dead. */
|
||||
embedUrl?: string;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a stable identity for `item`, used for active-state comparisons and
|
||||
* to decide when the player is showing something new. Deliberately independent
|
||||
* of `streamUrl` so a re-resolved track is still recognised as the same track.
|
||||
* Returns a key that identifies `item` whatever its stream URL, so that a
|
||||
* re-resolved track is still the same track. GlobalPlayer keys its tracklist
|
||||
* on it and tells a new item by it; PlayerProvider tells repeated failures of
|
||||
* one track by it.
|
||||
*/
|
||||
export function playerItemKey(item: PlayerItem): string {
|
||||
switch (item.kind) {
|
||||
@@ -93,13 +89,13 @@ export function playerItemKey(item: PlayerItem): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link PlayerContext} holds: playback state, readable by any consumer,
|
||||
* and controls, callable by any consumer.
|
||||
* Provided by PlayerProvider through {@link PlayerContext}. Cards and previews
|
||||
* read what plays and call the controls; GlobalPlayer and MediaPlayer use the
|
||||
* internal fields to report the media's state back.
|
||||
*/
|
||||
export interface PlayerContextValue {
|
||||
/** The item playing or paused; always `queue[queueIndex]`. */
|
||||
/** Always `queue[queueIndex]`, or null when nothing plays. */
|
||||
current: PlayerItem | null;
|
||||
/** Whether it is playing. */
|
||||
playing: boolean;
|
||||
|
||||
/**
|
||||
@@ -107,62 +103,60 @@ export interface PlayerContextValue {
|
||||
* and albums share one code path.
|
||||
*/
|
||||
queue: PlayerItem[];
|
||||
/** The position of `current` in the queue. */
|
||||
queueIndex: number;
|
||||
/** Whether an item follows `current`. */
|
||||
hasNext: boolean;
|
||||
/** Whether an item precedes `current`. */
|
||||
hasPrevious: boolean;
|
||||
/** True while a stream is being (re-)resolved, for spinner affordances. */
|
||||
/** True while a stream's URLs are re-resolved, which GlobalPlayer shows. */
|
||||
resolving: boolean;
|
||||
|
||||
/**
|
||||
* Initial seek offset for the active item: non-zero only for an item restored
|
||||
* from a previous session, or resumed after a re-resolve.
|
||||
* Where MediaPlayer starts the current item, in seconds: not 0 only for an
|
||||
* item restored from a previous session, or resumed after a re-resolve.
|
||||
*/
|
||||
startTime: number;
|
||||
/**
|
||||
* Whether the active MediaPlayer should autoplay. A restored item appears
|
||||
* paused; a fresh play() autoplays on the user gesture.
|
||||
* Whether the current item starts on mount. A restored session appears
|
||||
* paused, since no user gesture is behind it; a fresh `play()` autoplays.
|
||||
*/
|
||||
autoplay: boolean;
|
||||
|
||||
/** Plays `item` alone. */
|
||||
/** Plays `item` alone, replacing the queue. */
|
||||
play(item: PlayerItem): void;
|
||||
/** Plays `items` as the queue, from `startIndex`. */
|
||||
/** Replaces the queue with `items` and plays it from `startIndex`. */
|
||||
playQueue(items: PlayerItem[], startIndex?: number): void;
|
||||
/** Plays the queue's item at `index`. */
|
||||
/** Plays the queue's item at `index`, or restarts it when it is current. */
|
||||
playAt(index: number): void;
|
||||
/** Plays the next item. */
|
||||
next(): void;
|
||||
/** Plays the previous item. */
|
||||
/**
|
||||
* Restarts the item after its first 3 seconds, else plays the previous one.
|
||||
*/
|
||||
previous(): void;
|
||||
/** Stops and closes the player. */
|
||||
/** Empties the queue, which closes the player. */
|
||||
stop(): void;
|
||||
/** Seeks to `time` seconds. */
|
||||
/** Seeks a file or stream to `time` seconds. An embed doesn't move. */
|
||||
seekTo(time: number): void;
|
||||
/** Pauses or resumes. */
|
||||
/** Pauses or resumes a file or stream. An embed has its own controls. */
|
||||
togglePlay(): void;
|
||||
|
||||
/**
|
||||
* Internal: where GlobalPlayer registers MediaPlayer's seek handle, so seekTo
|
||||
* can reach into the actual media element.
|
||||
*/
|
||||
/** Where the mounted MediaPlayer registers its seek handle, for `seekTo`. */
|
||||
seekRef: { current: ((t: number) => void) | null };
|
||||
/** Internal: MediaPlayer's play/pause handle, for togglePlay. */
|
||||
/** Where the mounted MediaPlayer registers its play/pause handle. */
|
||||
toggleRef: { current: (() => void) | null };
|
||||
|
||||
/** Internal: GlobalPlayer reports whether the media is playing. */
|
||||
/** Called by MediaPlayer when its media starts or stops. */
|
||||
onPlayStateChange(playing: boolean): void;
|
||||
/** Internal: GlobalPlayer reports the playhead and the duration. */
|
||||
/** Called by MediaPlayer as its media plays. */
|
||||
onTimeUpdate(time: number, duration: number): void;
|
||||
/** Current item finished — advances the queue, or stops at the end. */
|
||||
/** Called by MediaPlayer at its media's end, to play the next item. */
|
||||
onEnded(): void;
|
||||
/** The media element failed. For streams, triggers a re-resolve. */
|
||||
/** Called by MediaPlayer when its media fails, to re-resolve a stream. */
|
||||
onError(): void;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/**
|
||||
* Given by {@link PlayerContext} outside PlayerProvider: nothing plays and no
|
||||
* control does anything. The tests' render helper starts from it.
|
||||
*/
|
||||
export const defaultPlayerValue: PlayerContextValue = {
|
||||
current: null,
|
||||
playing: false,
|
||||
@@ -189,7 +183,7 @@ export const defaultPlayerValue: PlayerContextValue = {
|
||||
onError: () => {},
|
||||
};
|
||||
|
||||
/** The player context. */
|
||||
/** The player's state and controls, from PlayerProvider. */
|
||||
export const PlayerContext: React.Context<PlayerContextValue> = createContext<
|
||||
PlayerContextValue
|
||||
>(
|
||||
@@ -197,24 +191,25 @@ export const PlayerContext: React.Context<PlayerContextValue> = createContext<
|
||||
);
|
||||
|
||||
/**
|
||||
* The playhead, apart from the rest: it moves several times a second while
|
||||
* something plays, and only what draws a position should re-render with it —
|
||||
* not every card that reads `current` or `playing`.
|
||||
* Provided by PlayerProvider through {@link PlayerTimeContext}, apart from the
|
||||
* rest because it changes several times a second while something plays. Only
|
||||
* what draws a position, like the dump page's audio preview, reads it, so the
|
||||
* cards that read {@link PlayerContext} don't re-render with it.
|
||||
*/
|
||||
export interface PlayerTimeValue {
|
||||
/** The playhead, in seconds. */
|
||||
/** In seconds. */
|
||||
currentTime: number;
|
||||
/** The current item's length, in seconds; 0 until known. */
|
||||
/** In seconds, 0 until the media's metadata loads. */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given by {@link PlayerTimeContext} outside PlayerProvider. */
|
||||
export const defaultPlayerTimeValue: PlayerTimeValue = {
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
/** The playhead context. */
|
||||
/** The playhead of the current file or stream, from PlayerProvider. */
|
||||
export const PlayerTimeContext: React.Context<PlayerTimeValue> = createContext<
|
||||
PlayerTimeValue
|
||||
>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The player's provider: the queue, the playback state and the controls, kept
|
||||
* across reloads, and the expiring streams kept fresh.
|
||||
* Holds the player's queue, playback state and controls, saves them across
|
||||
* reloads and keeps streams' signed URLs fresh. App mounts it around the whole
|
||||
* app, and GlobalPlayer draws what it holds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -18,24 +19,18 @@ import {
|
||||
} from "../../utils/player/streamSources.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The localStorage key of the stored session. */
|
||||
const STORAGE_KEY = "player";
|
||||
|
||||
/**
|
||||
* Snapshot persisted across reloads: the whole queue, which entry was playing
|
||||
* and how far in.
|
||||
*
|
||||
* v1 stored a single `{ item, time }`. `readSession` still accepts that shape
|
||||
* so a session written by the previous build survives the upgrade.
|
||||
* Written to localStorage by {@link PlayerProvider} as the queue changes and on
|
||||
* `pagehide`, and read back by {@link readSession} on load. Version 1 stored a
|
||||
* single `{ item, time }`, which readSession still accepts.
|
||||
*/
|
||||
interface StoredSession {
|
||||
/** The format's version. */
|
||||
v: 2;
|
||||
/** The queue. */
|
||||
queue: PlayerItem[];
|
||||
/** The position of the entry that was playing. */
|
||||
index: number;
|
||||
/** How far in it was, in seconds. */
|
||||
/** How far into `queue[index]`, in seconds. */
|
||||
time: number;
|
||||
/**
|
||||
* Whose queue this is (null for a guest). File URLs carry the listener's
|
||||
@@ -44,7 +39,10 @@ interface StoredSession {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** Checks whether `value` looks like a {@link PlayerItem}. */
|
||||
/**
|
||||
* Checks whether `value`, read from localStorage, looks like a
|
||||
* {@link PlayerItem}. Only its `kind` is checked.
|
||||
*/
|
||||
function isPlayerItem(value: unknown): value is PlayerItem {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
@@ -64,9 +62,8 @@ function readSession(
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const time = Number(parsed.time) || 0;
|
||||
|
||||
// Another account's queue, or one saved before queues had owners: the
|
||||
// latter may be anybody's, and only file URLs hold a token worth keeping
|
||||
// to themselves.
|
||||
// A session saved before sessions had owners may be anybody's. Only file
|
||||
// URLs carry a media token, so it is kept only when it holds none.
|
||||
if (parsed.owner === undefined) {
|
||||
const items = Array.isArray(parsed.queue) ? parsed.queue : [parsed.item];
|
||||
if (items.some((i) => isPlayerItem(i) && i.kind === "file")) return null;
|
||||
@@ -85,7 +82,7 @@ function readSession(
|
||||
return { queue, index, time };
|
||||
}
|
||||
|
||||
// Legacy v1 single-item session.
|
||||
// A version 1 session, of a single item.
|
||||
if (isPlayerItem(parsed.item)) {
|
||||
return { queue: [parsed.item], index: 0, time };
|
||||
}
|
||||
@@ -95,28 +92,30 @@ function readSession(
|
||||
}
|
||||
}
|
||||
|
||||
/** Don't retry the same failing track more than once per window. */
|
||||
/**
|
||||
* A track that fails again this soon after its last re-resolve is dead, not
|
||||
* stale, and is skipped.
|
||||
*/
|
||||
const RERESOLVE_COOLDOWN_MS = 30_000;
|
||||
|
||||
/** The props of {@link PlayerProvider}. */
|
||||
/** Passed to {@link PlayerProvider} by App, around the whole app. */
|
||||
export interface PlayerProviderProps {
|
||||
/** The app, which plays through the player. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link PlayerContext} and {@link PlayerTimeContext}. The queue and
|
||||
* the position are stored as they change and restored on load, paused; signing
|
||||
* out, or in as someone else, ends the session. A Bandcamp stream whose signed
|
||||
* URL expires is re-resolved and resumed where it was, falling back to the
|
||||
* embed if that fails.
|
||||
* the position are saved as they change and restored on load, paused; signing
|
||||
* out, or in as someone else, closes the player. A stream whose signed URL
|
||||
* expires is re-resolved and resumed where it was, and falls back to its embed
|
||||
* if that fails.
|
||||
*/
|
||||
export function PlayerProvider(
|
||||
{ children }: PlayerProviderProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const owner = user?.id ?? null;
|
||||
// Lazy initialiser: readSession runs exactly once, on first render.
|
||||
// Read once, for the account AuthProvider restored synchronously.
|
||||
const [restored] = useState(() => readSession(owner));
|
||||
|
||||
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
|
||||
@@ -124,10 +123,7 @@ export function PlayerProvider(
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
// Resume offset for the active item — carried by a restored item, and by an
|
||||
// item whose stream URL was re-resolved mid-listen.
|
||||
const [startTime, setStartTime] = useState(restored?.time ?? 0);
|
||||
// Restored items start paused (no user gesture); fresh play() autoplays.
|
||||
const [autoplay, setAutoplay] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
@@ -135,22 +131,19 @@ export function PlayerProvider(
|
||||
const hasNext = queueIndex + 1 < queue.length;
|
||||
const hasPrevious = queueIndex > 0;
|
||||
|
||||
// GlobalPlayer registers the active MediaPlayer's imperative handles here
|
||||
const seekRef = useRef<((t: number) => void) | null>(null);
|
||||
const toggleRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Suppresses stale timeupdate callbacks that fire between play() and the old
|
||||
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
|
||||
// Set when the player moves to a new item, until the new media starts: the
|
||||
// old MediaPlayer can still report its playhead before it unmounts.
|
||||
const suppressUpdates = useRef(false);
|
||||
|
||||
// Bumped whenever the player is moved somewhere new — a fresh queue, a queue
|
||||
// step, a stop. An async re-resolve compares it against the value it captured
|
||||
// to tell whether it still owns the player by the time it lands.
|
||||
// Bumped whenever the player moves somewhere new (a fresh queue, a step, a
|
||||
// stop), so that a re-resolve landing later can tell it no longer applies.
|
||||
const playGen = useRef(0);
|
||||
|
||||
// Latest state, for callbacks that must not close over a stale render.
|
||||
// Written in an effect (after render) rather than during it, the same
|
||||
// convention MediaPlayer uses for its callback refs.
|
||||
// The latest state, for callbacks that must not close over an old render.
|
||||
// Written after render, since the linter forbids writing refs during it.
|
||||
const stateRef = useRef({ current, currentTime, playing, queue, queueIndex });
|
||||
useEffect(() => {
|
||||
stateRef.current = { current, currentTime, playing, queue, queueIndex };
|
||||
@@ -173,7 +166,7 @@ export function PlayerProvider(
|
||||
playQueue([item], 0);
|
||||
}, [playQueue]);
|
||||
|
||||
/** Move within the existing queue, as a fresh (autoplaying) item. */
|
||||
/** Moves to `index` in the queue and autoplays it, as a fresh queue would. */
|
||||
const advanceTo = useCallback((index: number) => {
|
||||
playGen.current++;
|
||||
suppressUpdates.current = true;
|
||||
@@ -197,7 +190,8 @@ export function PlayerProvider(
|
||||
|
||||
const seekTo = useCallback((t: number) => {
|
||||
seekRef.current?.(t);
|
||||
setCurrentTime(t); // optimistic — prevents waveform jitter before timeupdate fires
|
||||
// Shown at once, or the playhead jumps back until the next timeupdate.
|
||||
setCurrentTime(t);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
@@ -206,13 +200,11 @@ export function PlayerProvider(
|
||||
|
||||
const playAt = useCallback((index: number) => {
|
||||
if (index < 0 || index >= queue.length) return;
|
||||
// Re-selecting the playing row restarts it. Going through advanceTo would
|
||||
// leave the index unchanged — so the element would never remount and never
|
||||
// seek — while still flipping `playing` false against audio that's running.
|
||||
// advanceTo to the same index wouldn't remount the element, so it wouldn't
|
||||
// seek, and it would set `playing` false against audio that runs.
|
||||
if (index === queueIndex) {
|
||||
seekTo(0);
|
||||
// Rewinding alone would make the click look like a no-op on a row that
|
||||
// has ended, or that the user paused. Start it again.
|
||||
// On an ended or paused row, rewinding alone would look like nothing.
|
||||
if (!stateRef.current.playing) togglePlay();
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +215,10 @@ export function PlayerProvider(
|
||||
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
|
||||
}, [queueIndex, queue.length, advanceTo]);
|
||||
|
||||
/** Restart the track first, like every other music player, then step back. */
|
||||
/**
|
||||
* Plays the previous track, unless 3 seconds of this one have played: then
|
||||
* it restarts this one, like other players.
|
||||
*/
|
||||
const previous = useCallback(() => {
|
||||
if (stateRef.current.currentTime > 3 || queueIndex === 0) {
|
||||
seekTo(0);
|
||||
@@ -251,10 +246,9 @@ export function PlayerProvider(
|
||||
setPlaying(false);
|
||||
}, [queueIndex, queue.length, advanceTo]);
|
||||
|
||||
// ── Expiring streams ───────────────────────────────────────────────────────────
|
||||
// Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a
|
||||
// long listen, will 403. Re-resolve the source page, swap in fresh URLs and
|
||||
// resume at the same offset; if that fails too, fall back to the iframe embed.
|
||||
// Bandcamp signs its mp3 URLs for about 24 hours, so a session restored the
|
||||
// next day, or a long listen, gets a 403. The page is re-resolved and the
|
||||
// track resumed at the same offset; failing that, the embed takes over.
|
||||
const lastReresolve = useRef<{ key: string; at: number } | null>(null);
|
||||
|
||||
const fallBackToEmbed = useCallback((
|
||||
@@ -274,8 +268,8 @@ export function PlayerProvider(
|
||||
}, [playQueue, stop]);
|
||||
|
||||
/**
|
||||
* A track that stays dead after a re-resolve is one track, not one album:
|
||||
* step over it and keep the queue. Only the last one falls back to the embed.
|
||||
* Skips a track that stays dead after a re-resolve, since the rest of the
|
||||
* album may play. Only the last track falls back to the embed.
|
||||
*/
|
||||
const skipOrFallBack = useCallback((
|
||||
item: Extract<PlayerItem, { kind: "stream" }>,
|
||||
@@ -298,19 +292,17 @@ export function PlayerProvider(
|
||||
const now = Date.now();
|
||||
const last = lastReresolve.current;
|
||||
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
|
||||
// Already tried recently — the track is genuinely dead, not just stale.
|
||||
skipOrFallBack(item);
|
||||
return;
|
||||
}
|
||||
lastReresolve.current = { key, at: now };
|
||||
|
||||
// Resolving is a network round-trip against Bandcamp. The user can pick
|
||||
// another dump, skip on, or close the player while it's in flight — and a
|
||||
// result that lands after that must not drag the player back here.
|
||||
// The user may play something else, skip or close the player meanwhile,
|
||||
// and a late result must not drag the player back here.
|
||||
const gen = playGen.current;
|
||||
const stillOurs = () => playGen.current === gen;
|
||||
// Separate from `gen`: the spinner belongs to the newest resolve, whichever
|
||||
// item that one is for, so a superseded resolve must not clear it.
|
||||
// Not `gen`: the spinner belongs to the newest resolve, whichever item it
|
||||
// is for, and an older one must not clear it.
|
||||
const seq = ++resolveSeq.current;
|
||||
|
||||
setResolving(true);
|
||||
@@ -320,9 +312,9 @@ export function PlayerProvider(
|
||||
const found = items.findIndex((i) =>
|
||||
i.kind === "stream" && i.resolveIndex === item.resolveIndex
|
||||
);
|
||||
// The track can be gone from the release, or no longer streamable once
|
||||
// Bandcamp's free-play cap kicks in. Falling back to the first track is
|
||||
// fine; carrying the old offset onto it would seek past its end.
|
||||
// The track may be gone, or capped by Bandcamp's free plays. The first
|
||||
// track then plays from its start, since the old offset may be past its
|
||||
// end.
|
||||
const index = found < 0 ? 0 : found;
|
||||
const resume = found < 0 ? 0 : resumeAt;
|
||||
suppressUpdates.current = true;
|
||||
@@ -334,8 +326,7 @@ export function PlayerProvider(
|
||||
setAutoplay(shouldAutoplay);
|
||||
setPlaying(false);
|
||||
} catch {
|
||||
// The resolve itself failed (offline, endpoint down) — that's the whole
|
||||
// album, not one track, so the embed is the right fallback.
|
||||
// The whole album failed to resolve, not one track.
|
||||
if (stillOurs()) fallBackToEmbed(item);
|
||||
} finally {
|
||||
if (resolveSeq.current === seq) setResolving(false);
|
||||
@@ -351,29 +342,26 @@ export function PlayerProvider(
|
||||
});
|
||||
}, [reresolve]);
|
||||
|
||||
// A session restored from a previous day holds URLs that are already dead.
|
||||
// Refresh them up front so the first press of play doesn't visibly stall.
|
||||
// A session from a previous day holds dead URLs. Refreshing them now keeps
|
||||
// the first press of play from stalling.
|
||||
useEffect(() => {
|
||||
if (!restored) return;
|
||||
const item = restored.queue[restored.index];
|
||||
if (item?.kind === "stream" && isStreamStale(item)) {
|
||||
// Genuinely an external-system sync: the stored URLs are dead and only
|
||||
// the network can replace them. The setState this triggers is the point.
|
||||
// Syncing with the network is the point of the setState this triggers.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void reresolve(item, {
|
||||
resumeAt: restored.time,
|
||||
shouldAutoplay: false,
|
||||
});
|
||||
}
|
||||
// Runs once, against the session captured at mount.
|
||||
// Once, for the session read at mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────────
|
||||
// Write the queue to storage as soon as it changes (survives crashes), then
|
||||
// refresh the playback position on pagehide — which also fires on a normal
|
||||
// reload — so the resume offset is accurate without thrashing localStorage on
|
||||
// every timeupdate.
|
||||
// The queue is saved as soon as it changes, which survives a crash, and the
|
||||
// position on `pagehide` (a reload fires it too), rather than on every
|
||||
// timeupdate.
|
||||
useEffect(() => {
|
||||
if (queue.length > 0) {
|
||||
localStorage.setItem(
|
||||
@@ -391,12 +379,12 @@ export function PlayerProvider(
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
// currentTime intentionally omitted from deps — see pagehide writer below.
|
||||
// Not on currentTime: the pagehide handler saves the position.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queue, queueIndex, owner]);
|
||||
|
||||
// Signing out, or in as someone else, ends the listening session: the queue
|
||||
// was built with the previous account's media token. Signing in keeps a
|
||||
// Signing out, or in as someone else, closes the player, since the queue's
|
||||
// file URLs carry the previous account's media token. Signing in keeps a
|
||||
// guest's queue, which holds no token.
|
||||
const [queueOwner, setQueueOwner] = useState(owner);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
/**
|
||||
* The follow context: whom and what the signed-in user follows, provided by
|
||||
* FollowProvider.
|
||||
* Declares the context that holds the users and playlists the signed-in user
|
||||
* follows, for the Follow buttons. `FollowProvider` provides it and
|
||||
* `useFollows` reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** What {@link FollowContext} holds. */
|
||||
/**
|
||||
* Provided by `FollowProvider` through {@link FollowContext}. The four
|
||||
* functions change the sets at once and undo the change when the request
|
||||
* fails. Their promises never reject, so a caller can't tell a failure.
|
||||
*/
|
||||
export interface FollowContextValue {
|
||||
/** The ids of the users they follow. */
|
||||
followedUserIds: Set<string>;
|
||||
/** The ids of the playlists they follow. */
|
||||
followedPlaylistIds: Set<string>;
|
||||
/** Follows a user. */
|
||||
followUser: (userId: string) => Promise<void>;
|
||||
/** Stops following a user. */
|
||||
unfollowUser: (userId: string) => Promise<void>;
|
||||
/** Follows a playlist. */
|
||||
followPlaylist: (playlistId: string) => Promise<void>;
|
||||
/** Stops following a playlist. */
|
||||
unfollowPlaylist: (playlistId: string) => Promise<void>;
|
||||
/** Whether the follow status has been fetched. */
|
||||
/**
|
||||
* False until the follows are fetched, and always for a visitor. The sets
|
||||
* are empty until then, so a button would show "Follow" for a followed user.
|
||||
*/
|
||||
isLoaded: boolean;
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `FollowProvider`: nothing is followed. */
|
||||
export const defaultFollowValue: FollowContextValue = {
|
||||
followedUserIds: new Set(),
|
||||
followedPlaylistIds: new Set(),
|
||||
@@ -36,7 +38,7 @@ export const defaultFollowValue: FollowContextValue = {
|
||||
isLoaded: false,
|
||||
};
|
||||
|
||||
/** The follow context. */
|
||||
/** Holds the signed-in user's follows. Read it with `useFollows`. */
|
||||
export const FollowContext: React.Context<FollowContextValue> = createContext<
|
||||
FollowContextValue
|
||||
>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The follows' provider: what the signed-in user follows, changed
|
||||
* optimistically.
|
||||
* Provides `FollowContext`, from `GET /api/follows/status`. `App` mounts it
|
||||
* around every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -17,16 +17,15 @@ import { API_URL } from "../../config/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import type { FollowStatus } from "../../../shared/follow.ts";
|
||||
|
||||
/** The props of {@link FollowProvider}. */
|
||||
/** Passed to {@link FollowProvider} by `App`. */
|
||||
export interface FollowProviderProps {
|
||||
/** The app, which reads and changes the follows. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link FollowContext}: fetches the follow status when someone signs
|
||||
* in, clears it when they sign out. A follow or unfollow applies at once and is
|
||||
* rolled back if the request fails.
|
||||
* Provides {@link FollowContext}. It fetches the follows whenever the token
|
||||
* changes, and empties them on sign-out. Follows made in another tab don't
|
||||
* show until the next sign-in or reload: no socket message carries them.
|
||||
*/
|
||||
export function FollowProvider(
|
||||
{ children }: FollowProviderProps,
|
||||
@@ -42,9 +41,8 @@ export function FollowProvider(
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
// Deferred to a microtask (rather than called directly) so this
|
||||
// doesn't fire synchronously within the effect body — same timing
|
||||
// for the user, just not in the same tick.
|
||||
// The React hooks lint forbids a synchronous setState in an effect. The
|
||||
// microtask changes nothing the user can see.
|
||||
queueMicrotask(() => {
|
||||
setFollowedUserIds(new Set());
|
||||
setFollowedPlaylistIds(new Set());
|
||||
@@ -66,6 +64,7 @@ export function FollowProvider(
|
||||
setIsLoaded(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Enables the buttons anyway, though the sets may be empty or stale.
|
||||
if (err.name !== "AbortError") setIsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* The theme context: the style and the color scheme, provided by ThemeProvider.
|
||||
* Declares the theme's React context. `ThemeProvider` fills it and `useTheme`
|
||||
* reads it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
/** A site style, one stylesheet each under src/themes/. */
|
||||
/**
|
||||
* A site style. Each has a stylesheet under `src/themes/`, keyed on the root
|
||||
* element's `data-style`, which is absent for "smooth".
|
||||
*/
|
||||
export type StyleName =
|
||||
| "smooth"
|
||||
| "brutalist"
|
||||
@@ -14,22 +18,21 @@ export type StyleName =
|
||||
| "nyt"
|
||||
| "kanagawa"
|
||||
| "melange";
|
||||
/** Light, dark, or following the system. */
|
||||
/** A color scheme. "auto" follows the system's. */
|
||||
export type ColorScheme = "auto" | "light" | "dark";
|
||||
|
||||
/** What {@link ThemeContext} holds. */
|
||||
/**
|
||||
* Provided by `ThemeProvider` to `useTheme`. A setter applies and stores the
|
||||
* choice for this browser.
|
||||
*/
|
||||
export interface ThemeContextValue {
|
||||
/** The chosen style. */
|
||||
style: StyleName;
|
||||
/** The chosen color scheme. */
|
||||
colorScheme: ColorScheme;
|
||||
/** Chooses a style. */
|
||||
setStyle(s: StyleName): void;
|
||||
/** Chooses a color scheme. */
|
||||
setColorScheme(c: ColorScheme): void;
|
||||
}
|
||||
|
||||
/** The theme context. */
|
||||
/** The theme's context. Its default is only seen outside `ThemeProvider`. */
|
||||
export const ThemeContext: React.Context<ThemeContextValue> = createContext<
|
||||
ThemeContextValue
|
||||
>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The theme's provider: the chosen style and color scheme, stored and applied
|
||||
* to the document.
|
||||
* Holds the chosen style and color scheme, stores them in localStorage and
|
||||
* applies them to the document. `App` mounts it outermost.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,24 +12,26 @@ import {
|
||||
ThemeContext,
|
||||
} from "./ThemeContext.ts";
|
||||
|
||||
/** The props of {@link ThemeProvider}. */
|
||||
/** Passed to {@link ThemeProvider} by `App`. */
|
||||
export interface ThemeProviderProps {
|
||||
/** The app. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link ThemeContext}. The style and color scheme are read from
|
||||
* localStorage, stored back when they change, and set on the root element as
|
||||
* `data-style` and `data-color-scheme` (absent for the defaults, `smooth` and
|
||||
* `auto`), which the stylesheets key on.
|
||||
* Provides {@link ThemeContext}. It sets the choices on the root element as
|
||||
* `data-style` and `data-color-scheme`, which the stylesheets key on, and
|
||||
* leaves them out for the defaults, "smooth" and "auto".
|
||||
*
|
||||
* An inline script in index.html sets the same attributes from the same
|
||||
* localStorage keys before the first paint, so the page doesn't flash the
|
||||
* default theme. The two must agree.
|
||||
*/
|
||||
export function ThemeProvider(
|
||||
{ children }: ThemeProviderProps,
|
||||
): React.JSX.Element {
|
||||
const [style, setStyleState] = useState<StyleName>(() => {
|
||||
const stored = localStorage.getItem("style");
|
||||
// migrate legacy "default" value
|
||||
// "smooth" was once stored as "default".
|
||||
if (stored === "default" || stored === null) return "smooth";
|
||||
return stored as StyleName;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The avatar override context: avatars changed live, provided by WSProvider.
|
||||
* Declares the context through which `Avatar` learns of avatars changed since
|
||||
* the page loaded. `WSProvider` fills it from `user_updated` messages.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,22 +8,22 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
/**
|
||||
* Live avatar state learned from `user_updated` WS events. Kept in its own
|
||||
* context (not WSContext) so that `Avatar` — rendered many times across feeds
|
||||
* and comment threads — only re-renders when an avatar actually changes, not on
|
||||
* every vote/comment/dump event.
|
||||
* Set by `WSProvider` for a user whose profile changed, and read by `Avatar`,
|
||||
* where it wins over the possibly stale props the page fetched.
|
||||
*/
|
||||
export interface AvatarOverride {
|
||||
/**
|
||||
* Whether the user currently has an avatar, so a freshly added or removed
|
||||
* avatar flips even when the surrounding list data is stale.
|
||||
*/
|
||||
hasAvatar: boolean;
|
||||
/** Cache-busts the avatar URL (derived from the user's `updatedAt`). */
|
||||
/**
|
||||
* The user's `updatedAt` in ms, or the arrival time without one. It busts the
|
||||
* cached image.
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** The avatar override context, keyed by user id. */
|
||||
/**
|
||||
* Holds an {@link AvatarOverride} by user id. It is apart from `WSContext`, so
|
||||
* the many avatars of a page don't re-render on every vote or comment.
|
||||
*/
|
||||
export const AvatarOverrideContext: React.Context<
|
||||
Record<string, AvatarOverride>
|
||||
> = createContext<
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The socket contexts, split three ways by how often each part changes, so a
|
||||
* consumer re-renders only with what it reads: typing and presence update every
|
||||
* few seconds while people are around, chat with every message, and a feed has
|
||||
* no use for either. WSProvider provides all three.
|
||||
* Declares the three contexts `WSProvider` fills from the WebSocket, and the
|
||||
* types they carry. They are split by how often each part changes, so that a
|
||||
* feed doesn't re-render with every chat message or presence change.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -16,225 +15,266 @@ import type { OnlineUser } from "../../../shared/ws.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
|
||||
/** A vote someone cast or withdrew. */
|
||||
/**
|
||||
* Set as `lastVoteEvent` by `WSProvider` on a `votes_update`. The profile
|
||||
* pages read it to add or drop a dump from a user's upvoted list.
|
||||
*/
|
||||
export interface VoteEvent {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Who voted. */
|
||||
voterId: string;
|
||||
/** Whether the vote was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** A like someone cast or withdrew. */
|
||||
/** Set as `lastLikeEvent` by `WSProvider` on a `comment_likes_update`. */
|
||||
export interface LikeEvent {
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Who liked it. */
|
||||
likerId: string;
|
||||
/** Whether the like was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** A playlist created, changed, deleted, or whose dumps changed. */
|
||||
/**
|
||||
* Set as `lastPlaylistEvent` by `WSProvider` on each `playlist_*` message.
|
||||
* The pages that show playlists apply it, some through `usePlaylistListSync`.
|
||||
* Which fields are set depends on `type`.
|
||||
*/
|
||||
export interface PlaylistEvent {
|
||||
/** What happened. */
|
||||
type: "created" | "updated" | "deleted" | "dumps_updated";
|
||||
/** The playlist's id. */
|
||||
playlistId: string;
|
||||
/** The playlist as it is now, when created or updated. */
|
||||
/** Set when created or updated. */
|
||||
playlist?: Playlist;
|
||||
/** The owner's id, when deleted. */
|
||||
/** The owner's id, set when deleted. */
|
||||
userId?: string;
|
||||
/** The dumps the user can see in it, in order, when its dumps changed. */
|
||||
/**
|
||||
* Set when the dumps changed. It lists only the dumps this user may see, in
|
||||
* order.
|
||||
*/
|
||||
dumpIds?: string[];
|
||||
/** How many dumps it holds, when its dumps changed. */
|
||||
/** Set when the dumps changed. It counts private dumps too. */
|
||||
dumpCount?: number;
|
||||
}
|
||||
|
||||
/** A comment posted, edited or deleted. */
|
||||
/**
|
||||
* Set as `lastCommentEvent` by `WSProvider` on each `comment_*` message. The
|
||||
* `Dump` page applies it to its thread, and `useDumpListSync` to its comment
|
||||
* counts.
|
||||
*/
|
||||
export interface CommentEvent {
|
||||
/** What happened. */
|
||||
type: "created" | "deleted" | "updated";
|
||||
/** The id of the dump it is under. */
|
||||
dumpId: string;
|
||||
/** The comment as it is now, when posted or edited. */
|
||||
/** Set when created or updated. */
|
||||
comment?: Comment;
|
||||
/** The comment's id, when deleted. */
|
||||
/** Set when deleted. */
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
/** A user whose public profile changed. */
|
||||
/**
|
||||
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the
|
||||
* server sends on any change to a profile. `UserPublicProfile` applies it.
|
||||
*/
|
||||
export interface UserEvent {
|
||||
/** The user as they are now. */
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/** The replied-to message, as shown in the reply bar and on a pending send. */
|
||||
/**
|
||||
* Chosen in `ChatModal` for the reply bar, and kept on the pending message so
|
||||
* it shows the reply before the server's echo. Only `id` goes to the server.
|
||||
*/
|
||||
export interface ChatReplyTarget {
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** Its author's name. */
|
||||
author: string;
|
||||
/** Its text. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message this client sent that the server hasn't echoed back yet — shown in
|
||||
* the list as "sending", or "failed" with a way to retry or discard it.
|
||||
* Held by `WSProvider` for a chat message this client sent and the server
|
||||
* hasn't echoed back yet. `ChatModal` shows it, as `PendingItem`, after the
|
||||
* messages the server has.
|
||||
*/
|
||||
export interface PendingChatMessage {
|
||||
/** The id the echo will carry back. */
|
||||
/**
|
||||
* Sent with `chat_send` and returned with the echo or the error. A retry
|
||||
* gets a new one, so a late echo of the first attempt can't retire it.
|
||||
*/
|
||||
nonce: string;
|
||||
/** The message. */
|
||||
body: string;
|
||||
/** The message it replies to. */
|
||||
replyTo?: ChatReplyTarget;
|
||||
/** When it was sent. */
|
||||
/** When this client sent it, to find it in the history after a reconnect. */
|
||||
createdAt: Date;
|
||||
/** Whether it is on its way or failed. */
|
||||
/**
|
||||
* `failed` when the server refused it, the socket was down or closed, or no
|
||||
* echo came in 10 s. A failed message may still have reached the server.
|
||||
*/
|
||||
status: "sending" | "failed";
|
||||
/** Why it failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
|
||||
/**
|
||||
* Set as `chatError` by `WSProvider` when the server refuses a chat edit or
|
||||
* delete. `ChatModal` shows it for a few seconds.
|
||||
*/
|
||||
export interface ChatError {
|
||||
/** Distinguishes one error from the next. */
|
||||
/** Tells apart two errors with the same message. */
|
||||
id: number;
|
||||
/** What went wrong. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link WSContext} holds: the connection, votes, likes, and the live
|
||||
* dump, playlist, comment, user and notification events.
|
||||
* Provided by `WSProvider` through {@link WSContext}, read with `useWS`. The
|
||||
* `last*Event` fields hold only the latest event of their kind: a consumer
|
||||
* reacts in an effect when one changes, and an event that another replaces
|
||||
* within the same render is missed. After a reconnect, `connectionEpoch` tells
|
||||
* pages to fetch what they missed.
|
||||
*/
|
||||
export interface WSContextValue {
|
||||
/** The connection's state. */
|
||||
wsStatus: "connecting" | "connected" | "disconnected";
|
||||
/** Why the socket is down, while it is. */
|
||||
/** Set while the socket is down, for the banner that says so. */
|
||||
wsErrorMessage: string | null;
|
||||
/** Live vote counts, by dump id. */
|
||||
/**
|
||||
* Counts known to the socket, by dump id: pushed by the server, or changed
|
||||
* optimistically. A missing dump shows its fetched `voteCount`.
|
||||
*/
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
/** Reset from the server's `welcome` on every connection. */
|
||||
myVotes: Set<string>;
|
||||
/** Live like counts, by comment id. */
|
||||
/** As `voteCounts`, for comments. */
|
||||
likeCounts: Record<string, number>;
|
||||
/** The ids of the comments the user likes. */
|
||||
/** As `myVotes`, for comments. */
|
||||
myLikes: Set<string>;
|
||||
/** Dumps posted (or made public) this session, newest first (capped). */
|
||||
/**
|
||||
* Dumps that arrived this session, newest first and at most 100: the ones
|
||||
* the server announced, which are public, and the ones this user posted,
|
||||
* which may be private. `Index` puts them above the feed it fetched.
|
||||
*/
|
||||
recentDumps: Dump[];
|
||||
/** Ids of dumps deleted this session. */
|
||||
/**
|
||||
* Dumps deleted or made private this session, for the lists to filter out.
|
||||
* A dump made public again leaves it.
|
||||
*/
|
||||
deletedDumpIds: Set<string>;
|
||||
/** The latest vote. */
|
||||
lastVoteEvent: VoteEvent | null;
|
||||
/** The latest like. */
|
||||
/** Read by no consumer at the moment. */
|
||||
lastLikeEvent: LikeEvent | null;
|
||||
/** The latest edited dump. */
|
||||
/** The dump as it is after its latest edit. */
|
||||
lastDumpEvent: Dump | null;
|
||||
/** The latest playlist change. */
|
||||
lastPlaylistEvent: PlaylistEvent | null;
|
||||
/** Ids of playlists deleted this session. */
|
||||
deletedPlaylistIds: Set<string>;
|
||||
/** The latest comment change. */
|
||||
lastCommentEvent: CommentEvent | null;
|
||||
/** The latest profile change. */
|
||||
lastUserEvent: UserEvent | null;
|
||||
/** How many notifications the user hasn't read. */
|
||||
/** Read from the `welcome`, then counted up by each new notification. */
|
||||
unreadNotificationCount: number;
|
||||
/** The latest notification received. */
|
||||
/** Read by the bell, which rings, and by the notifications page. */
|
||||
lastNotification: Notification | null;
|
||||
/** Increments on each WS reconnect so pages can backfill missed events. */
|
||||
/**
|
||||
* Incremented on each reconnect, never on the first connection, so pages
|
||||
* fetch again what they missed while the socket was down.
|
||||
*/
|
||||
connectionEpoch: number;
|
||||
/**
|
||||
* Upvotes a dump, optimistically. `shownCount`: the count on screen, used
|
||||
* until a live one arrives.
|
||||
* Upvotes a dump at once and sends `vote_cast`. The server's answer confirms
|
||||
* or reverts it, and so does a timeout of 5 s, which covers a closed socket.
|
||||
* `shownCount` is the count on screen, used when `voteCounts` has none yet.
|
||||
* Does nothing when the user already voted.
|
||||
*/
|
||||
castVote: (dumpId: string, shownCount?: number) => void;
|
||||
/** Withdraws an upvote, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Withdraws an upvote, as {@link WSContextValue.castVote} casts one. */
|
||||
removeVote: (dumpId: string, shownCount?: number) => void;
|
||||
/** Likes a comment, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Likes a comment, as {@link WSContextValue.castVote} upvotes a dump. */
|
||||
castCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
/** Withdraws a like, optimistically; `shownCount` as for `castVote`. */
|
||||
/** Withdraws a like, as {@link WSContextValue.castVote} casts a vote. */
|
||||
removeCommentLike: (commentId: string, shownCount?: number) => void;
|
||||
/**
|
||||
* Adds a dump this client just posted to `recentDumps`, without waiting for
|
||||
* its broadcast.
|
||||
* Adds a dump this user just posted to `recentDumps`, without waiting for
|
||||
* its broadcast, which a private dump never gets. `DumpCreateModal` calls it.
|
||||
*/
|
||||
injectDump: (dump: Dump) => void;
|
||||
/** Sets the unread notification count to zero. */
|
||||
/**
|
||||
* Zeroes the bell's count, once the notifications page has marked them read
|
||||
* on the server.
|
||||
*/
|
||||
clearUnreadNotifications: () => void;
|
||||
}
|
||||
|
||||
/** The chat room: its live stream, unread state and what to send into it. */
|
||||
/**
|
||||
* Provided by `WSProvider` through {@link WSChatContext}, read with
|
||||
* `useWSChat`. The socket sends chat messages only to signed-in users, so for
|
||||
* a visitor it stays empty.
|
||||
*/
|
||||
export interface WSChatContextValue {
|
||||
/** Live buffer of chat messages received this session (capped). */
|
||||
/**
|
||||
* The messages received this session, at most 200, for `ChatModal` to merge
|
||||
* with the history it fetches. A reconnect empties it while the chat is
|
||||
* closed, since `ChatModal` fetches everything again on open.
|
||||
*/
|
||||
chatMessages: ChatMessage[];
|
||||
/**
|
||||
* Ids of chat messages deleted live this session, so fetched history can
|
||||
* filter them out.
|
||||
* Messages deleted this session, which `ChatModal` filters out of its
|
||||
* fetched history.
|
||||
*/
|
||||
deletedChatIds: Set<string>;
|
||||
/**
|
||||
* Messages from others since the read marker — kept by the server, so it
|
||||
* survives reloads and agrees across tabs and devices.
|
||||
* Messages from others after the read marker. The server keeps the marker,
|
||||
* so the count agrees across tabs and devices.
|
||||
*/
|
||||
unreadChatCount: number;
|
||||
/**
|
||||
* The server's read marker: created_at of the newest message the user has
|
||||
* seen. Null until the socket's welcome.
|
||||
* The time of the newest message the user has seen, which the server keeps.
|
||||
* Null until the `welcome`, and for a visitor.
|
||||
*/
|
||||
chatReadAt: Date | null;
|
||||
/** Messages sent and not yet echoed back. */
|
||||
pendingChatMessages: PendingChatMessage[];
|
||||
/** The latest failed edit or delete, until dismissed. */
|
||||
/** Only for edits and deletes. A failed send shows on its pending message. */
|
||||
chatError: ChatError | null;
|
||||
/**
|
||||
* Users currently composing a chat message (excludes nobody — the chatbox
|
||||
* filters out the current user itself). Shaped like presence for avatars.
|
||||
*/
|
||||
/** Includes the user when they are typing: `ChatModal` filters them out. */
|
||||
typingUsers: OnlineUser[];
|
||||
/** Sends a message, replying to `replyTo` if given. */
|
||||
/** Trims `body` and sends it as a pending message. Ignores a blank body. */
|
||||
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||
/** Resends the failed message `nonce`. */
|
||||
retryChatMessage: (nonce: string) => void;
|
||||
/** Drops the failed message `nonce`. */
|
||||
discardChatMessage: (nonce: string) => void;
|
||||
/** Edits a message. */
|
||||
/**
|
||||
* Sends the edit if the socket is open, else drops it. It shows once the
|
||||
* server broadcasts it, or fails as `chatError`.
|
||||
*/
|
||||
editChatMessage: (id: string, body: string) => void;
|
||||
/** Deletes a message. */
|
||||
/** Sends the deletion if the socket is open, as `editChatMessage` does. */
|
||||
deleteChatMessage: (id: string) => void;
|
||||
/** Clears `chatError`. */
|
||||
dismissChatError: () => void;
|
||||
/** Moves the read marker up to the message created at `at`. */
|
||||
/**
|
||||
* Moves the read marker up to `at`, the time of the newest message seen. It
|
||||
* zeroes `unreadChatCount` at once and sends the marker at most once a
|
||||
* second. A marker set offline goes out with the next `welcome`.
|
||||
*/
|
||||
markChatRead: (at: Date) => void;
|
||||
/**
|
||||
* Folds an authoritative fetch of the stream between `from` and `to` into the
|
||||
* live buffer after a reconnect: buffered messages in that range which the
|
||||
* server no longer has were deleted while the socket was down.
|
||||
* Replaces the live buffer's messages between `from` and `to` with
|
||||
* `messages`, which `ChatModal` fetched after a reconnect. A message missing
|
||||
* from them was deleted while the socket was down. A failed send found among
|
||||
* them did reach the server, and leaves the pending list.
|
||||
*/
|
||||
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
|
||||
/**
|
||||
* Informs the server whether the user is currently composing a message, so it
|
||||
* can show/hide the "is typing" indicator to other clients.
|
||||
* Tells the server whether the user is typing, when the socket is open.
|
||||
* `ChatModal` resends `true` every few seconds while typing continues, since
|
||||
* the server forgets a typer after 6 s.
|
||||
*/
|
||||
setChatTyping: (typing: boolean) => void;
|
||||
/**
|
||||
* Informs the server whether the chatbox is open (suppresses redundant
|
||||
* mention notifications while the user is reading chat).
|
||||
* Tells the server whether the user is reading the chat: open, in a visible
|
||||
* tab. The server sends no notification for a mention the user reads live,
|
||||
* and this client doesn't count the message unread.
|
||||
*/
|
||||
setChatOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Who's connected. */
|
||||
/**
|
||||
* Provided by `WSProvider` through {@link WSPresenceContext}, read with
|
||||
* `useWSPresence`.
|
||||
*/
|
||||
export interface WSPresenceContextValue {
|
||||
/** The signed-in users with a socket open. */
|
||||
/** The signed-in users with a socket open, each once. */
|
||||
onlineUsers: OnlineUser[];
|
||||
}
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`, as if it were still connecting. */
|
||||
export const defaultWSValue: WSContextValue = {
|
||||
wsStatus: "connecting",
|
||||
wsErrorMessage: null,
|
||||
@@ -262,7 +302,7 @@ export const defaultWSValue: WSContextValue = {
|
||||
clearUnreadNotifications: () => {},
|
||||
};
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`: an empty chat. */
|
||||
export const defaultWSChatValue: WSChatContextValue = {
|
||||
chatMessages: [],
|
||||
deletedChatIds: new Set(),
|
||||
@@ -283,22 +323,25 @@ export const defaultWSChatValue: WSChatContextValue = {
|
||||
setChatOpen: () => {},
|
||||
};
|
||||
|
||||
/** What a consumer outside any provider gets. */
|
||||
/** Given to a consumer outside `WSProvider`: nobody is online. */
|
||||
export const defaultWSPresenceValue: WSPresenceContextValue = {
|
||||
onlineUsers: [],
|
||||
};
|
||||
|
||||
/** The socket context: connection, votes, likes and live events. */
|
||||
/**
|
||||
* Holds the connection, the votes and likes, and the latest live event of
|
||||
* each kind. Read it with `useWS`.
|
||||
*/
|
||||
export const WSContext: React.Context<WSContextValue> = createContext<
|
||||
WSContextValue
|
||||
>(defaultWSValue);
|
||||
/** The chat context. */
|
||||
/** Holds the chat's live stream and read state. Read it with `useWSChat`. */
|
||||
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
|
||||
WSChatContextValue
|
||||
>(
|
||||
defaultWSChatValue,
|
||||
);
|
||||
/** The presence context. */
|
||||
/** Holds who is online. Read it with `useWSPresence`. */
|
||||
export const WSPresenceContext: React.Context<WSPresenceContextValue> =
|
||||
createContext<
|
||||
WSPresenceContextValue
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* The socket's provider: one connection per session, and the live state it
|
||||
* feeds — presence, votes, likes, dumps, playlists, comments, users,
|
||||
* notifications and the chat.
|
||||
* Holds the app's one WebSocket to `/ws` and turns its messages into the state
|
||||
* of `WSContext`, `WSChatContext`, `WSPresenceContext` and
|
||||
* `AvatarOverrideContext`. `App` mounts it around every page. The protocol is
|
||||
* `shared/ws.ts`, and the server's side is `api/routes/ws.ts`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -59,69 +60,55 @@ import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
import { playChime } from "../../utils/shared/sound.ts";
|
||||
import { mentionsUser } from "../../utils/chat/text.ts";
|
||||
|
||||
/** The props of {@link WSProvider}. */
|
||||
/** Passed to {@link WSProvider} by `App`. */
|
||||
export interface WSProviderProps {
|
||||
/** The app, which reads the live state. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The longest wait between reconnection attempts, in ms. */
|
||||
const MAX_BACKOFF = 30_000;
|
||||
/**
|
||||
* How long an optimistic vote or like waits for its acknowledgement before it
|
||||
* is rolled back, in ms.
|
||||
*/
|
||||
/** How long an optimistic vote or like waits for the server, in ms. */
|
||||
const ACK_TIMEOUT = 5_000;
|
||||
/**
|
||||
* How long a connection attempt may take before the socket is shown as down, in
|
||||
* ms.
|
||||
*/
|
||||
/** How long a connection attempt may take before the banner shows, in ms. */
|
||||
const CONNECT_TIMEOUT = 2_500;
|
||||
/**
|
||||
* Cap on the live "new dumps" buffer, so a long-lived session can't grow it
|
||||
* without bound. Feeds merge these on top of their own paginated list, so a
|
||||
* generous cap is plenty — older live arrivals are reachable via normal
|
||||
* pagination.
|
||||
* The most dumps `recentDumps` holds. Older arrivals are in the feeds' later
|
||||
* pages.
|
||||
*/
|
||||
const MAX_RECENT_DUMPS = 100;
|
||||
/**
|
||||
* Cap on the live chat buffer, so a long-lived session can't grow it without
|
||||
* bound. Older messages remain reachable via the /api/chat history endpoint.
|
||||
* The most messages `chatMessages` holds. Older ones are in the history,
|
||||
* which `ChatModal` fetches.
|
||||
*/
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
/**
|
||||
* How long a sent chat message may go without its echo before it's shown as
|
||||
* failed, in ms.
|
||||
*/
|
||||
/** How long a sent chat message waits for its echo, in ms. */
|
||||
const CHAT_SEND_TIMEOUT = 10_000;
|
||||
/** How often the read marker may go out, in ms. */
|
||||
/**
|
||||
* The least time between two read markers, in ms. The server drops markers
|
||||
* past a rate of one a second.
|
||||
*/
|
||||
const CHAT_READ_THROTTLE = 1_000;
|
||||
/**
|
||||
* A failed send whose text turns up in the resynced history within this long of
|
||||
* being sent did reach the server — only its echo was lost.
|
||||
* How close in time a message of the history must be to a failed send with
|
||||
* the same text to count as that send, in ms.
|
||||
*/
|
||||
const CHAT_ECHO_WINDOW = 60_000;
|
||||
|
||||
/** An optimistic vote awaiting its acknowledgement. */
|
||||
/** An optimistic vote waiting for the server. */
|
||||
interface PendingVote {
|
||||
/** The timer that rolls it back. */
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
/** Undoes it. */
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
/** An optimistic like awaiting its acknowledgement. */
|
||||
/** An optimistic like waiting for the server. */
|
||||
interface PendingLike {
|
||||
/** The timer that rolls it back. */
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
/** Undoes it. */
|
||||
rollback: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `data` as a server message, or null when it isn't one. The runtime
|
||||
* check is minimal: the `type` field must be a string, so the cast to the
|
||||
* discriminated union is safe and TypeScript narrows from there.
|
||||
* Returns `data` as a server message, or null when it isn't JSON with a string
|
||||
* `type`. The fields aren't checked: the server is trusted.
|
||||
*/
|
||||
function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
||||
try {
|
||||
@@ -137,12 +124,11 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
|
||||
|
||||
/**
|
||||
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
|
||||
* and {@link AvatarOverrideContext}. It connects with the session's token and
|
||||
* reconnects with a growing backoff; each reconnect bumps `connectionEpoch` so
|
||||
* pages backfill what they missed. Votes, likes and chat messages apply at once
|
||||
* and are confirmed or rolled back by the server's answer. A new notification
|
||||
* or chat message chimes, and the unread count goes in the tab title. A
|
||||
* `force_logout` signs the user out; a change of session clears the live state.
|
||||
* and {@link AvatarOverrideContext}. It connects with the session's token, or
|
||||
* anonymously, and reconnects with a backoff doubling up to 30 s. A new token
|
||||
* (sign-in, sign-out, another account) clears the live state and reconnects.
|
||||
* A new notification or chat message chimes, and the unread counts go in the
|
||||
* tab title. A `force_logout` signs the user out.
|
||||
*/
|
||||
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { token, user, logout } = useAuth();
|
||||
@@ -152,9 +138,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
>("connecting");
|
||||
const [wsErrorMessage, setWSErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Tracks the token to detect login/logout below. The actual reset runs after
|
||||
// all state declarations (see the `prevToken !== token` block) because it
|
||||
// touches setters that aren't in scope yet here.
|
||||
// For the reset below, which must follow every state declaration.
|
||||
const [prevToken, setPrevToken] = useState(token);
|
||||
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
@@ -190,20 +174,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
>([]);
|
||||
const [chatError, setChatError] = useState<ChatError | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
Record<string, AvatarOverride>
|
||||
>({});
|
||||
// Bumped each time the socket reconnects (a `welcome` after the first), so
|
||||
// pages can backfill events they missed while disconnected. Monotonic — not
|
||||
// reset on token change.
|
||||
// Never reset: pages compare it with the value they last saw.
|
||||
const [connectionEpoch, setConnectionEpoch] = useState(0);
|
||||
|
||||
// Reset during render when the token changes (login/logout/account switch),
|
||||
// rather than inside the effect (which would cause a cascading re-render).
|
||||
// The socket reconnects and `welcome` re-syncs presence/votes/likes/unread,
|
||||
// but the accumulated realtime state below would otherwise bleed from the
|
||||
// previous session into the next (e.g. another account's recent dumps).
|
||||
// A new token means another session: the `welcome` resyncs votes, likes and
|
||||
// unread counts, but what the last session gathered (another account's
|
||||
// private dumps, its pending messages) must go. Reset during render, since
|
||||
// an effect would render the stale state once more.
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setWSStatus("connecting");
|
||||
@@ -234,15 +214,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
// The socket's handlers and the stable callbacks read the current values
|
||||
// through these refs. Depending on the values instead would reconnect the
|
||||
// socket, or change the callbacks, on every vote.
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
const myVotesRef = useRef(myVotes);
|
||||
const likeCountsRef = useRef(likeCounts);
|
||||
const myLikesRef = useRef(myLikes);
|
||||
const userIdRef = useRef(userId);
|
||||
const usernameRef = useRef(user?.username ?? null);
|
||||
// Stable ref for logout so the effect doesn't reconnect when the function
|
||||
// reference changes on re-renders.
|
||||
const onForceLogoutRef = useRef(logout);
|
||||
useLayoutEffect(() => {
|
||||
voteCountsRef.current = voteCounts;
|
||||
@@ -255,23 +235,23 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks whether the chatbox is open so it can be re-announced to the server
|
||||
// after a reconnect (the fresh socket starts with the flag cleared).
|
||||
// A new socket starts with the chat closed on the server's side, so the
|
||||
// `welcome` handler announces it again.
|
||||
const chatOpenRef = useRef(false);
|
||||
// The user is only actually reading the chat when the chatbox is open *and*
|
||||
// the tab is on screen: a chatbox left open in a background tab must not
|
||||
// A chat left open in a background tab isn't being read: it must not
|
||||
// swallow mention notifications or unread counts.
|
||||
const isViewingChat = useCallback(
|
||||
() => chatOpenRef.current && !document.hidden,
|
||||
[],
|
||||
);
|
||||
// Echo timeouts of in-flight chat sends, by nonce.
|
||||
// By nonce. A send is in flight while it has a timer here.
|
||||
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const chatErrorSeqRef = useRef(0);
|
||||
// Read marker bookkeeping: the newest marker sent (or learned from the
|
||||
// server), and a trailing timer so a burst of arrivals sends one marker.
|
||||
// The read marker, in ms. `Sent` is the newest the server has, from this tab
|
||||
// or another. `Queued` is the newest to send, which a trailing timer sends
|
||||
// once for a burst of messages.
|
||||
const chatReadSentRef = useRef(0);
|
||||
const chatReadQueuedRef = useRef(0);
|
||||
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -293,11 +273,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
|
||||
}, []);
|
||||
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
// By dump id, and by comment id for likes. One per item: a second click
|
||||
// before the answer replaces the first's timer, so only the latest change
|
||||
// can be rolled back.
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
);
|
||||
// Tracks pending optimistic comment likes: commentId → pending rollback handler
|
||||
const pendingLikesRef = useRef<Map<string, PendingLike>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -360,8 +341,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let everConnected = false;
|
||||
// Counts `welcome` messages for this token; the first is the initial
|
||||
// connect, any later one is a reconnect worth a backfill epoch bump.
|
||||
// For this token. Every `welcome` after the first is a reconnect.
|
||||
let welcomeCount = 0;
|
||||
|
||||
function connect() {
|
||||
@@ -373,6 +353,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const ws = new WebSocket(url);
|
||||
socketRef.current = ws;
|
||||
|
||||
// A connection that hangs may take far longer than this to fail. Closing
|
||||
// it shows the banner and goes through `onclose` to the next attempt.
|
||||
connectTimeout = setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.CONNECTING) return;
|
||||
setWSStatus("disconnected");
|
||||
@@ -398,13 +380,14 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
// The server closes a socket that misses a pong.
|
||||
ws.send(
|
||||
JSON.stringify({ type: "pong" } satisfies ClientToServerMessage),
|
||||
);
|
||||
break;
|
||||
|
||||
case "welcome":
|
||||
backoff = 500; // reset backoff on successful connect
|
||||
backoff = 500;
|
||||
setOnlineUsers(msg.users);
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
@@ -418,24 +401,21 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
readAt.getTime(),
|
||||
);
|
||||
}
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
// The server's sets replace whatever was pending. The optimistic
|
||||
// counts stay, though, until a live count replaces them.
|
||||
clearAllPendingVotes();
|
||||
clearAllPendingLikes();
|
||||
// Reconnect (not the first welcome): signal pages to backfill the
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) {
|
||||
setConnectionEpoch((n) => n + 1);
|
||||
// With the chatbox closed nothing will resync the live buffer,
|
||||
// and whatever was edited or deleted meanwhile would win over
|
||||
// the fresh history it fetches on open — so just drop it.
|
||||
// With the chat closed, nothing resyncs the live buffer, and its
|
||||
// stale copies would win over the history fetched on open.
|
||||
if (!chatOpenRef.current) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
}
|
||||
}
|
||||
// A marker that was waiting on the socket goes out now.
|
||||
// A marker set while offline goes out now.
|
||||
if (chatReadQueuedRef.current > chatReadSentRef.current) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
@@ -447,8 +427,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
);
|
||||
chatReadSentRef.current = chatReadQueuedRef.current;
|
||||
}
|
||||
// Restore the chat-open flag on the fresh connection so mention
|
||||
// suppression keeps working across reconnects.
|
||||
// The server knows the chat is open per socket, and this one is
|
||||
// new.
|
||||
if (isViewingChat()) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
@@ -469,8 +449,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { dumpId, voteCount, voterId, action } = msg;
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
setLastVoteEvent({ dumpId, voterId, action });
|
||||
// Keep myVotes in sync across tabs: if this vote event belongs to
|
||||
// the current user (from another tab), update myVotes accordingly.
|
||||
// The user's own vote, from this tab or another: it settles the
|
||||
// pending one and keeps every tab's button in step.
|
||||
if (voterId === userIdRef.current) {
|
||||
clearPendingVote(dumpId);
|
||||
setMyVotes((prev) => {
|
||||
@@ -487,8 +467,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const { commentId, likeCount, likerId, action } = msg;
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
setLastLikeEvent({ commentId, likerId, action });
|
||||
// Keep myLikes in sync across tabs: if this like event belongs to
|
||||
// the current user (from another tab), update myLikes accordingly.
|
||||
// As for votes.
|
||||
if (likerId === userIdRef.current) {
|
||||
clearPendingLike(commentId);
|
||||
setMyLikes((prev) => {
|
||||
@@ -502,8 +481,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "dump_created": {
|
||||
// Also sent when a dump goes from private to public: to everyone
|
||||
// who couldn't see it, that's an arrival.
|
||||
// Also sent when a dump goes public, which to everyone else is an
|
||||
// arrival, and may undo its `dump_deleted` from going private.
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setDeletedDumpIds((prev) => {
|
||||
if (!prev.has(dump.id)) return prev;
|
||||
@@ -523,9 +502,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "dump_updated": {
|
||||
const dump = deserializeDump(msg.dump);
|
||||
setLastDumpEvent(dump);
|
||||
// An edit is not an arrival: refresh a live entry in place, but
|
||||
// don't pull an old dump to the top of the feeds (going public
|
||||
// comes as a `dump_created`).
|
||||
// An edit is not an arrival: it must not pull an old dump to the
|
||||
// top of the feeds.
|
||||
setRecentDumps((prev) =>
|
||||
prev.some((d) => d.id === dump.id)
|
||||
? prev.map((d) => d.id === dump.id ? dump : d)
|
||||
@@ -535,6 +513,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
case "dump_deleted": {
|
||||
// Also sent when a dump goes private.
|
||||
const { dumpId } = msg;
|
||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||
@@ -544,9 +523,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "vote_ack": {
|
||||
const { dumpId, action, voteCount } = msg;
|
||||
clearPendingVote(dumpId);
|
||||
// Reconcile with authoritative count
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
// Confirm vote state
|
||||
setMyVotes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(dumpId);
|
||||
@@ -559,9 +536,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "comment_like_ack": {
|
||||
const { commentId, action, likeCount } = msg;
|
||||
clearPendingLike(commentId);
|
||||
// Reconcile with authoritative count
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: likeCount }));
|
||||
// Confirm like state
|
||||
setMyLikes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (action === "cast") next.add(commentId);
|
||||
@@ -603,8 +578,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
case "user_updated": {
|
||||
const user = deserializePublicUser(msg.user);
|
||||
setLastUserEvent({ user });
|
||||
// Refresh this user's avatar everywhere it's rendered. updatedAt
|
||||
// (bumped on any profile/avatar write) doubles as the version.
|
||||
setAvatarOverrides((prev) => ({
|
||||
...prev,
|
||||
[user.id]: {
|
||||
@@ -655,16 +628,16 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
||||
});
|
||||
// The sender's own echo: its pending copy is now a message.
|
||||
if (msg.nonce) retirePendingChat(msg.nonce);
|
||||
// Count as unread unless it's the current user's own message, or
|
||||
// they're looking at it (the chatbox then moves the read marker).
|
||||
// A message read live isn't counted: the chat moves the marker.
|
||||
if (message.userId !== userIdRef.current) {
|
||||
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
|
||||
const username = usernameRef.current;
|
||||
if (username && mentionsUser(message.body, username)) {
|
||||
// Someone reading the chat gets no notification for a mention
|
||||
// (the server skips it), so the chime has to come from here;
|
||||
// otherwise the notification that follows brings its own.
|
||||
// The server sends no notification for a mention read live, so
|
||||
// the chime comes from here. Otherwise the notification that
|
||||
// follows chimes.
|
||||
if (isViewingChat()) playChime("notify", message.id);
|
||||
} else if (!isViewingChat()) {
|
||||
playChime("chat", message.id);
|
||||
@@ -675,6 +648,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
case "chat_message_updated": {
|
||||
const message = deserializeChatMessage(msg.message);
|
||||
// An edit of a message older than the buffer is added to it, so
|
||||
// it wins over the history's copy. `ChatModal` sorts by time.
|
||||
setChatMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === message.id);
|
||||
if (idx === -1) return [...prev, message];
|
||||
@@ -697,6 +672,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
break;
|
||||
|
||||
case "chat_read_update": {
|
||||
// Sent to every socket of the user, whichever tab moved it.
|
||||
const readAt = new Date(msg.readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
@@ -718,9 +694,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
else setChatError({ id: ++chatErrorSeqRef.current, message });
|
||||
break;
|
||||
}
|
||||
// When the server identifies the failed optimistic action, revert
|
||||
// its pending vote/like right away instead of waiting out the ACK
|
||||
// timeout. Without context, the per-action timer handles cleanup.
|
||||
// A failed vote or like names its dump or comment, so it is
|
||||
// rolled back at once. Other errors leave it to the timer.
|
||||
if (msg.dumpId) {
|
||||
const pending = pendingRef.current.get(msg.dumpId);
|
||||
if (pending) {
|
||||
@@ -747,9 +722,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
connectTimeout = null;
|
||||
}
|
||||
if (closed) return;
|
||||
// Sends still waiting on their echo won't get one on this socket.
|
||||
// They may have landed, though: the resync after reconnect retires
|
||||
// any whose text shows up in the history.
|
||||
// Sends still waiting won't get their echo on this socket. They may
|
||||
// have landed, though: `reconcileChat` retires those it finds.
|
||||
for (const nonce of chatSendTimersRef.current.keys()) {
|
||||
failPendingChat(
|
||||
nonce,
|
||||
@@ -769,7 +743,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after onerror
|
||||
// `onclose` follows, and reconnects.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -804,11 +778,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
]);
|
||||
|
||||
const castVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
// Until the socket brings a count, the one on screen is the only one.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (prevVoted) return; // already voted
|
||||
if (prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -817,7 +790,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -834,15 +806,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
// Nothing is sent while the socket is down, so the timer rolls it back.
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const removeVote = useCallback((dumpId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = voteCountsRef.current[dumpId] ?? shownCount ?? 0;
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (!prevVoted) return; // not voted
|
||||
if (!prevVoted) return;
|
||||
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -854,7 +824,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
[dumpId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingVote(dumpId, () => {
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -871,16 +840,13 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
}, [schedulePendingVote]);
|
||||
|
||||
const castCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (prevLiked) return; // already liked
|
||||
if (prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -889,7 +855,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
});
|
||||
setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 }));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -909,18 +874,15 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
|
||||
const removeCommentLike = useCallback(
|
||||
(commentId: string, shownCount?: number) => {
|
||||
// Optimistic update. Until a live event arrives there's no count here, only
|
||||
// the one the caller got over REST and is showing.
|
||||
const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0;
|
||||
const prevLiked = myLikesRef.current.has(commentId);
|
||||
if (!prevLiked) return; // not liked
|
||||
if (!prevLiked) return;
|
||||
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -932,7 +894,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
[commentId]: Math.max(0, prevCount - 1),
|
||||
}));
|
||||
|
||||
// Schedule revert if no authoritative confirmation arrives.
|
||||
schedulePendingLike(commentId, () => {
|
||||
setMyLikes((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -952,7 +913,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
),
|
||||
);
|
||||
}
|
||||
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT
|
||||
},
|
||||
[schedulePendingLike],
|
||||
);
|
||||
@@ -968,9 +928,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
// Sends (or resends) a pending message: it shows as "sending" until its echo
|
||||
// arrives, or as failed if the socket is down, the server rejects it, or no
|
||||
// echo comes back in time.
|
||||
// The pending message leaves the list with its echo (`chat_message`), or
|
||||
// fails on a refusal (`error`), on a closed socket or after the timeout.
|
||||
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
@@ -1025,8 +984,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const retryChatMessage = useCallback((nonce: string) => {
|
||||
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
|
||||
if (!pending) return;
|
||||
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
|
||||
// retire the retry.
|
||||
// A late echo of the first attempt must not retire the retry.
|
||||
const retry: PendingChatMessage = {
|
||||
...pending,
|
||||
nonce: crypto.randomUUID(),
|
||||
@@ -1076,7 +1034,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
const at = chatReadQueuedRef.current;
|
||||
if (at <= chatReadSentRef.current) return;
|
||||
const socket = socketRef.current;
|
||||
// Offline: stays queued, and the next welcome sends it.
|
||||
// Offline, it stays queued, and the next `welcome` sends it.
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
@@ -1096,7 +1054,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
chatReadQueuedRef.current = time;
|
||||
// The badge goes at once; the server's count follows with the marker.
|
||||
// The badge goes at once. The server's count follows the marker.
|
||||
setUnreadChatCount(0);
|
||||
if (chatReadTimerRef.current) return;
|
||||
const wait = Math.max(
|
||||
@@ -1113,9 +1071,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
|
||||
const reconcileChat = useCallback(
|
||||
(messages: ChatMessage[], from: Date, to: Date) => {
|
||||
// In that stretch the fetched copy is the truth: a live copy of a
|
||||
// message edited while the socket was down is stale, and the merge in
|
||||
// the chatbox would let it win.
|
||||
// A live copy of a message edited while the socket was down is stale,
|
||||
// and `ChatModal`'s merge would let it win over the fetched one.
|
||||
const byId = new Map(messages.map((m) => [m.id, m]));
|
||||
setChatMessages((prev) =>
|
||||
prev
|
||||
@@ -1124,7 +1081,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
)
|
||||
.map((m) => byId.get(m.id) ?? m)
|
||||
);
|
||||
// Failed sends that did land (only the echo was lost) are retired.
|
||||
// A failed send that did land lost only its echo. Its text and time
|
||||
// are all there is to recognize it by.
|
||||
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
||||
for (const pending of pendingChatRef.current) {
|
||||
if (pending.status !== "failed") continue;
|
||||
@@ -1149,8 +1107,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tell the server whether the user is reading the chat right now (chatbox
|
||||
// open and tab visible), so it knows when a mention notification is moot.
|
||||
const sendChatFocus = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
@@ -1169,7 +1125,8 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
sendChatFocus();
|
||||
}, [sendChatFocus]);
|
||||
|
||||
// Re-announce chat focus whenever the tab is hidden or shown.
|
||||
// Hiding the tab stops the user reading an open chat, and showing it again
|
||||
// resumes.
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (chatOpenRef.current) sendChatFocus();
|
||||
@@ -1178,7 +1135,6 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
|
||||
return () => document.removeEventListener("visibilitychange", onVisibility);
|
||||
}, [sendChatFocus]);
|
||||
|
||||
// Unread count in the tab title, so a background tab shows there's news.
|
||||
useEffect(() => {
|
||||
setTitleBadge(unreadNotificationCount + unreadChatCount);
|
||||
}, [unreadNotificationCount, unreadChatCount]);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user