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:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

@@ -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,

View File

@@ -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 = () => {

View File

@@ -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;

View File

@@ -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()}>

View File

@@ -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;

View File

@@ -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 }:

View File

@@ -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,

View File

@@ -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: "" });

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}}

View File

@@ -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));

View File

@@ -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">

View File

@@ -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}`

View File

@@ -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">

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
}}
>

View File

@@ -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 });

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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 ||

View File

@@ -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) => (

View File

@@ -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,

View File

@@ -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 = "";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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();

View File

@@ -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 = () => {

View File

@@ -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"

View File

@@ -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(() => {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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");

View File

@@ -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 = () => {

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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,

View File

@@ -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}`,

View File

@@ -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,

View File

@@ -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();
}}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 = "";
};

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}}

View File

@@ -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();
}

View File

@@ -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);
}}