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

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

View File

@@ -1,4 +1,4 @@
name: Build and deploy docs name: Build and Deploy Static Documentation Site
on: on:
push: push:
@@ -6,6 +6,7 @@ on:
paths: paths:
- "api/**" - "api/**"
- "shared/**" - "shared/**"
- "src/**"
- "mod.ts" - "mod.ts"
- "deno.json" - "deno.json"
- "deno.lock" - "deno.lock"

View File

@@ -158,22 +158,34 @@ place that fetches from other sites. Tests mirror the source one file per
module, so the tests of `services/chat-service.ts` are in module, so the tests of `services/chat-service.ts` are in
`test/services/chat-service.test.ts`. `test/services/chat-service.test.ts`.
The backend and `shared/` are documented for `deno doc`, the same way in every All three parts — `api/`, `shared/` and `src/` — are documented for `deno doc`,
file. Each module opens with a `/** … @module */` block saying what it is for. the same way in every file. Each module opens with a `/** … @module */` block
Every export has a JSDoc comment, and so does every field of an exported saying what it is for. Every export has a JSDoc comment, and so does every field
interface; a comment that describes a declaration is always `/** */`, and `//` of an exported interface; a comment that describes a declaration is always `/**
is only for notes on the code inside. Functions open with a verb ("Returns…", */`, and `//` is only for notes on the code inside. Functions open with a verb
"Checks…"), everything else with a noun phrase. Parameters are named in the ("Returns…", "Checks…", "Renders…" for a component), everything else with a noun
prose, in backticks. `{@link}` points at symbols the file already has in scope. phrase. Parameters are named in the prose, in backticks. `{@link}` points at
A function that can throw to its caller says so with `@throws`: for an symbols the file already has in scope. A function that can throw to its caller
`APIException`, the statuses and when. A router's JSDoc lists its endpoints, one says so with `@throws`: for an `APIException`, the statuses and when. A router's
line each: method and path, who may call it, what it does. `deno task doc` JSDoc lists its endpoints, one line each: method and path, who may call it, what
builds the HTML reference into `docs/` from every module of `api/` and `shared/` it does; a page's names its route and who may see it.
but the tests. Its index page is the module doc of `mod.ts` at the root, which
introduces the repository's three parts and links every documented module In the frontend, every exported function states its return type, so the
(`mod.test.ts` checks it), and the `docs` workflow publishes it on every push to reference can show it: a component returns `React.JSX.Element` (or `| null`), a
`main` that touches them. `deno task doc:lint` checks the rules the linter can hook returning several things returns a documented `Use<Name>Result` interface.
see. A component that takes props takes an exported `<Name>Props` interface, each
prop documented. React's types are used through the global `React` namespace
rather than a default import of `react`, which `deno doc` can't follow.
`deno task doc` builds the HTML reference into `docs/` from every module of
`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`).
Its index page is the module doc of `mod.ts` at the root, which introduces the
repository's three parts and links every documented module (`mod.test.ts` checks
it), and the `docs` workflow publishes it on every push to `main` that touches
them. `deno task doc:lint` checks the rules the linter can see, through
`doc-lint.ts`: `deno doc` doesn't document npm packages, so it takes a type
imported from one (react-hook-form's `FieldValues`, say) for a private one, and
those references alone are let through.
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
split into the same domain folders — `auth`, `category`, `chat`, `comment`, split into the same domain folders — `auth`, `category`, `chat`, `comment`,

View File

@@ -12,8 +12,8 @@
"test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/ mod.test.ts", "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/ mod.test.ts",
"test:web": "deno run -A npm:vitest run", "test:web": "deno run -A npm:vitest run",
"test:web:coverage": "deno run -A npm:vitest run --coverage", "test:web:coverage": "deno run -A npm:vitest run --coverage",
"doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')", "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')",
"doc:lint": "deno doc --lint mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')" "doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')"
}, },
"exports": "./mod.ts", "exports": "./mod.ts",
"nodeModulesDir": "auto", "nodeModulesDir": "auto",

77
doc-lint.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* `deno task doc:lint`: runs `deno doc --lint` over the modules given as
* arguments and fails on what it reports, with one exception. `deno doc`
* doesn't document npm packages, so it calls every type imported from one
* private — react-hook-form's `FieldValues` in a form field's props, say —
* and no change to our own documentation could satisfy it. A `private-type-ref`
* whose type the module imports from a package is let through, and counted;
* every other diagnostic fails the run.
*
* @module
*/
/** One diagnostic of `deno doc --lint`. */
interface Diagnostic {
/** The rule, e.g. `missing-jsdoc`. */
rule: string;
/** The rule's message. */
message: string;
/** The module it was reported in. */
file: string;
/** The diagnostic as printed, location and excerpt included. */
text: string;
}
/** Returns the diagnostics of `deno doc --lint` over `modules`. */
async function lint(modules: string[]): Promise<Diagnostic[]> {
const { stderr } = await new Deno.Command(Deno.execPath(), {
args: ["doc", "--lint", ...modules],
env: { NO_COLOR: "1" },
stdout: "null",
stderr: "piped",
}).output();
const output = new TextDecoder().decode(stderr);
return output.split(/\n(?=error\[)/).flatMap((text) => {
const head = /^error\[([\w-]+)\]: (.*)$/m.exec(text);
const at = /--> (.+?):\d+:\d+/.exec(text);
if (!head || !at) return [];
return [{ rule: head[1], message: head[2], file: at[1], text }];
});
}
/** Checks whether module `file` imports `name` from a package. */
function importedFromPackage(file: string, name: string): boolean {
const source = Deno.readTextFileSync(file);
const imports = source.matchAll(
/import\s+(?:type\s+)?([^;]*?)\s+from\s+"([^"]+)"/g,
);
for (const [, clause, specifier] of imports) {
if (specifier.startsWith(".")) continue;
const names = clause.replace(/[{}]/g, " ").split(",").map((part) =>
part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()!.trim()
);
if (names.includes(name)) return true;
}
return false;
}
/** Checks whether `d` is a private type from a package, which is let through. */
function isPackageTypeRef(d: Diagnostic): boolean {
const type = /references private type '([\w$]+)/.exec(d.message)?.[1];
return d.rule === "private-type-ref" && !!type &&
importedFromPackage(d.file, type);
}
if (import.meta.main) {
const diagnostics = await lint(Deno.args);
const failures = diagnostics.filter((d) => !isPackageTypeRef(d));
for (const d of failures) console.error(d.text.trimEnd() + "\n");
const allowed = diagnostics.length - failures.length;
console.error(
`${failures.length} documentation lint errors` +
(allowed
? `, ${allowed} references to npm packages' types let through`
: ""),
);
if (failures.length) Deno.exit(1);
}

View File

@@ -3,18 +3,20 @@ import { assertEquals } from "@std/assert";
Deno.test("mod.ts, the docs' index page, links every documented module", async () => { Deno.test("mod.ts, the docs' index page, links every documented module", async () => {
const doc = await Deno.readTextFile("mod.ts"); const doc = await Deno.readTextFile("mod.ts");
const linked = new Set( const linked = new Set(
[...doc.matchAll(/\]\(\.\/(\S+?\.ts)\/index\.html\)/g)].map((m) => m[1]), [...doc.matchAll(/\]\(\.\/(\S+?\.tsx?)\/index\.html\)/g)].map((m) => m[1]),
); );
const modules: string[] = []; const modules: string[] = [];
async function walk(dir: string) { async function walk(dir: string) {
for await (const entry of Deno.readDir(dir)) { for await (const entry of Deno.readDir(dir)) {
const path = `${dir}/${entry.name}`; const path = `${dir}/${entry.name}`;
if (entry.isDirectory && entry.name !== "test") await walk(path); if (entry.isDirectory) {
else if (entry.name.endsWith(".ts")) modules.push(path); if (entry.name !== "test" && path !== "src/locales") await walk(path);
} else if (/\.tsx?$/.test(entry.name)) modules.push(path);
} }
} }
await walk("api"); await walk("api");
await walk("shared"); await walk("shared");
await walk("src");
assertEquals(modules.filter((m) => !linked.has(m)), []); assertEquals(modules.filter((m) => !linked.has(m)), []);
assertEquals([...linked].filter((m) => !modules.includes(m)), []); assertEquals([...linked].filter((m) => !modules.includes(m)), []);
}); });

199
mod.ts
View File

@@ -4,7 +4,7 @@
* A small invite-only social platform for sharing links and files. The * A small invite-only social platform for sharing links and files. The
* repository holds three parts: the backend in `api/`, the frontend in `src/`, * repository holds three parts: the backend in `api/`, the frontend in `src/`,
* and in `shared/` whatever crosses the wire between them. This reference * and in `shared/` whatever crosses the wire between them. This reference
* documents the backend and the shared modules; the frontend isn't in it yet. * documents all three.
* *
* ## Backend — `api/` * ## Backend — `api/`
* *
@@ -155,7 +155,202 @@
* *
* ## Frontend — `src/` * ## Frontend — `src/`
* *
* The React app, built with Vite. Not in this reference yet. * The React app, built with Vite and translated with Lingui. Each layer holds
* one kind of module, and every layer but pages/ is split into the same domains
* (auth, category, chat, comment, dump, editor, feed, form, layout, player,
* playlist, profile, social, theme, ws, and shared for what belongs to no one
* domain). A page reads the API and the socket through contexts and hooks, and
* draws with components; utils hold what isn't React at all.
*
* - **Entry point and app** — mounting, the providers and the route table, the
* language: [App](./src/App.tsx/index.html),
* [i18n](./src/i18n.ts/index.html), [main](./src/main.tsx/index.html).
* - **Pages** — one component per route, each naming it, and the two guards
* that wrap the routes for the signed in and the signed out:
* [Dump](./src/pages/Dump.tsx/index.html),
* [DumpEdit](./src/pages/DumpEdit.tsx/index.html),
* [Index](./src/pages/Index.tsx/index.html),
* [NotFound](./src/pages/NotFound.tsx/index.html),
* [Notifications](./src/pages/Notifications.tsx/index.html),
* [PlaylistDetail](./src/pages/PlaylistDetail.tsx/index.html),
* [ResetPassword](./src/pages/ResetPassword.tsx/index.html),
* [RestrictedGuest](./src/pages/RestrictedGuest.tsx/index.html),
* [RestrictedLoggedIn](./src/pages/RestrictedLoggedIn.tsx/index.html),
* [Search](./src/pages/Search.tsx/index.html),
* [UserDumps](./src/pages/UserDumps.tsx/index.html),
* [UserLogin](./src/pages/UserLogin.tsx/index.html),
* [UserPlaylists](./src/pages/UserPlaylists.tsx/index.html),
* [UserPublicProfile](./src/pages/UserPublicProfile.tsx/index.html),
* [UserRegister](./src/pages/UserRegister.tsx/index.html),
* [UserUpvoted](./src/pages/UserUpvoted.tsx/index.html).
* - **Config** — settings read at startup:
* [api](./src/config/api.ts/index.html),
* [feedTabs](./src/config/feedTabs.ts/index.html),
* [playerMode](./src/config/playerMode.ts/index.html).
* - **Contexts** — a FooContext holding the state and its FooProvider, by
* domain:
* - auth: [AuthContext](./src/contexts/auth/AuthContext.ts/index.html),
* [AuthProvider](./src/contexts/auth/AuthProvider.tsx/index.html).
* - category:
* [CategoriesContext](./src/contexts/category/CategoriesContext.ts/index.html),
* [CategoriesProvider](./src/contexts/category/CategoriesProvider.tsx/index.html).
* - chat: [ChatContext](./src/contexts/chat/ChatContext.ts/index.html),
* [ChatProvider](./src/contexts/chat/ChatProvider.tsx/index.html).
* - player:
* [PlayerContext](./src/contexts/player/PlayerContext.ts/index.html),
* [PlayerProvider](./src/contexts/player/PlayerProvider.tsx/index.html).
* - social:
* [FollowContext](./src/contexts/social/FollowContext.ts/index.html),
* [FollowProvider](./src/contexts/social/FollowProvider.tsx/index.html).
* - theme: [ThemeContext](./src/contexts/theme/ThemeContext.ts/index.html),
* [ThemeProvider](./src/contexts/theme/ThemeProvider.tsx/index.html).
* - ws:
* [AvatarOverrideContext](./src/contexts/ws/AvatarOverrideContext.ts/index.html),
* [WSContext](./src/contexts/ws/WSContext.ts/index.html),
* [WSProvider](./src/contexts/ws/WSProvider.tsx/index.html).
* - **Hooks** — every hook, by domain:
* - auth: [useAuth](./src/hooks/auth/useAuth.ts/index.html).
* - category:
* [useCategories](./src/hooks/category/useCategories.ts/index.html).
* - chat: [useChat](./src/hooks/chat/useChat.ts/index.html).
* - dump: [useDumpCardLink](./src/hooks/dump/useDumpCardLink.ts/index.html),
* [useDumpListSync](./src/hooks/dump/useDumpListSync.ts/index.html),
* [useUserDumpFeed](./src/hooks/dump/useUserDumpFeed.ts/index.html).
* - editor:
* [useEmojiTrigger](./src/hooks/editor/useEmojiTrigger.ts/index.html),
* [useMentionAutocomplete](./src/hooks/editor/useMentionAutocomplete.ts/index.html).
* - feed:
* [useDefaultFeedTab](./src/hooks/feed/useDefaultFeedTab.ts/index.html),
* [useFading](./src/hooks/feed/useFading.ts/index.html),
* [useFeedCache](./src/hooks/feed/useFeedCache.ts/index.html),
* [useInfiniteScroll](./src/hooks/feed/useInfiniteScroll.ts/index.html),
* [usePositionAwareSync](./src/hooks/feed/usePositionAwareSync.ts/index.html),
* [useScrollSave](./src/hooks/feed/useScrollSave.ts/index.html).
* - form: [useApiForm](./src/hooks/form/useApiForm.ts/index.html).
* - player:
* [usePlayRichContent](./src/hooks/player/usePlayRichContent.ts/index.html),
* [useYouTubePlaylist](./src/hooks/player/useYouTubePlaylist.ts/index.html).
* - playlist:
* [usePlaylistListSync](./src/hooks/playlist/usePlaylistListSync.ts/index.html).
* - shared:
* [useAnchoredFloat](./src/hooks/shared/useAnchoredFloat.ts/index.html),
* [useDocumentTitle](./src/hooks/shared/useDocumentTitle.ts/index.html),
* [useHeightTransition](./src/hooks/shared/useHeightTransition.ts/index.html),
* [useRoomBeside](./src/hooks/shared/useRoomBeside.ts/index.html),
* [useScrollLock](./src/hooks/shared/useScrollLock.ts/index.html),
* [useSoundsEnabled](./src/hooks/shared/useSoundsEnabled.ts/index.html),
* [useTabParam](./src/hooks/shared/useTabParam.ts/index.html).
* - social: [useFollows](./src/hooks/social/useFollows.ts/index.html).
* - theme: [useTheme](./src/hooks/theme/useTheme.ts/index.html).
* - ws: [useWS](./src/hooks/ws/useWS.ts/index.html).
* - **Components** — only components, by domain:
* - category:
* [CategoryManager](./src/components/category/CategoryManager.tsx/index.html),
* [CategorySelect](./src/components/category/CategorySelect.tsx/index.html),
* [CategorySwitcher](./src/components/category/CategorySwitcher.tsx/index.html).
* - chat: [ChatEditBox](./src/components/chat/ChatEditBox.tsx/index.html),
* [ChatMessageItem](./src/components/chat/ChatMessageItem.tsx/index.html),
* [ChatModal](./src/components/chat/ChatModal.tsx/index.html),
* [PendingItem](./src/components/chat/PendingItem.tsx/index.html),
* [ReplyRef](./src/components/chat/ReplyRef.tsx/index.html).
* - comment:
* [CommentForm](./src/components/comment/CommentForm.tsx/index.html),
* [CommentNode](./src/components/comment/CommentNode.tsx/index.html),
* [CommentThread](./src/components/comment/CommentThread.tsx/index.html).
* - dump: [DumpCard](./src/components/dump/DumpCard.tsx/index.html),
* [FilePreview](./src/components/dump/FilePreview.tsx/index.html),
* [JournalCard](./src/components/dump/JournalCard.tsx/index.html),
* [RichContentCard](./src/components/dump/RichContentCard.tsx/index.html),
* [DumpCreateModal](./src/components/dump/create/DumpCreateModal.tsx/index.html),
* [DuplicateNotice](./src/components/dump/create/DuplicateNotice.tsx/index.html),
* [LocalFilePreview](./src/components/dump/create/LocalFilePreview.tsx/index.html),
* [StepIndicator](./src/components/dump/create/StepIndicator.tsx/index.html),
* [TitleField](./src/components/dump/create/TitleField.tsx/index.html).
* - editor:
* [EmojiPickerPopup](./src/components/editor/EmojiPickerPopup.tsx/index.html),
* [Markdown](./src/components/editor/Markdown.tsx/index.html),
* [MentionDropdown](./src/components/editor/MentionDropdown.tsx/index.html),
* [TextEditor](./src/components/editor/TextEditor.tsx/index.html).
* - feed: [DumpFeed](./src/components/feed/DumpFeed.tsx/index.html),
* [FeedTabBar](./src/components/feed/FeedTabBar.tsx/index.html),
* [FollowedFeed](./src/components/feed/FollowedFeed.tsx/index.html),
* [JournalFeed](./src/components/feed/JournalFeed.tsx/index.html),
* [PresenceRow](./src/components/feed/PresenceRow.tsx/index.html).
* - form: [FileDropZone](./src/components/form/FileDropZone.tsx/index.html),
* [FileField](./src/components/form/FileField.tsx/index.html),
* [FormActions](./src/components/form/FormActions.tsx/index.html),
* [FormError](./src/components/form/FormError.tsx/index.html),
* [RichTextField](./src/components/form/RichTextField.tsx/index.html),
* [SegmentedField](./src/components/form/SegmentedField.tsx/index.html),
* [SubmitButton](./src/components/form/SubmitButton.tsx/index.html),
* [TextField](./src/components/form/TextField.tsx/index.html).
* - layout: [AppHeader](./src/components/layout/AppHeader.tsx/index.html),
* [ChatButton](./src/components/layout/ChatButton.tsx/index.html),
* [ChatFab](./src/components/layout/ChatFab.tsx/index.html),
* [DumpFab](./src/components/layout/DumpFab.tsx/index.html),
* [NotificationBell](./src/components/layout/NotificationBell.tsx/index.html),
* [PageError](./src/components/layout/PageError.tsx/index.html),
* [PageShell](./src/components/layout/PageShell.tsx/index.html),
* [SearchBar](./src/components/layout/SearchBar.tsx/index.html),
* [UserMenu](./src/components/layout/UserMenu.tsx/index.html).
* - player:
* [GlobalPlayer](./src/components/player/GlobalPlayer.tsx/index.html),
* [MediaPlayer](./src/components/player/MediaPlayer.tsx/index.html),
* [PlayerIcons](./src/components/player/PlayerIcons.tsx/index.html),
* [SeekBar](./src/components/player/SeekBar.tsx/index.html),
* [Waveform](./src/components/player/Waveform.tsx/index.html).
* - playlist:
* [AddToPlaylistModal](./src/components/playlist/AddToPlaylistModal.tsx/index.html),
* [NewPlaylistButton](./src/components/playlist/NewPlaylistButton.tsx/index.html),
* [PlaylistCard](./src/components/playlist/PlaylistCard.tsx/index.html),
* [PlaylistCreateForm](./src/components/playlist/PlaylistCreateForm.tsx/index.html),
* [PlaylistMembershipPanel](./src/components/playlist/PlaylistMembershipPanel.tsx/index.html).
* - profile:
* [ChangePasswordModal](./src/components/profile/ChangePasswordModal.tsx/index.html),
* [ProfileSubpageHeader](./src/components/profile/ProfileSubpageHeader.tsx/index.html).
* - shared: [Avatar](./src/components/shared/Avatar.tsx/index.html),
* [ConfirmModal](./src/components/shared/ConfirmModal.tsx/index.html),
* [CountedInput](./src/components/shared/CountedInput.tsx/index.html),
* [ErrorCard](./src/components/shared/ErrorCard.tsx/index.html),
* [ImagePicker](./src/components/shared/ImagePicker.tsx/index.html),
* [Modal](./src/components/shared/Modal.tsx/index.html),
* [TabBar](./src/components/shared/TabBar.tsx/index.html),
* [Thumbnail](./src/components/shared/Thumbnail.tsx/index.html),
* [ThumbnailPlaceholder](./src/components/shared/ThumbnailPlaceholder.tsx/index.html),
* [Tooltip](./src/components/shared/Tooltip.tsx/index.html).
* - social:
* [FollowButton](./src/components/social/FollowButton.tsx/index.html),
* [LikeButton](./src/components/social/LikeButton.tsx/index.html),
* [UserListPopover](./src/components/social/UserListPopover.tsx/index.html),
* [VoteButton](./src/components/social/VoteButton.tsx/index.html).
* - **Utils** — every module that isn't React, by domain:
* - auth: [permissions](./src/utils/auth/permissions.ts/index.html),
* [storage](./src/utils/auth/storage.ts/index.html).
* - chat: [draft](./src/utils/chat/draft.ts/index.html),
* [message](./src/utils/chat/message.ts/index.html),
* [text](./src/utils/chat/text.ts/index.html),
* [time](./src/utils/chat/time.ts/index.html).
* - dump: [draft](./src/utils/dump/draft.ts/index.html).
* - editor: [skinTone](./src/utils/editor/skinTone.ts/index.html),
* [text](./src/utils/editor/text.ts/index.html).
* - feed: [hotScore](./src/utils/feed/hotScore.ts/index.html),
* [journalLayout](./src/utils/feed/journalLayout.ts/index.html).
* - player: [bandcamp](./src/utils/player/bandcamp.ts/index.html),
* [duration](./src/utils/player/duration.ts/index.html),
* [streamSources](./src/utils/player/streamSources.ts/index.html),
* [volume](./src/utils/player/volume.ts/index.html),
* [waveform](./src/utils/player/waveform.ts/index.html),
* [youtube](./src/utils/player/youtube.ts/index.html).
* - shared: [api](./src/utils/shared/api.ts/index.html),
* [charCount](./src/utils/shared/charCount.ts/index.html),
* [deserialize](./src/utils/shared/deserialize.ts/index.html),
* [format](./src/utils/shared/format.ts/index.html),
* [proxyImage](./src/utils/shared/proxyImage.ts/index.html),
* [relativeTime](./src/utils/shared/relativeTime.ts/index.html),
* [sound](./src/utils/shared/sound.ts/index.html),
* [thumbnailTint](./src/utils/shared/thumbnailTint.ts/index.html),
* [urls](./src/utils/shared/urls.ts/index.html),
* [visited](./src/utils/shared/visited.ts/index.html).
* *
* @module * @module
*/ */

View File

@@ -1,3 +1,29 @@
/**
* The app: its providers and its routes.
*
* - `/` — redirects to the default feed tab.
* - `/~/:feedTab` — the feed of every dump ({@link Index}).
* - `/:categorySlug/:feedTab` — a category's feed; `/:categorySlug` redirects
* to
* its default tab.
* - `/dumps/:selectedDump` — a dump ({@link Dump}); `/edit` — its editor
* ({@link DumpEdit}), signed in.
* - `/login`, `/register` — sign-in and sign-up ({@link UserLogin},
* {@link UserRegister}), signed out.
* - `/reset-password` — a new password from a reset link ({@link
* ResetPassword}).
* - `/users/:username` and `/users/:username/~/:profileTab` — a profile
* ({@link UserPublicProfile}); `/dumps`, `/upvoted`, `/playlists` — its full
* lists ({@link UserDumps}, {@link UserUpvoted}, {@link UserPlaylists}).
* - `/playlists/:playlistId` — a playlist ({@link PlaylistDetail}).
* - `/search` and `/search/~/:searchTab` — search results ({@link Search}).
* - `/notifications` — the user's notifications ({@link Notifications}), signed
* in.
* - anything else — {@link NotFound}.
*
* @module
*/
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { import {
BrowserRouter, BrowserRouter,
@@ -27,6 +53,7 @@ import { ChatFab } from "./components/layout/ChatFab.tsx";
import "./App.css"; import "./App.css";
/** The feed page, loaded with its route; so is every page below. */
const Index = lazy(() => const Index = lazy(() =>
import("./pages/Index.tsx").then((m) => ({ default: m.Index })) import("./pages/Index.tsx").then((m) => ({ default: m.Index }))
); );
@@ -80,22 +107,27 @@ const NotFound = lazy(() =>
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound })) import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
); );
// Default feed tab for the current viewer ("followed" needs a session). /**
* Returns the default feed tab for the current viewer ("followed" needs a
* session).
*/
function useResolvedDefaultTab() { function useResolvedDefaultTab() {
const { user } = useAuth(); const { user } = useAuth();
const [preferredTab] = useDefaultFeedTab(); const [preferredTab] = useDefaultFeedTab();
return preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; return preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab;
} }
// The query string has to survive the hop: the Web Share Target posts to "/", /**
// so an Android share arrives here as `/?share_url=…` and the feed below is the * Redirects `/` to the default feed tab. The query string has to survive the
// only thing that can act on it. * hop: the Web Share Target posts to "/", so an Android share arrives here as
* `/?share_url=…` and the feed below is the only thing that can act on it.
*/
function IndexRedirect() { function IndexRedirect() {
const { search } = useLocation(); const { search } = useLocation();
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />; return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
} }
// Bare `/<slug>` lands on that category's default feed tab. /** Redirects a bare `/<slug>` to that category's default feed tab. */
function CategoryRedirect() { function CategoryRedirect() {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
const { search } = useLocation(); const { search } = useLocation();
@@ -107,14 +139,17 @@ function CategoryRedirect() {
); );
} }
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed. /**
// Keying by scope remounts the feed when switching categories so it refetches, * Renders the feed of `/~/:feedTab` (all) and `/:categorySlug/:feedTab` alike.
// while switching tabs within a scope keeps it mounted (client-side re-sort). * Keying by scope remounts the feed when switching categories so it refetches,
* while switching tabs within a scope keeps it mounted (client-side re-sort).
*/
function ScopedIndex() { function ScopedIndex() {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
return <Index key={categorySlug ?? "~"} categorySlug={categorySlug} />; return <Index key={categorySlug ?? "~"} categorySlug={categorySlug} />;
} }
/** Renders the route table, and the floating buttons over every page. */
function AppRoutes() { function AppRoutes() {
return ( return (
<> <>
@@ -181,7 +216,11 @@ function AppRoutes() {
); );
} }
export function App() { /**
* Renders the app: the theme, session, player, socket, follow and category
* providers around the router, the chat, the routes and the global player.
*/
export function App(): React.JSX.Element {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>

View File

@@ -1,18 +1,30 @@
/**
* A dump form's category picker.
*
* @module
*/
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useCategories } from "../../hooks/category/useCategories.ts"; import { useCategories } from "../../hooks/category/useCategories.ts";
/** The props of {@link CategorySelect}. */
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;
}
/** /**
* Multi-select of categories rendered as toggle chips. Renders nothing when no * Renders a multi-select of categories as toggle chips. Renders nothing when
* categories are configured, so dump forms stay unchanged until an admin adds * no categories are configured, so dump forms stay unchanged until an admin
* the first category. * adds the first category.
*/ */
export function CategorySelect( export function CategorySelect(
{ selected, onToggle, disabled }: { { selected, onToggle, disabled }: CategorySelectProps,
selected: Set<string>; ): React.JSX.Element | null {
onToggle: (id: string) => void;
disabled?: boolean;
},
) {
const { categories } = useCategories(); const { categories } = useCategories();
if (categories.length === 0) return null; if (categories.length === 0) return null;

View File

@@ -1,3 +1,9 @@
/**
* The feed's category switcher.
*
* @module
*/
import { import {
type CSSProperties, type CSSProperties,
useEffect, useEffect,
@@ -12,15 +18,16 @@ import { useCategories } from "../../hooks/category/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
/** /**
* Header dropdown that switches the feed scope between "All" and a category, * Renders the header dropdown that switches the feed scope between "All" and a
* preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as * category, preserving the active feed tab (e.g. /music/new → /cinema/new).
* the leading item of the feed-sort tab row so it reads as part of the bar. * 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 * 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 * 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. * sentinel value for the all-categories feed. Hidden when no categories exist.
*/ */
export function CategorySwitcher() { export function CategorySwitcher(): React.JSX.Element | null {
const { categories } = useCategories(); const { categories } = useCategories();
const navigate = useNavigate(); const navigate = useNavigate();
const { categorySlug, feedTab } = useParams(); const { categorySlug, feedTab } = useParams();

View File

@@ -1,16 +1,32 @@
/**
* The inline editor of a chat message.
*
* @module
*/
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts"; import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
/** The props of {@link ChatEditBox}. */
export interface ChatEditBoxProps { export interface ChatEditBoxProps {
/** The message's current text. */
initial: string; initial: string;
/** Called with the trimmed new text. */
onSave: (body: string) => void; onSave: (body: string) => void;
/** Called to leave without saving. */
onCancel: () => void; onCancel: () => void;
} }
export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) { /**
* 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.
*/
export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps,
): React.JSX.Element {
const [draft, setDraft] = useState(initial); const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null); const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* A message in the chat.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -14,25 +20,42 @@ import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "../../utils/chat/message.ts"; import { messageTextClass } from "../../utils/chat/message.ts";
import { clockTime } from "../../utils/chat/time.ts"; import { clockTime } from "../../utils/chat/time.ts";
/** The props of {@link ChatMessageItem}. */
export interface ChatMessageItemProps { export interface ChatMessageItemProps {
/** The message. */
message: ChatMessage; message: ChatMessage;
/** Continues the previous message's run: no avatar or author line. */ /** Continues the previous message's run: no avatar or author line. */
grouped: boolean; grouped: boolean;
/** Whether it @mentions the user, which highlights it. */
mentionsMe: boolean; mentionsMe: boolean;
/** The replied-to message has been deleted since this one loaded. */ /** The replied-to message has been deleted since this one loaded. */
replyDeleted: boolean; replyDeleted: boolean;
/** Whether the user may edit it. */
canEdit: boolean; canEdit: boolean;
/** Whether the user may delete it. */
canDelete: boolean; canDelete: boolean;
/** Whether it is flashed, having just been jumped to. */
flashed: boolean; flashed: boolean;
/** Whether it is being edited. */
editing: boolean; editing: boolean;
/** Starts editing the message `id`. */
onStartEdit: (id: string) => void; onStartEdit: (id: string) => void;
/** Ends the editing. */
onEndEdit: () => void; onEndEdit: () => void;
/** Saves `body` as the text of message `id`. */
onEdit: (id: string, body: string) => void; onEdit: (id: string, body: string) => void;
/** Deletes message `id`. */
onDelete: (id: string) => void; onDelete: (id: string) => void;
/** Replies to `message`. */
onReply: (message: ChatMessage) => void; onReply: (message: ChatMessage) => void;
/** Scrolls to message `id`, for the reply reference. */
onJumpTo: (id: string) => void; 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.
*/
export function ChatMessageItem( export function ChatMessageItem(
{ {
message, message,
@@ -50,7 +73,7 @@ export function ChatMessageItem(
onReply, onReply,
onJumpTo, onJumpTo,
}: ChatMessageItemProps, }: ChatMessageItemProps,
) { ): React.JSX.Element {
const bodyRef = useHeightTransition<HTMLDivElement>(editing); const bodyRef = useHeightTransition<HTMLDivElement>(editing);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* The live chat, in a modal.
*
* @module
*/
import { import {
type KeyboardEvent, type KeyboardEvent,
useCallback, useCallback,
@@ -31,28 +37,44 @@ import { COUNT_FROM, sendable, snippet } from "../../utils/chat/message.ts";
import { dayLabel, isSameDay } from "../../utils/chat/time.ts"; import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
// Matches the server's default page size; a full page means there may be more. /**
* Matches the server's default page size; a full page means there may be more.
*/
const PAGE_SIZE = 50; 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. * 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.
*/
const RESYNC_PAGE = 100; const RESYNC_PAGE = 100;
/** How many pages a resync walks at most. */
const RESYNC_MAX_PAGES = 5; const RESYNC_MAX_PAGES = 5;
// Consecutive messages from one author within this window share a header. /** Consecutive messages from one author within this window share a header. */
const GROUP_WINDOW = 5 * 60_000; const GROUP_WINDOW = 5 * 60_000;
// Relative timestamps ("2 minutes ago") are refreshed this often. /** Relative timestamps ("2 minutes ago") are refreshed this often. */
const TICK = 30_000; const TICK = 30_000;
// How long a failed edit/delete notice stays up. /** How long a failed edit/delete notice stays up. */
const ERROR_TTL = 6_000; const ERROR_TTL = 6_000;
// How close to the bottom still counts as "at the bottom" (px). /** How close to the bottom still counts as "at the bottom" (px). */
const BOTTOM_SLACK = 80; const BOTTOM_SLACK = 80;
// How long the composer stays "typing" after the last keystroke, and how often /**
// we refresh that signal while typing continuously. The refresh interval must * How long the composer stays "typing" after the last keystroke. The server
// stay below the server's TYPING_TTL so a steady typist never flickers out. * drops a typer it hasn't heard from in its TYPING_TTL.
*/
const TYPING_STOP_DELAY = 3_500; 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.
*/
const TYPING_REFRESH = 3_000; const TYPING_REFRESH = 3_000;
/** The session's `authFetch`. */
type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>; type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
/**
* Returns the chat messages `query` selects from `GET /api/chat`.
*
* @throws {Error} When the API answers a failure.
*/
async function fetchChat( async function fetchChat(
authFetch: AuthFetch, authFetch: AuthFetch,
query: string, query: string,
@@ -63,12 +85,19 @@ async function fetchChat(
return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage); return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage);
} }
// Stream order, matching the server's: by time, then id to break ties. /**
* Orders two messages as the stream does, matching the server's: by time, then
* id to break ties.
*/
function compareMessages(a: ChatMessage, b: ChatMessage): number { function compareMessages(a: ChatMessage, b: ChatMessage): number {
return a.createdAt.getTime() - b.createdAt.getTime() || return a.createdAt.getTime() - b.createdAt.getTime() ||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0); (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.
*/
type Row = type Row =
| { kind: "sep"; key: string; label: string } | { kind: "sep"; key: string; label: string }
| { kind: "new"; key: string } | { kind: "new"; key: string }
@@ -80,7 +109,23 @@ type Row =
grouped: boolean; grouped: boolean;
}; };
export function ChatModal({ onClose }: { onClose: () => void }) { /** The props of {@link ChatModal}. */
export interface ChatModalProps {
/** Called to close the chat. */
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.
*/
export function ChatModal(
{ onClose }: ChatModalProps,
): React.JSX.Element {
const { wsStatus, connectionEpoch } = useWS(); const { wsStatus, connectionEpoch } = useWS();
const { const {
chatMessages, chatMessages,

View File

@@ -1,3 +1,9 @@
/**
* A chat message on its way.
*
* @module
*/
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
@@ -6,22 +12,32 @@ import type { PublicUser } from "../../../shared/user.ts";
import { ReplyRef } from "./ReplyRef.tsx"; import { ReplyRef } from "./ReplyRef.tsx";
import { messageTextClass } from "../../utils/chat/message.ts"; import { messageTextClass } from "../../utils/chat/message.ts";
/** The props of {@link PendingItem}. */
export interface PendingItemProps { export interface PendingItemProps {
/** The message. */
pending: PendingChatMessage; pending: PendingChatMessage;
/** Continues the previous message's run: no avatar or author line. */
grouped: boolean; grouped: boolean;
/** The sender: the user. */
user: PublicUser; user: PublicUser;
/** Resends message `nonce`. */
onRetry: (nonce: string) => void; onRetry: (nonce: string) => void;
/** Drops message `nonce`. */
onDiscard: (nonce: string) => void; onDiscard: (nonce: string) => void;
/** Takes the message back into the composer. */
onRestore: (pending: PendingChatMessage) => void; onRestore: (pending: PendingChatMessage) => void;
/** Scrolls to message `id`, for the reply reference. */
onJumpTo: (id: string) => void; onJumpTo: (id: string) => void;
} }
// 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 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.
*/
export function PendingItem( export function PendingItem(
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }: { pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
PendingItemProps, PendingItemProps,
) { ): React.JSX.Element {
const failed = pending.status === "failed"; const failed = pending.status === "failed";
return ( return (
<div <div

View File

@@ -1,16 +1,31 @@
/**
* A chat message's reference to the message it replies to.
*
* @module
*/
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { snippet } from "../../utils/chat/message.ts"; import { snippet } from "../../utils/chat/message.ts";
/** The props of {@link ReplyRef}. */
export interface ReplyRefProps { export interface ReplyRefProps {
/** The replied-to message's id. */
replyToId: string; replyToId: string;
/** Its author; undefined when it was deleted. */
author?: string; author?: string;
/** Its text. */
body?: string; body?: string;
/** Scrolls to message `id`. */
onJumpTo: (id: string) => void; onJumpTo: (id: string) => void;
} }
// 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 a subtle one-line reference to the replied-to message; clicking jumps
export function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) { * to it. Falls back to a muted note when the target was deleted.
*/
export function ReplyRef(
{ replyToId, author, body, onJumpTo }: ReplyRefProps,
): React.JSX.Element {
return ( return (
<button <button
type="button" type="button"

View File

@@ -1,4 +1,9 @@
import React from "react"; /**
* The comment editor, for posting, replying and editing.
*
* @module
*/
import { Controller, FormProvider } from "react-hook-form"; import { Controller, FormProvider } from "react-hook-form";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
@@ -7,26 +12,36 @@ import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx"; import { SubmitButton } from "../form/SubmitButton.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { useApiForm } from "../../hooks/form/useApiForm.ts";
/** The props of {@link CommentForm}. */
export interface CommentFormProps { export interface CommentFormProps {
/** Async submit; throw to surface a root error, resolve to clear/close. */ /** Async submit; throw to surface a root error, resolve to clear/close. */
onSubmit: (body: string) => Promise<void>; onSubmit: (body: string) => Promise<void>;
/** The submit button's label. */
submitLabel: React.ReactNode; submitLabel: React.ReactNode;
/** The submit button's label while submitting. */
pendingLabel: React.ReactNode; pendingLabel: React.ReactNode;
/** The title of the error shown when the submit fails. */
errorTitle: string; errorTitle: string;
/** The editor's placeholder. */
placeholder?: string; placeholder?: string;
/** The initial text; empty by default. */
defaultValue?: string; defaultValue?: string;
/** Called by the Cancel button; no Cancel button without it. */
onCancel?: () => void; onCancel?: () => void;
/** Top-level form: only show Cancel once there's text to discard. */ /** Top-level form: only show Cancel once there's text to discard. */
cancelWhenEmpty?: boolean; cancelWhenEmpty?: boolean;
/** Forwarded to the editor so callers can focus it. */
editorRef?: React.Ref<TextEditorHandle>; editorRef?: React.Ref<TextEditorHandle>;
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */ /** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
layout?: "inline" | "top"; layout?: "inline" | "top";
/** Shown before the editor in the `top` layout (the user's avatar). */
leading?: React.ReactNode; leading?: React.ReactNode;
/** An extra class for the form. */
className?: string; className?: string;
} }
/** /**
* Single-field comment editor (post / reply / edit). Wraps the shared * Renders a single-field comment editor (post / reply / edit). Wraps the shared
* react-hook-form lifecycle so the three call sites stay identical. * react-hook-form lifecycle so the three call sites stay identical.
*/ */
export function CommentForm({ export function CommentForm({
@@ -42,7 +57,7 @@ export function CommentForm({
layout = "inline", layout = "inline",
leading, leading,
className, className,
}: CommentFormProps) { }: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({ const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue }, defaultValues: { body: defaultValue },
}); });

View File

@@ -1,4 +1,10 @@
import React, { useRef, useState } from "react"; /**
* A comment in the thread, with its replies.
*
* @module
*/
import { useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -25,18 +31,32 @@ import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
interface CommentNodeProps { /** The props of {@link CommentNode}. */
export interface CommentNodeProps {
/** The comment. */
comment: Comment; comment: Comment;
/** Every comment's replies, by parent id. */
tree: Map<string, Comment[]>; tree: Map<string, Comment[]>;
/** How deep it is nested. */
depth: number; depth: number;
/** The id of the dump it is under. */
dumpId: string; dumpId: string;
/** The signed-in user, null when signed out. */
currentUser: SelfUser | null; currentUser: SelfUser | null;
/** The session token. */
token: string | null; token: string | null;
/** Called with a reply posted. */
onCommentCreated: (comment: Comment) => void; onCommentCreated: (comment: Comment) => void;
/** Called with the id of a comment deleted. */
onCommentDeleted: (commentId: string) => void; onCommentDeleted: (commentId: string) => void;
/** Called with a comment edited. */
onCommentUpdated: (comment: Comment) => void; 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.
*/
export function CommentNode({ export function CommentNode({
comment, comment,
tree, tree,
@@ -47,7 +67,7 @@ export function CommentNode({
onCommentCreated, onCommentCreated,
onCommentDeleted, onCommentDeleted,
onCommentUpdated, onCommentUpdated,
}: CommentNodeProps) { }: CommentNodeProps): React.JSX.Element {
const [replyOpen, setReplyOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* A dump's comment thread.
*
* @module
*/
import { useMemo } from "react"; import { useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
@@ -12,16 +18,28 @@ import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
import { CommentNode } from "./CommentNode.tsx"; import { CommentNode } from "./CommentNode.tsx";
interface CommentThreadProps { /** The props of {@link CommentThread}. */
export interface CommentThreadProps {
/** The dump's id. */
dumpId: string; dumpId: string;
/** Every comment on the dump. */
comments: Comment[]; comments: Comment[];
/** The signed-in user, null when signed out. */
currentUser: SelfUser | null; currentUser: SelfUser | null;
/** The session token. */
token: string | null; token: string | null;
/** Called with a comment posted. */
onCommentCreated: (comment: Comment) => void; onCommentCreated: (comment: Comment) => void;
/** Called with the id of a comment deleted. */
onCommentDeleted: (commentId: string) => void; onCommentDeleted: (commentId: string) => void;
/** Called with a comment edited. */
onCommentUpdated: (comment: Comment) => void; onCommentUpdated: (comment: Comment) => void;
} }
/**
* Returns the replies of each comment of `comments`, by parent id (`root` for
* the top level).
*/
function buildTree(comments: Comment[]): Map<string, Comment[]> { function buildTree(comments: Comment[]): Map<string, Comment[]> {
const map = new Map<string, Comment[]>(); const map = new Map<string, Comment[]>();
for (const c of comments) { for (const c of comments) {
@@ -32,6 +50,10 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
return map; return map;
} }
/**
* Renders the form that posts a comment (for a signed-in user), and the thread
* under it.
*/
export function CommentThread({ export function CommentThread({
dumpId, dumpId,
comments, comments,
@@ -40,7 +62,7 @@ export function CommentThread({
onCommentCreated, onCommentCreated,
onCommentDeleted, onCommentDeleted,
onCommentUpdated, onCommentUpdated,
}: CommentThreadProps) { }: CommentThreadProps): React.JSX.Element {
const tree = useMemo(() => buildTree(comments), [comments]); const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? []; const roots = tree.get("root") ?? [];
const { authFetch } = useAuth(); const { authFetch } = useAuth();

View File

@@ -1,3 +1,9 @@
/**
* A dump, as a card in the hot and new feeds.
*
* @module
*/
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
@@ -19,20 +25,36 @@ import { VoteButton } from "../social/VoteButton.tsx";
import { Markdown } from "../editor/Markdown.tsx"; import { Markdown } from "../editor/Markdown.tsx";
import { Tooltip } from "../shared/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
interface DumpCardProps { /** The props of {@link DumpCard}. */
export interface DumpCardProps {
/** The dump. */
dump: Dump; dump: Dump;
/** Its live vote count. */
voteCount: number; voteCount: number;
/** Whether the user upvoted it. */
voted: boolean; voted: boolean;
/** Whether the user may vote (signed in). */
canVote: boolean; canVote: boolean;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void; castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void;
/** An extra class. */
className?: string; className?: string;
/** Whether the viewer posted it: no unread dot. */
isOwner?: boolean; isOwner?: boolean;
} }
// 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: its preview (playable where it can be), title,
export const DumpCard = memo(function DumpCard( * 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.
*/
export const DumpCard: React.MemoExoticComponent<
(props: DumpCardProps) => React.JSX.Element
> = memo(function DumpCard(
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }: { dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
DumpCardProps, DumpCardProps,
) { ) {

View File

@@ -1,3 +1,10 @@
/**
* The preview of an uploaded file: an image, a player, a PDF or a download
* link.
*
* @module
*/
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import { formatBytes } from "../../utils/shared/format.ts"; import { formatBytes } from "../../utils/shared/format.ts";
@@ -25,14 +32,18 @@ import {
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
interface FilePreviewProps { /** The props of {@link FilePreview}. */
export interface FilePreviewProps {
/** The file dump. */
dump: Dump; dump: Dump;
/** Whether to render the small thumbnail of a card. */
compact?: boolean; compact?: boolean;
/** Whether audio and video play in the global player rather than inline. */
global?: boolean; global?: boolean;
} }
/** /**
* Build the global-player item for an uploaded file, so every entry point * 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 * (compact cards, the detail page, the waveform) puts the same title, artwork
* and subtitle in the player header. * and subtitle in the player header.
* *
@@ -58,8 +69,11 @@ function filePlayerItem(
}; };
} }
// Waveform preview for the dump detail page — routes to global player, /**
// reflects live play state and position from PlayerContext/PlayerTimeContext. * Renders the waveform preview for the dump detail page — routes to the global
* player, reflects live play state and position from PlayerContext and
* PlayerTimeContext.
*/
function AudioFilePreview( function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) { ) {
@@ -160,6 +174,7 @@ function AudioFilePreview(
); );
} }
/** Renders a video's still, or `fallback` when it fails to load. */
function VideoThumb({ src, fallback }: { src: string; fallback: string }) { function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (failed) { if (failed) {
@@ -177,6 +192,7 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
); );
} }
/** Returns the icon for a file of type `mime`. */
function mimeIcon(mime: string): string { function mimeIcon(mime: string): string {
if (mime.startsWith("video/")) return "🎬"; if (mime.startsWith("video/")) return "🎬";
if (mime.startsWith("audio/")) return "🎵"; if (mime.startsWith("audio/")) return "🎵";
@@ -185,9 +201,14 @@ function mimeIcon(mime: string): string {
return "📁"; return "📁";
} }
/**
* 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.
*/
export function FilePreview( export function FilePreview(
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps, { dump, compact = false, global: useGlobal = false }: FilePreviewProps,
) { ): React.JSX.Element {
const { current, playing, play, togglePlay } = useContext(PlayerContext); const { current, playing, play, togglePlay } = useContext(PlayerContext);
const { mediaToken: token } = useAuth(); const { mediaToken: token } = useAuth();
const fileUrl = dumpFileUrl(dump, token); const fileUrl = dumpFileUrl(dump, token);

View File

@@ -1,3 +1,9 @@
/**
* A dump, as a tile of the journal's mosaic.
*
* @module
*/
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
@@ -25,21 +31,34 @@ import {
usePlayRichContent, usePlayRichContent,
} from "../../hooks/player/usePlayRichContent.ts"; } from "../../hooks/player/usePlayRichContent.ts";
export type { JournalShape }; /** The props of {@link JournalCard}. */
export interface JournalCardProps {
interface JournalCardProps { /** The dump. */
dump: Dump; dump: Dump;
/** The tile's footprint in the mosaic. */
shape: JournalShape; shape: JournalShape;
/** Its live vote count. */
voteCount: number; voteCount: number;
/** Whether the user upvoted it. */
voted: boolean; voted: boolean;
/** Whether the user may vote (signed in). */
canVote: boolean; canVote: boolean;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void; castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void;
/** Whether the viewer posted it: no unread dot. */
isOwner?: boolean; isOwner?: boolean;
} }
// Memoized, like DumpCard: live events re-render the feed, not every card. /**
export const JournalCard = memo(function JournalCard( * 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.
*/
export const JournalCard: React.MemoExoticComponent<
(props: JournalCardProps) => React.JSX.Element
> = memo(function JournalCard(
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }: { dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
JournalCardProps, JournalCardProps,
) { ) {

View File

@@ -1,3 +1,9 @@
/**
* A link's preview card.
*
* @module
*/
import { useContext } from "react"; import { useContext } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import type { RichContent } from "../../../shared/dump.ts"; import type { RichContent } from "../../../shared/dump.ts";
@@ -8,8 +14,11 @@ import {
} from "../../hooks/player/usePlayRichContent.ts"; } from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx"; import { Thumbnail } from "../shared/Thumbnail.tsx";
interface RichContentCardProps { /** The props of {@link RichContentCard}. */
export interface RichContentCardProps {
/** The link's preview. */
richContent: RichContent; richContent: RichContent;
/** Whether to render the small thumbnail of a card. */
compact?: boolean; compact?: boolean;
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */ /** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
thumbnailOverrideUrl?: string; thumbnailOverrideUrl?: string;
@@ -17,10 +26,15 @@ interface RichContentCardProps {
dumpHref?: string; 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.
*/
export function RichContentCard( export function RichContentCard(
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }: { richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
RichContentCardProps, RichContentCardProps,
) { ): React.JSX.Element {
const { current, playing, togglePlay } = useContext(PlayerContext); const { current, playing, togglePlay } = useContext(PlayerContext);
const { playRichContent, pending } = usePlayRichContent(); const { playRichContent, pending } = usePlayRichContent();

View File

@@ -1,3 +1,9 @@
/**
* The dump creation modal.
*
* @module
*/
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -54,11 +60,13 @@ import { TitleField } from "./TitleField.tsx";
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */ /** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
const NO_DUPLICATES: DumpUrlMatch[] = []; const NO_DUPLICATES: DumpUrlMatch[] = [];
/** The link's preview, as fetched for the form. */
type UrlPreview = type UrlPreview =
| { status: "idle" } | { status: "idle" }
| { status: "loading" } | { status: "loading" }
| { status: "done"; reached: boolean; richContent: RichContent | null }; | { status: "done"; reached: boolean; richContent: RichContent | null };
/** The form's fields. */
interface CreateValues { interface CreateValues {
url: string; url: string;
file: File | null; file: File | null;
@@ -68,7 +76,7 @@ interface CreateValues {
} }
/** /**
* Whether the pointer is coarse — a touch device, where autofocusing the URL * 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 * input raises a keyboard that covers most of the modal before the user has
* seen it. * seen it.
*/ */
@@ -76,14 +84,27 @@ function prefersNoAutoFocus(): boolean {
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false; return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
} }
interface DumpCreateModalProps { /** The props of {@link DumpCreateModal}. */
export interface DumpCreateModalProps {
/** Called to close it. */
onClose: () => void; onClose: () => void;
/**
* A link to start from (the Android share sheet); it replaces any saved
* draft.
*/
initialUrl?: string; 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.
*/
export function DumpCreateModal( export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps, { onClose, initialUrl = "" }: DumpCreateModalProps,
) { ): React.JSX.Element {
const { authFetch, authUpload } = useAuth(); const { authFetch, authUpload } = useAuth();
const { injectDump } = useWS(); const { injectDump } = useWS();

View File

@@ -1,16 +1,30 @@
/**
* The create form's "already dumped" hint.
*
* @module
*/
import { Link } from "react-router"; import { Link } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { DumpUrlMatch } from "../../../../shared/dump.ts"; import type { DumpUrlMatch } from "../../../../shared/dump.ts";
import { dumpUrl } from "../../../utils/shared/urls.ts"; import { dumpUrl } from "../../../utils/shared/urls.ts";
import { relativeTime } from "../../../utils/shared/relativeTime.ts"; import { relativeTime } from "../../../utils/shared/relativeTime.ts";
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ /** The props of {@link DuplicateNotice}. */
export interface DuplicateNoticeProps {
/** The dumps already pointing at the link, most recent first. */
matches: DumpUrlMatch[];
/** Called when a link to one of them is followed. */
onNavigate: () => void;
}
/**
* Renders the "Already dumped" hint — never blocking, since reposts are
* sometimes on purpose.
*/
export function DuplicateNotice( export function DuplicateNotice(
{ matches, onNavigate }: { { matches, onNavigate }: DuplicateNoticeProps,
matches: DumpUrlMatch[]; ): React.JSX.Element {
onNavigate: () => void;
},
) {
const [first, ...rest] = matches; const [first, ...rest] = matches;
return ( return (

View File

@@ -1,8 +1,22 @@
/**
* The preview of a picked file, before it is uploaded.
*
* @module
*/
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MediaPlayer } from "../../player/MediaPlayer.tsx"; import { MediaPlayer } from "../../player/MediaPlayer.tsx";
/** What a picked file will look like, before it's uploaded. */ /** The props of {@link LocalFilePreview}. */
export function LocalFilePreview({ file }: { file: File }) { export interface LocalFilePreviewProps {
/** The file picked. */
file: File;
}
/** Renders what a picked file will look like, before it's uploaded. */
export function LocalFilePreview(
{ file }: LocalFilePreviewProps,
): React.JSX.Element | null {
const [src, setSrc] = useState<string | null>(null); const [src, setSrc] = useState<string | null>(null);
useEffect(() => { useEffect(() => {

View File

@@ -1,21 +1,39 @@
/**
* The create modal's steps, and where the user is in them.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
/** /**
* Posting a dump walks three panels. The split is deliberate: categories and * A panel of the create modal. Posting a dump walks three panels. The split is
* visibility used to sit behind a disclosure, where they read as optional and * deliberate: categories and visibility used to sit behind a disclosure, where
* went untouched — they are neither, so "why & where" is a stop on the way to * they read as optional and went untouched — they are neither, so "why & where"
* posting rather than something to go looking for. * is a stop on the way to posting rather than something to go looking for.
*/ */
export type Step = "what" | "details" | "playlist"; export type Step = "what" | "details" | "playlist";
/** The panels, in order, with their labels. */
const STEPS: { id: Step; label: () => string }[] = [ const STEPS: { id: Step; label: () => string }[] = [
{ id: "what", label: () => t`Link or file` }, { id: "what", label: () => t`Link or file` },
{ id: "details", label: () => t`Why & where` }, { id: "details", label: () => t`Why & where` },
{ id: "playlist", label: () => t`Playlists` }, { id: "playlist", label: () => t`Playlists` },
]; ];
/** Where you are in the three panels — and, past the first, how far you got. */ /** The props of {@link StepIndicator}. */
export function StepIndicator({ current }: { current: Step }) { export interface StepIndicatorProps {
/** The panel shown. */
current: Step;
}
/**
* Renders where you are in the three panels — and, past the first, how far you
* got.
*/
export function StepIndicator(
{ current }: StepIndicatorProps,
): React.JSX.Element {
const currentIndex = STEPS.findIndex((s) => s.id === current); const currentIndex = STEPS.findIndex((s) => s.id === current);
return ( return (

View File

@@ -1,14 +1,34 @@
/**
* The create form's title row.
*
* @module
*/
import { useRef } from "react"; import { useRef } from "react";
import type { UseFormRegisterReturn } from "react-hook-form"; import type { UseFormRegisterReturn } from "react-hook-form";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { VALIDATION } from "../../../../shared/validation.ts"; import { VALIDATION } from "../../../../shared/validation.ts";
/** The props of {@link TitleField}. */
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. */
onReset?: () => void;
/** Whether the row is disabled. */
disabled: boolean;
}
/** /**
* Title row shared by both kinds of dump: the suggested title (scraped from the * Renders the title row shared by both kinds of dump: the suggested title
* page, or derived from the filename) sits there read-only until the ✎ is used, * (scraped from the page, or derived from the filename) sits there read-only
* which is enough to stop stray typing while keeping a correction one click * until the ✎ is used, which is enough to stop stray typing while keeping a
* away. Once edited, a reset offers the suggestion back. * correction one click away. Once edited, a reset offers the suggestion back.
*/ */
export function TitleField({ export function TitleField({
registration, registration,
@@ -16,13 +36,7 @@ export function TitleField({
onEdit, onEdit,
onReset, onReset,
disabled, disabled,
}: { }: TitleFieldProps): React.JSX.Element {
registration: UseFormRegisterReturn<"title">;
editing: boolean;
onEdit: () => void;
onReset?: () => void;
disabled: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const { ref, ...titleReg } = registration; const { ref, ...titleReg } = registration;

View File

@@ -1,3 +1,9 @@
/**
* The editor's floating emoji picker.
*
* @module
*/
import { import {
type CSSProperties, type CSSProperties,
type KeyboardEvent, type KeyboardEvent,
@@ -27,22 +33,35 @@ function SkinTonePersist() {
return null; return null;
} }
interface EmojiPickerPopupProps { /** The props of {@link EmojiPickerPopup}. */
export interface EmojiPickerPopupProps {
/** The ref of the floating panel, for outside-click checks. */
floatRef: Ref<HTMLDivElement>; floatRef: Ref<HTMLDivElement>;
/** The ref of the picker's search field, to focus it. */
searchRef: Ref<HTMLInputElement>; searchRef: Ref<HTMLInputElement>;
/** The ref of the picker's scrolling grid. */
viewportRef: Ref<HTMLDivElement>; viewportRef: Ref<HTMLDivElement>;
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
style: CSSProperties; style: CSSProperties;
/** Inline, the query is typed in the textarea and the search only shows it; /** Inline, the query is typed in the textarea and the search only shows it;
* browsing, the search field takes it. */ * browsing, the search field takes it. */
inline: boolean; inline: boolean;
/** What the picker searches for. */
query: string; query: string;
/** Called as the search field is typed in. */
onQueryChange: (query: string) => void; onQueryChange: (query: string) => void;
/** Called with the emoji picked. */
onSelect: (emoji: string) => void; onSelect: (emoji: string) => void;
/** Called to close the picker. */
onClose: () => void; onClose: () => void;
/** The editor's key handling while focus is in the picker. */
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void; onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
} }
/** The floating emoji picker the editor opens from its button or a `:query`. */ /**
* Renders the floating emoji picker the editor opens from its button or a
* `:query`, in the current locale, remembering the skin tone.
*/
export function EmojiPickerPopup( export function EmojiPickerPopup(
{ {
floatRef, floatRef,
@@ -56,7 +75,7 @@ export function EmojiPickerPopup(
onClose, onClose,
onKeyDownCapture, onKeyDownCapture,
}: EmojiPickerPopupProps, }: EmojiPickerPopupProps,
) { ): React.JSX.Element {
const { i18n } = useLingui(); const { i18n } = useLingui();
return ( return (
<div <div

View File

@@ -1,18 +1,33 @@
/**
* Markdown, rendered: GFM, @mentions as profile links, line breaks kept, and
* links into the app kept in the app.
*
* @module
*/
import { memo } from "react"; import { memo } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
interface MarkdownProps { /** The props of {@link Markdown}. */
export interface MarkdownProps {
/** The Markdown source. */
children: string; children: string;
/** An extra class. */
className?: string; className?: string;
/** Whether it is a line-clamped teaser inside a card: images are dropped. */
inline?: boolean; inline?: boolean;
} }
// The mention rule the server notifies by (see utils/chatText.ts): an `@` not /**
// preceded by a word character, so an email address isn't a mention. * 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.
*/
const MENTION_RE = /(?<!\w)@(\w+)/g; const MENTION_RE = /(?<!\w)@(\w+)/g;
/** A node of the Markdown syntax tree, the part the plugins read. */
interface MdNode { interface MdNode {
type: string; type: string;
value?: string; value?: string;
@@ -59,7 +74,7 @@ function remarkMentions() {
} }
/** /**
* A single newline is a line break, as it is in chat everywhere: markdown's * 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 * 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 * Shift+Enter came out as one. (Code keeps its newlines; it has no text
* nodes.) * nodes.)
@@ -82,10 +97,13 @@ function remarkBreaks() {
return (tree: MdNode) => walk(tree); return (tree: MdNode) => walk(tree);
} }
/** The remark plugins, in order. */
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks]; const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
/** The path of a link into this app — relative, or absolute to this origin — /**
* or null for anywhere else. */ * 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).
*/
function internalPath(href: string | undefined): string | null { function internalPath(href: string | undefined): string | null {
if (!href) return null; if (!href) return null;
if (href.startsWith("/") && !href.startsWith("//")) { if (href.startsWith("/") && !href.startsWith("//")) {
@@ -102,9 +120,14 @@ function internalPath(href: string | undefined): string | null {
} }
} }
/** react-markdown's element overrides. */
type Components = React.ComponentProps<typeof ReactMarkdown>["components"]; type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
// Static components objects — defined once at module scope to avoid recreation on every render /**
* 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.
*/
const MARKDOWN_COMPONENTS: Components = { const MARKDOWN_COMPONENTS: Components = {
// Mentions and links to other pages here stay in the app; the rest open // Mentions and links to other pages here stay in the app; the rest open
// in a new tab. // in a new tab.
@@ -128,17 +151,24 @@ const MARKDOWN_COMPONENTS: Components = {
), ),
}; };
// Inline renderings are line-clamped teasers inside cards, where an embedded /**
// image blows the layout apart. Drop images there — the full description is * The element overrides of an inline rendering. Inline renderings are
// one click away on the detail page. * 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.
*/
const INLINE_MARKDOWN_COMPONENTS: Components = { const INLINE_MARKDOWN_COMPONENTS: Components = {
...MARKDOWN_COMPONENTS, ...MARKDOWN_COMPONENTS,
img: () => null, img: () => null,
}; };
// Memoized: parsing runs the whole remark pipeline, and the text rarely /**
// changes when the component around it re-renders. * Renders Markdown. Memoized: parsing runs the whole remark pipeline, and the
export const Markdown = memo(function Markdown( * text rarely changes when the component around it re-renders.
*/
export const Markdown: React.MemoExoticComponent<
(props: MarkdownProps) => React.JSX.Element
> = memo(function Markdown(
{ children, className, inline = false }: MarkdownProps, { children, className, inline = false }: MarkdownProps,
) { ) {
return ( return (

View File

@@ -1,20 +1,34 @@
/**
* The editor's @mention suggestions.
*
* @module
*/
import { type CSSProperties, useLayoutEffect, useRef } from "react"; import { type CSSProperties, useLayoutEffect, useRef } from "react";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts"; import type { UserSuggestion } from "../../../shared/user.ts";
interface MentionDropdownProps { /** The props of {@link MentionDropdown}. */
export interface MentionDropdownProps {
/** For the textarea's `aria-controls` / `aria-activedescendant`. */ /** For the textarea's `aria-controls` / `aria-activedescendant`. */
id: string; id: string;
results: UserResult[]; /** The matching users. */
results: UserSuggestion[];
/** The highlighted match. */
selectedIndex: number; selectedIndex: number;
/** Called with the name picked. */
onSelect: (username: string) => void; onSelect: (username: string) => void;
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */ /** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
style?: CSSProperties; style?: CSSProperties;
} }
/**
* Renders the users matching the `@query` as a listbox, keeping the highlighted
* one in view; nothing when none match.
*/
export function MentionDropdown( export function MentionDropdown(
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps, { id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
) { ): React.JSX.Element | null {
const listRef = useRef<HTMLUListElement>(null); const listRef = useRef<HTMLUListElement>(null);
// Arrowing past the visible rows keeps the selection in view. // Arrowing past the visible rows keeps the selection in view.

View File

@@ -1,3 +1,10 @@
/**
* The site's text editor: a Markdown textarea with @mention and emoji
* autocompletes, image upload by paste or drop, and a character counter.
*
* @module
*/
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -20,20 +27,30 @@ import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
/** What a ref to {@link TextEditor} can do. */
export interface TextEditorHandle { export interface TextEditorHandle {
/** `atEnd` puts the caret after the last character (and scrolls the text /** `atEnd` puts the caret after the last character (and scrolls the text
* there); `preventScroll` leaves the page and its scrollers where they are. */ * there); `preventScroll` leaves the page and its scrollers where they are. */
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void; focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
} }
interface TextEditorProps { /** The props of {@link TextEditor}. */
export interface TextEditorProps {
/** The text. */
value: string; value: string;
/** Called with the new text. */
onChange: (value: string) => void; onChange: (value: string) => void;
/** The textarea's placeholder. */
placeholder?: string; placeholder?: string;
/** Whether the textarea is disabled. */
disabled?: boolean; disabled?: boolean;
/** The textarea's height, in rows. */
rows?: number; rows?: number;
/** The textarea's id. */
id?: string; id?: string;
/** An extra class. */
className?: string; className?: string;
/** Whether the textarea grows with its text. */
autoResize?: boolean; autoResize?: boolean;
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */ /** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
onSubmit?: () => void; onSubmit?: () => void;
@@ -44,19 +61,24 @@ interface TextEditorProps {
* while an IME is composing, so an Enter that confirms a candidate isn't * 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; onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
/** The most characters it takes, counted and warned about. */
maxLength?: number; maxLength?: number;
/** Show the character counter only from this length. Defaults to 80% of /** Show the character counter only from this length. Defaults to 80% of
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns * `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
* up well before the warning colour does. */ * up well before the warning colour does. */
countFrom?: number; countFrom?: number;
/** Marks the textarea invalid. */
"aria-invalid"?: boolean; "aria-invalid"?: boolean;
/** The id of what describes it (its error). */
"aria-describedby"?: string; "aria-describedby"?: string;
} }
/** Checks whether `dt` carries files. */
function hasFiles(dt: DataTransfer): boolean { function hasFiles(dt: DataTransfer): boolean {
return Array.from(dt.types).includes("Files"); return Array.from(dt.types).includes("Files");
} }
/** Text to put in once the textarea can take it. */
interface PendingInsert { interface PendingInsert {
start: number; start: number;
end: number; end: number;
@@ -64,7 +86,16 @@ interface PendingInsert {
focus: boolean; focus: boolean;
} }
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>( /**
* 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.
*/
export const TextEditor: React.ForwardRefExoticComponent<
TextEditorProps & React.RefAttributes<TextEditorHandle>
> = forwardRef<TextEditorHandle, TextEditorProps>(
function TextEditor( function TextEditor(
{ {
value, value,

View File

@@ -1,3 +1,9 @@
/**
* The hot and new feeds, and the props every main feed of the index takes.
*
* @module
*/
import { type Ref, useMemo } from "react"; import { type Ref, useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -7,22 +13,44 @@ import type { Dump } from "../../../shared/dump.ts";
import type { SelfUser } from "../../../shared/user.ts"; import type { SelfUser } from "../../../shared/user.ts";
import { hotScore } from "../../utils/feed/hotScore.ts"; import { hotScore } from "../../utils/feed/hotScore.ts";
// What the index hands whichever main feed its tab shows. /** What the index hands whichever main feed its tab shows. */
export interface MainFeedProps { export interface MainFeedProps {
/** The dumps loaded so far. */
dumps: Dump[]; dumps: Dump[];
/** Whether the first page is loading. */
loading: boolean; loading: boolean;
/** Why loading failed, null when it didn't. */
error: string | null; error: string | null;
/** Whether another page follows. */
hasMore: boolean; hasMore: boolean;
/** Whether the next page is loading. */
loadingMore: boolean; loadingMore: boolean;
/**
* The ref of the element that loads the next page when scrolled into view.
*/
sentinelRef: Ref<HTMLDivElement>; sentinelRef: Ref<HTMLDivElement>;
/** Live vote counts, by dump id. */
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
myVotes: Set<string>; myVotes: Set<string>;
/** The signed-in user, null when signed out. */
user: SelfUser | null; user: SelfUser | null;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void; castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void;
} }
// The hot and new tabs: the same list, ordered by hot score or by date. /** The props of {@link DumpFeed}. */
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.
*/
export function DumpFeed( export function DumpFeed(
{ {
order, order,
@@ -37,8 +65,8 @@ export function DumpFeed(
user, user,
castVote, castVote,
removeVote, removeVote,
}: MainFeedProps & { order: "hot" | "new" }, }: DumpFeedProps,
) { ): React.JSX.Element {
const sorted = useMemo( const sorted = useMemo(
() => () =>
[...dumps].sort((a, b) => [...dumps].sort((a, b) =>

View File

@@ -1,3 +1,9 @@
/**
* The index's feed tabs.
*
* @module
*/
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
@@ -11,11 +17,11 @@ import { TabBar } from "../shared/TabBar.tsx";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx"; import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
/** /**
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the * Renders the feed sort tabs (hot/new/journal/followed). Navigation stays
* current scope: `~` for all categories, or the active category slug — so the * within the current scope: `~` for all categories, or the active category slug
* tabs link to `/~/new`, `/music/new`, etc. * — so the tabs link to `/~/new`, `/music/new`, etc.
*/ */
export function FeedTabBar() { export function FeedTabBar(): React.JSX.Element {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { categorySlug, feedTab } = useParams(); const { categorySlug, feedTab } = useParams();

View File

@@ -1,3 +1,9 @@
/**
* The followed feed: dumps from the users and the playlists the user follows.
*
* @module
*/
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../shared/TabBar.tsx"; import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -19,6 +25,7 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { SelfUser } from "../../../shared/user.ts"; import type { SelfUser } from "../../../shared/user.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts";
/** A section's state: loading, failed, or loaded with a page of dumps. */
type FeedState = type FeedState =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -30,8 +37,12 @@ type FeedState =
loadingMore: boolean; loadingMore: boolean;
}; };
/** Page of a followed feed. Throws the server's own message on an error /**
* envelope, so it isn't mistaken for a lost connection. */ * Returns a page of a followed feed from `res`.
*
* @throws {Error} The server's own message on an error envelope, so it isn't
* mistaken for a lost connection.
*/
async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> { async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
const body = await res.json(); const body = await res.json();
if (!body.success) { if (!body.success) {
@@ -40,20 +51,30 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
return body.data as PaginatedData<Wire<Dump>>; return body.data as PaginatedData<Wire<Dump>>;
} }
/** A section of the followed feed. */
type FollowedSection = "users" | "playlists"; type FollowedSection = "users" | "playlists";
interface FollowedFeedProps { /** The props of {@link FollowedFeed}. */
export interface FollowedFeedProps {
/** The category the feed is scoped to; all when undefined. */
categorySlug?: string; categorySlug?: string;
/** Live vote counts, by dump id. */
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
myVotes: Set<string>; myVotes: Set<string>;
/** The signed-in user. */
user: SelfUser; user: SelfUser;
/** Upvotes dump `id`; `shownCount` is the count on screen. */
castVote: (id: string, shownCount?: number) => void; castVote: (id: string, shownCount?: number) => void;
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
removeVote: (id: string, shownCount?: number) => void; removeVote: (id: string, shownCount?: number) => void;
/** Ids of dumps deleted this session, filtered out. */
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
} }
// ── FollowedSubFeed ─────────────────────────────────────────────────────────── // ── FollowedSubFeed ───────────────────────────────────────────────────────────
/** The props of {@link FollowedSubFeed}. */
interface FollowedSubFeedProps { interface FollowedSubFeedProps {
state: FeedState; state: FeedState;
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
@@ -66,6 +87,7 @@ interface FollowedSubFeedProps {
onLoadMore: () => void; onLoadMore: () => void;
} }
/** Renders a section of the followed feed, loading more on demand. */
function FollowedSubFeed({ function FollowedSubFeed({
state, state,
voteCounts, voteCounts,
@@ -131,6 +153,10 @@ function FollowedSubFeed({
// ── FollowedFeed ────────────────────────────────────────────────────────────── // ── FollowedFeed ──────────────────────────────────────────────────────────────
/**
* Renders the followed feed in two sections, from people and from playlists,
* each paginated and scoped to `categorySlug`.
*/
export function FollowedFeed({ export function FollowedFeed({
categorySlug, categorySlug,
voteCounts, voteCounts,
@@ -139,7 +165,7 @@ export function FollowedFeed({
castVote, castVote,
removeVote, removeVote,
deletedDumpIds, deletedDumpIds,
}: FollowedFeedProps) { }: FollowedFeedProps): React.JSX.Element {
const { token } = useAuth(); const { token } = useAuth();
// Scope key + query suffix applied to every followed-feed request. // Scope key + query suffix applied to every followed-feed request.

View File

@@ -1,3 +1,9 @@
/**
* The journal feed: the editorial mosaic.
*
* @module
*/
import { Fragment, useMemo } from "react"; import { Fragment, useMemo } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -6,6 +12,10 @@ import { JournalCard } from "../dump/JournalCard.tsx";
import { composeJournal } from "../../utils/feed/journalLayout.ts"; import { composeJournal } from "../../utils/feed/journalLayout.ts";
import type { MainFeedProps } from "./DumpFeed.tsx"; import type { MainFeedProps } from "./DumpFeed.tsx";
/**
* Renders the dumps as the journal's mosaic, block by block, loading more at
* its end.
*/
export function JournalFeed( export function JournalFeed(
{ {
dumps, dumps,
@@ -20,7 +30,7 @@ export function JournalFeed(
castVote, castVote,
removeVote, removeVote,
}: MainFeedProps, }: MainFeedProps,
) { ): React.JSX.Element {
const blocks = useMemo(() => composeJournal(dumps), [dumps]); const blocks = useMemo(() => composeJournal(dumps), [dumps]);
if (loading) { if (loading) {

View File

@@ -1,3 +1,9 @@
/**
* The row of who's online, in the feed's header.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -5,25 +11,40 @@ import { Avatar } from "../shared/Avatar.tsx";
import { useWSPresence } from "../../hooks/ws/useWS.ts"; import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { useRoomBeside } from "../../hooks/shared/useRoomBeside.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. * 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.
*/
const MAX_SEATS = 6; const MAX_SEATS = 6;
// However tight the row, it keeps an avatar and the counter — past that, the /**
// tabs beside it scroll instead. * However tight the row, it keeps an avatar and the counter — past that, the
* tabs beside it scroll instead.
*/
const MIN_SEATS = 2; const MIN_SEATS = 2;
// Each seat (avatar or counter) is 32px wide and overlaps the one before it /** Each seat (avatar or counter) is 32px wide… */
// by 10px (.index-presence-avatar's negative margin), so adds 22px.
const SEAT = 32; const SEAT = 32;
/**
* …and overlaps the one before it by 10px (.index-presence-avatar's negative
* margin), so adds 22px.
*/
const SEAT_STEP = 22; const SEAT_STEP = 22;
/** The props of {@link PresenceRow}. */
export interface PresenceRowProps {
/** An ancestor selector bounding the row it shares. */
fitWithin?: string;
/** A selector for what comes first in that row: the tabs. */
fitBeside?: string;
}
/** /**
* Who's online, as a row of overlapping avatars. Given `fitWithin` and * 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 * `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. * room left by the tabs allows, the rest folded into the counter.
*/ */
export function PresenceRow( export function PresenceRow(
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string }, { fitWithin, fitBeside }: PresenceRowProps,
) { ): React.JSX.Element | null {
const { onlineUsers } = useWSPresence(); const { onlineUsers } = useWSPresence();
const [el, setEl] = useState<HTMLDivElement | null>(null); const [el, setEl] = useState<HTMLDivElement | null>(null);
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside }); const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });

View File

@@ -1,8 +1,15 @@
/**
* A file drop zone, which is also a file picker.
*
* @module
*/
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { formatBytes } from "../../utils/shared/format.ts"; import { formatBytes } from "../../utils/shared/format.ts";
/** Returns the icon for a file of type `mime`. */
function fileIcon(mime: string): string { function fileIcon(mime: string): string {
if (mime.startsWith("image/")) return "🖼"; if (mime.startsWith("image/")) return "🖼";
if (mime.startsWith("video/")) return "🎬"; if (mime.startsWith("video/")) return "🎬";
@@ -11,21 +18,31 @@ function fileIcon(mime: string): string {
return "📎"; return "📎";
} }
interface FileDropZoneProps { /** The props of {@link FileDropZone}. */
export interface FileDropZoneProps {
/** The chosen file, null without one. */
file: File | null; file: File | null;
/** Called with the file dropped or picked, or null when it is removed. */
onChange: (file: File | null) => void; onChange: (file: File | null) => void;
/** Whether it takes no file. */
disabled?: boolean; disabled?: boolean;
/** The prompt shown while empty; "Drop a file here" by default. */
hint?: string; hint?: string;
/** Whether to state the size limit; true by default. */
showLimit?: boolean; 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.
*/
export function FileDropZone({ export function FileDropZone({
file, file,
onChange, onChange,
disabled, disabled,
hint, hint,
showLimit = true, showLimit = true,
}: FileDropZoneProps) { }: FileDropZoneProps): React.JSX.Element {
const resolvedHint = hint ?? t`Drop a file here`; const resolvedHint = hint ?? t`Drop a file here`;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);

View File

@@ -1,3 +1,9 @@
/**
* A form's file field.
*
* @module
*/
import { import {
Controller, Controller,
type FieldValues, type FieldValues,
@@ -8,18 +14,28 @@ import {
import { FileDropZone } from "./FileDropZone.tsx"; import { FileDropZone } from "./FileDropZone.tsx";
interface FileFieldProps<T extends FieldValues> { /** The props of {@link FileField}. */
export interface FileFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>; name: Path<T>;
/** The field's label. */
label?: string; label?: string;
/** The drop zone's prompt. */
hint?: string; hint?: string;
/** Whether to state the size limit. */
showLimit?: boolean; showLimit?: boolean;
/** Whether the field takes no file. */
disabled?: boolean; disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>; rules?: RegisterOptions<T, Path<T>>;
/** Side effect run after the field value changes (e.g. derive a title). */ /** Side effect run after the field value changes (e.g. derive a title). */
onValueChange?: (file: File | null) => void; onValueChange?: (file: File | null) => void;
} }
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */ /**
* Renders a `Controller`-wrapped {@link FileDropZone} bound to react-hook-form,
* with the field's error under it.
*/
export function FileField<T extends FieldValues>({ export function FileField<T extends FieldValues>({
name, name,
label, label,
@@ -28,7 +44,7 @@ export function FileField<T extends FieldValues>({
disabled, disabled,
rules, rules,
onValueChange, onValueChange,
}: FileFieldProps<T>) { }: FileFieldProps<T>): React.JSX.Element {
const { control, formState: { errors } } = useFormContext<T>(); const { control, formState: { errors } } = useFormContext<T>();
const error = errors[name]; const error = errors[name];

View File

@@ -1,24 +1,35 @@
/**
* A form's row of actions.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
interface FormActionsProps { /** The props of {@link FormActions}. */
export interface FormActionsProps {
/** The submit control(s). */
children: ReactNode; children: ReactNode;
/** Called by the Cancel button; no Cancel button without it. */
onCancel?: () => void; onCancel?: () => void;
/** The Cancel button's label; "Cancel" by default. */
cancelLabel?: ReactNode; cancelLabel?: ReactNode;
/** Whether the Cancel button is disabled. */
disabled?: boolean; disabled?: boolean;
} }
/** /**
* Right-aligned form action row: an optional Cancel button followed by the * Renders a right-aligned form action row: an optional Cancel button followed
* submit control(s). Standardizes the `.form-actions > .form-actions-right` * by the submit control(s). Standardizes the `.form-actions >
* markup duplicated across forms. * .form-actions-right` markup duplicated across forms.
*/ */
export function FormActions({ export function FormActions({
children, children,
onCancel, onCancel,
cancelLabel, cancelLabel,
disabled, disabled,
}: FormActionsProps) { }: FormActionsProps): React.JSX.Element {
return ( return (
<div className="form-actions"> <div className="form-actions">
<div className="form-actions-right"> <div className="form-actions-right">

View File

@@ -1,14 +1,28 @@
/**
* A form's submit error.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useFormState } from "react-hook-form"; import { useFormState } from "react-hook-form";
import { ErrorCard } from "../shared/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
/** The props of {@link FormError}. */
export interface FormErrorProps {
/** The card's title; "Something went wrong" by default. */
title?: string;
}
/** /**
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a * Renders the form's root error (set by `useApiForm`'s `submit` on a
* thrown/failed request) through the shared {@link ErrorCard}. One consistent * thrown/failed request) through the shared {@link ErrorCard}, and nothing
* place for server/submit errors across every form. * without one. One consistent place for server/submit errors across every form.
*/ */
export function FormError({ title }: { title?: string }) { export function FormError(
{ title }: FormErrorProps,
): React.JSX.Element | null {
const { errors } = useFormState(); const { errors } = useFormState();
const message = errors.root?.message; const message = errors.root?.message;
if (!message) return null; if (!message) return null;

View File

@@ -1,3 +1,9 @@
/**
* A form's rich-text field.
*
* @module
*/
import { useId } from "react"; import { useId } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { import {
@@ -10,15 +16,23 @@ import {
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
interface RichTextFieldProps<T extends FieldValues> { /** The props of {@link RichTextField}. */
export interface RichTextFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>; name: Path<T>;
/** The field's label. */
label?: string; label?: string;
/** The editor's placeholder. */
placeholder?: string; placeholder?: string;
/** The editor's height, in rows. */
rows?: number; rows?: number;
/** Counted in the editor and enforced on submit, with a message saying so. */ /** Counted in the editor and enforced on submit, with a message saying so. */
maxLength?: number; maxLength?: number;
/** Whether the editor grows with its text. */
autoResize?: boolean; autoResize?: boolean;
/** Whether the editor is disabled. */
disabled?: boolean; disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>; rules?: RegisterOptions<T, Path<T>>;
/** Forwarded to the underlying TextEditor so callers can focus it. */ /** Forwarded to the underlying TextEditor so callers can focus it. */
editorRef?: React.Ref<TextEditorHandle>; editorRef?: React.Ref<TextEditorHandle>;
@@ -27,9 +41,9 @@ interface RichTextFieldProps<T extends FieldValues> {
} }
/** /**
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji, * Renders a rich-text field: a `Controller`-wrapped {@link TextEditor}
* image upload, char count) bound to react-hook-form via the surrounding * (mentions, emoji, image upload, char count) bound to react-hook-form via the
* `FormProvider`. * surrounding `FormProvider`, with the field's error under it.
*/ */
export function RichTextField<T extends FieldValues>({ export function RichTextField<T extends FieldValues>({
name, name,
@@ -42,7 +56,7 @@ export function RichTextField<T extends FieldValues>({
rules, rules,
editorRef, editorRef,
onSubmit, onSubmit,
}: RichTextFieldProps<T>) { }: RichTextFieldProps<T>): React.JSX.Element {
const id = useId(); const id = useId();
const hintId = useId(); const hintId = useId();
const { control, formState: { errors } } = useFormContext<T>(); const { control, formState: { errors } } = useFormContext<T>();

View File

@@ -1,3 +1,9 @@
/**
* A form's segmented control, and the public/private toggle built on it.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { import {
@@ -7,28 +13,37 @@ import {
useFormContext, useFormContext,
} from "react-hook-form"; } from "react-hook-form";
interface SegmentedOption<V> { /** A choice of a {@link SegmentedField}. */
export interface SegmentedOption<V> {
/** The value it sets. */
value: V; value: V;
/** What it shows. */
label: ReactNode; label: ReactNode;
} }
interface SegmentedFieldProps<T extends FieldValues, V> { /** The props of {@link SegmentedField}. */
export interface SegmentedFieldProps<T extends FieldValues, V> {
/** The field's name in the form. */
name: Path<T>; name: Path<T>;
/** The choices, in order. */
options: SegmentedOption<V>[]; options: SegmentedOption<V>[];
/** The field's label. */
label?: string; label?: string;
/** Whether the control is disabled. */
disabled?: boolean; disabled?: boolean;
} }
/** /**
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form. * Renders a segmented control (the `.visibility-toggle` style) bound to
* Generic over the field value, so it works for booleans, string unions, etc. * react-hook-form. Generic over the field value, so it works for booleans,
* string unions, etc.
*/ */
export function SegmentedField<T extends FieldValues, V>({ export function SegmentedField<T extends FieldValues, V>({
name, name,
options, options,
label, label,
disabled, disabled,
}: SegmentedFieldProps<T, V>) { }: SegmentedFieldProps<T, V>): React.JSX.Element {
const { control } = useFormContext<T>(); const { control } = useFormContext<T>();
return ( return (
@@ -57,20 +72,26 @@ export function SegmentedField<T extends FieldValues, V>({
); );
} }
/** The props of {@link VisibilityToggle}. */
export interface VisibilityToggleProps<T extends FieldValues> {
/** The boolean field's name in the form. */
name: Path<T>;
/** The field's label. */
label?: string;
/** Whether the toggle is disabled. */
disabled?: boolean;
}
/** /**
* Public/Private toggle on a boolean field (default `isPublic`). Forms that * Renders a Public/Private toggle on a boolean field (default `isPublic`).
* persist `isPrivate` should still bind this to an `isPublic` field and invert * Forms that persist `isPrivate` should still bind this to an `isPublic` field
* at submit time. * and invert at submit time.
*/ */
export function VisibilityToggle<T extends FieldValues>({ export function VisibilityToggle<T extends FieldValues>({
name, name,
label, label,
disabled, disabled,
}: { }: VisibilityToggleProps<T>): React.JSX.Element {
name: Path<T>;
label?: string;
disabled?: boolean;
}) {
return ( return (
<SegmentedField<T, boolean> <SegmentedField<T, boolean>
name={name} name={name}

View File

@@ -1,20 +1,30 @@
/**
* A form's submit button.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useFormState } from "react-hook-form"; import { useFormState } from "react-hook-form";
interface SubmitButtonProps { /** The props of {@link SubmitButton}. */
export interface SubmitButtonProps {
/** The button's label. */
children: ReactNode; children: ReactNode;
/** Replaces the label while the form is submitting (e.g. "Saving…"). */ /** Replaces the label while the form is submitting (e.g. "Saving…"). */
pendingLabel?: ReactNode; pendingLabel?: ReactNode;
/** Whether it is disabled, besides while submitting. */
disabled?: boolean; disabled?: boolean;
/** Its class; `btn-primary` by default. */
className?: string; className?: string;
/** For an icon-only button (e.g. "✓"), its accessible name. */ /** For an icon-only button (e.g. "✓"), its accessible name. */
"aria-label"?: string; "aria-label"?: string;
} }
/** /**
* Submit button that reflects react-hook-form's `isSubmitting` from the * Renders a submit button that reflects react-hook-form's `isSubmitting` from
* surrounding `FormProvider`: it disables itself while submitting and swaps in * the surrounding `FormProvider`: it disables itself while submitting and swaps
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern. * in `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
*/ */
export function SubmitButton({ export function SubmitButton({
children, children,
@@ -22,7 +32,7 @@ export function SubmitButton({
disabled, disabled,
className = "btn-primary", className = "btn-primary",
"aria-label": ariaLabel, "aria-label": ariaLabel,
}: SubmitButtonProps) { }: SubmitButtonProps): React.JSX.Element {
const { isSubmitting } = useFormState(); const { isSubmitting } = useFormState();
return ( return (

View File

@@ -1,3 +1,9 @@
/**
* A form's text field.
*
* @module
*/
import { type ReactNode, useId } from "react"; import { type ReactNode, useId } from "react";
import { import {
type FieldValues, type FieldValues,
@@ -9,16 +15,27 @@ import {
import { charCountClass } from "../../utils/shared/charCount.ts"; import { charCountClass } from "../../utils/shared/charCount.ts";
interface TextFieldProps<T extends FieldValues> { /** The props of {@link TextField}. */
export interface TextFieldProps<T extends FieldValues> {
/** The field's name in the form. */
name: Path<T>; name: Path<T>;
/** The field's label. */
label?: string; label?: string;
/** Optional control rendered to the right of the label (e.g. a reset button). */ /**
* Optional control rendered to the right of the label (e.g. a reset button).
*/
labelAction?: ReactNode; labelAction?: ReactNode;
/** The input's type; `text` by default. */
type?: "text" | "email" | "password" | "url" | "search"; type?: "text" | "email" | "password" | "url" | "search";
/** The input's placeholder. */
placeholder?: string; placeholder?: string;
/** The input's autocomplete hint. */
autoComplete?: string; autoComplete?: string;
/** Whether the input takes focus on mount. */
autoFocus?: boolean; autoFocus?: boolean;
/** Whether the input is disabled. */
disabled?: boolean; disabled?: boolean;
/** The field's validation rules. */
rules?: RegisterOptions<T, Path<T>>; rules?: RegisterOptions<T, Path<T>>;
/** Cap input length at `max` (native `maxLength`). */ /** Cap input length at `max` (native `maxLength`). */
maxLength?: number; maxLength?: number;
@@ -26,7 +43,10 @@ interface TextFieldProps<T extends FieldValues> {
showCount?: boolean; showCount?: boolean;
} }
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */ /**
* Renders a live `n / max` counter of field `name`; isolated so only counted
* fields subscribe to the value.
*/
function CharCounter<T extends FieldValues>( function CharCounter<T extends FieldValues>(
{ name, max }: { name: Path<T>; max: number }, { name, max }: { name: Path<T>; max: number },
) { ) {
@@ -40,9 +60,9 @@ function CharCounter<T extends FieldValues>(
} }
/** /**
* Native text input wired to react-hook-form via the surrounding `FormProvider`. * Renders a native text input wired to react-hook-form via the surrounding
* Renders a label, the input (with an optional character counter), and a * `FormProvider`: a label, the input (with an optional character counter), and
* validation hint sourced from `formState.errors[name]`. * a validation hint sourced from `formState.errors[name]`.
*/ */
export function TextField<T extends FieldValues>({ export function TextField<T extends FieldValues>({
name, name,
@@ -56,7 +76,7 @@ export function TextField<T extends FieldValues>({
rules, rules,
maxLength, maxLength,
showCount = false, showCount = false,
}: TextFieldProps<T>) { }: TextFieldProps<T>): React.JSX.Element {
const id = useId(); const id = useId();
const { register, formState: { errors } } = useFormContext<T>(); const { register, formState: { errors } } = useFormContext<T>();
const error = errors[name]; const error = errors[name];

View File

@@ -1,3 +1,10 @@
/**
* The site's header: the brand, the search, the user's links and buttons, and
* the socket's status.
*
* @module
*/
import { lazy, type ReactNode, Suspense, useRef, useState } from "react"; import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -10,32 +17,51 @@ import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx"; import { SearchBar } from "./SearchBar.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts"; import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
/** The dump creation modal, loaded on first open. */
const DumpCreateModal = lazy(() => const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({ import("../dump/create/DumpCreateModal.tsx").then((m) => ({
default: m.DumpCreateModal, default: m.DumpCreateModal,
})) }))
); );
/** Carried in a search entry's history state: the history index of the page /** Carried in a search entry's history state. */
* the search started from, so leaving search returns there in one step
* however many searches (or result tabs) came in between. */
interface SearchEntryState { 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
* between.
*/
searchExit?: number; searchExit?: number;
} }
/** 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 — 0 for the first
* page of the session, which has nothing in the app to go back to.
*/
function historyIndex(): number { function historyIndex(): number {
return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0; return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0;
} }
/** The props of {@link AppHeader}. */
export interface AppHeaderProps {
/** What the page shows in the header's centre, beside the search. */
centerSlot?: ReactNode;
/** Whether the new-dump button is disabled (the server is unreachable). */
disableNew?: boolean;
/** A link to open the dump creation modal with, at mount. */
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.
*/
export function AppHeader( export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: { { centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
centerSlot?: ReactNode; ): React.JSX.Element {
disableNew?: boolean;
initialDumpUrl?: string;
},
) {
const { user } = useAuth(); const { user } = useAuth();
const { wsStatus, wsErrorMessage } = useWS(); const { wsStatus, wsErrorMessage } = useWS();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -1,9 +1,18 @@
/**
* The header's chat button.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useWSChat } from "../../hooks/ws/useWS.ts"; import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts"; import { useChat } from "../../hooks/chat/useChat.ts";
/** Header chat button with an unread-message badge. Opens the app-wide chat. */ /**
export function ChatButton() { * Renders the header chat button with an unread-message badge. Opens the
* app-wide chat.
*/
export function ChatButton(): React.JSX.Element {
const { unreadChatCount } = useWSChat(); const { unreadChatCount } = useWSChat();
const { openChat } = useChat(); const { openChat } = useChat();

View File

@@ -1,3 +1,9 @@
/**
* The floating chat button.
*
* @module
*/
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -6,11 +12,11 @@ import { useWSChat } from "../../hooks/ws/useWS.ts";
import { useChat } from "../../hooks/chat/useChat.ts"; import { useChat } from "../../hooks/chat/useChat.ts";
/** /**
* Floating chat button shown once the header has scrolled out of view, mirroring * Renders the floating chat button shown once the header has scrolled out of
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider). * view, mirroring `DumpFab`, with an unread badge. Opens the single app-wide
* Only rendered for signed-in users. * chat modal (owned by ChatProvider). Only rendered for signed-in users.
*/ */
export function ChatFab() { export function ChatFab(): React.JSX.Element | null {
const { user } = useAuth(); const { user } = useAuth();
const { unreadChatCount } = useWSChat(); const { unreadChatCount } = useWSChat();
const { openChat } = useChat(); const { openChat } = useChat();

View File

@@ -1,8 +1,15 @@
/**
* The floating new-dump button.
*
* @module
*/
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
/** The dump creation modal, loaded on first open. */
const DumpCreateModal = lazy(() => const DumpCreateModal = lazy(() =>
import("../dump/create/DumpCreateModal.tsx").then((m) => ({ import("../dump/create/DumpCreateModal.tsx").then((m) => ({
default: m.DumpCreateModal, default: m.DumpCreateModal,
@@ -10,17 +17,17 @@ const DumpCreateModal = lazy(() =>
); );
/** /**
* Floating "new dump" button shown once the header has scrolled out of view. * Renders the floating "new dump" button shown once the header has scrolled out
* Sits in the bottom-right corner and lifts above the global player (which is * of view. Sits in the bottom-right corner and lifts above the global player
* full-width on mobile) so the two never overlap. Only rendered for signed-in * (which is full-width on mobile) so the two never overlap. Only rendered for
* users — dump creation requires an account. * signed-in users — dump creation requires an account.
* *
* Mounted once at the app root (not per-page) since some pages render their own * 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 * 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 * read of the current page's `.app-header`, so it survives client-side
* navigation and lazily-mounted route headers. * navigation and lazily-mounted route headers.
*/ */
export function DumpFab() { export function DumpFab(): React.JSX.Element | null {
const { user } = useAuth(); const { user } = useAuth();
const location = useLocation(); const location = useLocation();
const [headerHidden, setHeaderHidden] = useState(false); const [headerHidden, setHeaderHidden] = useState(false);

View File

@@ -1,9 +1,19 @@
/**
* The header's notification bell.
*
* @module
*/
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useWS } from "../../hooks/ws/useWS.ts"; import { useWS } from "../../hooks/ws/useWS.ts";
export function NotificationBell() { /**
* Renders the header's notification bell with an unread badge; it rings when a
* notification arrives, and opens the notifications page.
*/
export function NotificationBell(): React.JSX.Element {
const { unreadNotificationCount, lastNotification } = useWS(); const { unreadNotificationCount, lastNotification } = useWS();
const navigate = useNavigate(); const navigate = useNavigate();
const [ringing, setRinging] = useState(false); const [ringing, setRinging] = useState(false);

View File

@@ -1,15 +1,28 @@
/**
* A page that failed to load.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { PageShell } from "./PageShell.tsx"; import { PageShell } from "./PageShell.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx"; import { ErrorCard } from "../shared/ErrorCard.tsx";
/** The props of {@link PageError}. */
export interface PageErrorProps {
/** What failed; "Something went wrong" by default. */
title?: string;
/** Why. */
message: string;
/** Buttons offered under it. */
actions?: ReactNode;
}
/** Renders an error card as the whole page, under the header. */
export function PageError( export function PageError(
{ title, message, actions }: { { title, message, actions }: PageErrorProps,
title?: string; ): React.JSX.Element {
message: string;
actions?: ReactNode;
},
) {
const resolvedTitle = title ?? t`Something went wrong`; const resolvedTitle = title ?? t`Something went wrong`;
return ( return (
<PageShell> <PageShell>

View File

@@ -1,15 +1,26 @@
/**
* A page's frame: the header, and the content under it.
*
* @module
*/
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { AppHeader } from "./AppHeader.tsx"; import { AppHeader } from "./AppHeader.tsx";
interface PageShellProps { /** The props of {@link PageShell}. */
export interface PageShellProps {
/** The page's content. */
children: ReactNode; children: ReactNode;
/** Whether the content is centred. */
centered?: boolean; centered?: boolean;
/** What the page shows in the header's centre. */
centerSlot?: ReactNode; centerSlot?: ReactNode;
} }
/** Renders the header and, under it, the page's content. */
export function PageShell( export function PageShell(
{ children, centered = false, centerSlot }: PageShellProps, { children, centered = false, centerSlot }: PageShellProps,
) { ): React.JSX.Element {
return ( return (
<div className="page-shell"> <div className="page-shell">
<AppHeader centerSlot={centerSlot} /> <AppHeader centerSlot={centerSlot} />

View File

@@ -1,24 +1,34 @@
/**
* The header's search form.
*
* @module
*/
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
interface SearchBarProps { /** The props of {@link SearchBar}. */
export interface SearchBarProps {
/** Whether it is open. */
expanded: boolean; expanded: boolean;
/** The query as typed. */
value: string; value: string;
/** Called as the query is typed. */
onValueChange: (v: string) => void; onValueChange: (v: string) => void;
/** Called with the trimmed query on submit. */
onSubmit: (q: string) => void; onSubmit: (q: string) => void;
/** Escape pressed in the input. */ /** Escape pressed in the input. */
onCancel: () => void; onCancel: () => void;
} }
/** /**
* The header's search form. The header renders two of these (one in its * 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 * centre column, one in the row that slides open under it on small viewports)
* viewports) over one shared state, so the draft survives crossing the * over one shared state, so the draft survives crossing the breakpoint.
* breakpoint.
*/ */
export function SearchBar( export function SearchBar(
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps, { expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
) { ): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const wasExpanded = useRef(expanded); const wasExpanded = useRef(expanded);

View File

@@ -1,3 +1,9 @@
/**
* The header's avatar menu, on small viewports.
*
* @module
*/
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -5,7 +11,17 @@ import { Trans } from "@lingui/react/macro";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts"; import type { SelfUser } from "../../../shared/user.ts";
export function UserMenu({ user }: { user: SelfUser }) { /** The props of {@link UserMenu}. */
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.
*/
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* The global player, docked at the bottom of every page.
*
* @module
*/
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -13,10 +19,13 @@ import { Thumbnail } from "../shared/Thumbnail.tsx";
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts"; import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/player/youtube.ts"; import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
/** An embed item of the player. */
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>; type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
/** One row of the tracklist, whether it comes from the queue or from a /**
* YouTube playlist the embed is holding. */ * Renders one row of the tracklist, whether it comes from the queue or from a
* YouTube playlist the embed is holding.
*/
function TrackRow( function TrackRow(
{ num, title, duration, isCurrent, disabled, onPlay, rowRef }: { { num, title, duration, isCurrent, disabled, onPlay, rowRef }: {
num: number | string; num: number | string;
@@ -44,13 +53,16 @@ function TrackRow(
); );
} }
// 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 * Returns the URL the iframe of `item` loads. The stored embedUrl is the
// rather than baked into rich_content. Only a fresh play() sets autoplay: a * canonical, non-playing form — it is also rendered outside the player, so the
// session restored from localStorage has no user gesture behind it, so it stays * autoplay parameter is added here at playback time rather than baked into
// paused, matching how MediaPlayer treats file items. * rich_content. Only a fresh play() sets `autoplay`: a session restored from
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native * localStorage has no user gesture behind it, so it stays paused, matching how
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve. * 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.
*/
function playbackUrl(item: EmbedItem, autoplay: boolean) { function playbackUrl(item: EmbedItem, autoplay: boolean) {
// A playlist embed is also driven from the outside — see useYouTubePlaylist. // A playlist embed is also driven from the outside — see useYouTubePlaylist.
const base = item.type === "youtube" && embedListId(item.embedUrl) const base = item.type === "youtube" && embedListId(item.embedUrl)
@@ -69,7 +81,13 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
} }
} }
export function GlobalPlayer() { /**
* 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.
*/
export function GlobalPlayer(): React.JSX.Element | null {
const { const {
current, current,
queue, queue,

View File

@@ -1,3 +1,9 @@
/**
* The site's audio and video player.
*
* @module
*/
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { fmt } from "../../utils/player/duration.ts"; import { fmt } from "../../utils/player/duration.ts";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -11,16 +17,26 @@ import { SeekBar } from "./SeekBar.tsx";
import { Waveform } from "./Waveform.tsx"; import { Waveform } from "./Waveform.tsx";
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts"; 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; const HIDE_DELAY = 2500;
interface MediaPlayerProps { /** The props of {@link MediaPlayer}. */
export interface MediaPlayerProps {
/** The media's URL. */
src: string; src: string;
/** Whether it is audio or video. */
kind: "audio" | "video"; kind: "audio" | "video";
/** The media's type. */
mime?: string; mime?: string;
/** A video's still before it plays. */
poster?: string; poster?: string;
/** Whether it starts playing on mount. */
autoplay?: boolean; autoplay?: boolean;
/** Where it starts, in seconds. */
startTime?: number; startTime?: number;
/** Called when it starts or stops playing. */
onPlayStateChange?: (playing: boolean) => void; onPlayStateChange?: (playing: boolean) => void;
/** Called as it plays, with the playhead and the duration. */
onTimeUpdate?: (time: number, duration: number) => void; onTimeUpdate?: (time: number, duration: number) => void;
/** Fired when the media reaches its end — the queue's advance hook. */ /** Fired when the media reaches its end — the queue's advance hook. */
onEnded?: () => void; onEnded?: () => void;
@@ -30,10 +46,17 @@ interface MediaPlayerProps {
/** "waveform" (default) decodes peaks from the source; "progress" draws a /** "waveform" (default) decodes peaks from the source; "progress" draws a
* slim seek bar and never fetches the media a second time. */ * slim seek bar and never fetches the media a second time. */
trackStyle?: "waveform" | "progress"; trackStyle?: "waveform" | "progress";
/** Receives its seek handle, for the global player's controls. */
seekRef?: { current: ((t: number) => void) | null }; seekRef?: { current: ((t: number) => void) | null };
/** Receives its play/pause handle, for the global player's controls. */
toggleRef?: { current: (() => void) | null }; 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.
*/
export function MediaPlayer( export function MediaPlayer(
{ {
src, src,
@@ -50,7 +73,7 @@ export function MediaPlayer(
seekRef, seekRef,
toggleRef, toggleRef,
}: MediaPlayerProps, }: MediaPlayerProps,
) { ): React.JSX.Element {
const mediaRef = useRef<HTMLMediaElement>(null); const mediaRef = useRef<HTMLMediaElement>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);

View File

@@ -1,19 +1,34 @@
// Player control icons, shared by MediaPlayer and FilePreview. /**
* Player control icons, shared by MediaPlayer and FilePreview.
*
* @module
*/
export const IconPlay = () => ( /** Renders the play icon. */
export const IconPlay = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}> <svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
<polygon points="6,3 20,12 6,21" /> <polygon points="6,3 20,12 6,21" />
</svg> </svg>
); );
export const IconPause = () => ( /** Renders the pause icon. */
export const IconPause = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}> <svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
<rect x="5" y="3" width="4" height="18" rx="1" /> <rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" /> <rect x="15" y="3" width="4" height="18" rx="1" />
</svg> </svg>
); );
export const IconVolume = ({ muted }: { muted: boolean }) => ( /** The props of {@link IconVolume}. */
export interface IconVolumeProps {
/** Whether the sound is off. */
muted: boolean;
}
/** Renders the volume icon, struck through when muted. */
export const IconVolume = (
{ muted }: IconVolumeProps,
): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
{muted {muted
? ( ? (
@@ -25,7 +40,8 @@ export const IconVolume = ({ muted }: { muted: boolean }) => (
</svg> </svg>
); );
export const IconFullscreen = () => ( /** Renders the fullscreen icon. */
export const IconFullscreen = (): React.JSX.Element => (
<svg viewBox="0 0 24 24" fill="currentColor"> <svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" /> <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg> </svg>

View File

@@ -1,16 +1,33 @@
/**
* A slim seek bar.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks /** The props of {@link SeekBar}. */
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */ 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. */
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.
*/
export function SeekBar( export function SeekBar(
{ current, duration, onSeek, onDragChange, className }: { { current, duration, onSeek, onDragChange, className }: SeekBarProps,
current: number; ): React.JSX.Element {
duration: number;
onSeek: (t: number) => void;
onDragChange: (dragging: boolean) => void;
className?: string;
},
) {
const progress = duration > 0 ? current / duration : 0; const progress = duration > 0 ? current / duration : 0;
return ( return (
<div className={`audio-player-track${className ? ` ${className}` : ""}`}> <div className={`audio-player-track${className ? ` ${className}` : ""}`}>

View File

@@ -1,3 +1,9 @@
/**
* An audio file's waveform, which is also its seek bar.
*
* @module
*/
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
BAR_GAP, BAR_GAP,
@@ -8,14 +14,25 @@ import {
WAVEFORM_H, WAVEFORM_H,
} from "../../utils/player/waveform.ts"; } from "../../utils/player/waveform.ts";
/** The props of {@link Waveform}. */
export interface WaveformProps {
/** The audio's URL. */
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.
*/
export function Waveform( export function Waveform(
{ src, current, duration, onSeek }: { { src, current, duration, onSeek }: WaveformProps,
src: string; ): React.JSX.Element {
current: number;
duration: number;
onSeek: (t: number) => void;
},
) {
const [peaks, setPeaks] = useState<Float32Array | null>(null); const [peaks, setPeaks] = useState<Float32Array | null>(null);
useEffect(() => { useEffect(() => {

View File

@@ -1,3 +1,9 @@
/**
* The modal that adds a dump to playlists.
*
* @module
*/
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
@@ -8,14 +14,22 @@ import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts
import { Modal } from "../shared/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps { /** The props of {@link AddToPlaylistModal}. */
export interface AddToPlaylistModalProps {
/** The dump's id. */
dumpId: string; dumpId: string;
/** Called to close it. */
onClose: () => void; 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.
*/
export function AddToPlaylistModal( export function AddToPlaylistModal(
{ dumpId, onClose }: AddToPlaylistModalProps, { dumpId, onClose }: AddToPlaylistModalProps,
) { ): React.JSX.Element {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]); const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -1,3 +1,9 @@
/**
* The new-playlist button.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -5,19 +11,24 @@ import type { Playlist } from "../../../shared/playlist.ts";
import { Modal } from "../shared/Modal.tsx"; import { Modal } from "../shared/Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistButtonProps { /** The props of {@link NewPlaylistButton}. */
export interface NewPlaylistButtonProps {
/** Called with the playlist created. */
onCreated: (playlist: Playlist) => void; onCreated: (playlist: Playlist) => void;
/** The button's label; "+ New playlist" by default. */
toggleLabel?: string; toggleLabel?: string;
/** The button's class. */
toggleClassName?: string; toggleClassName?: string;
} }
/** Renders a button that opens the playlist creation form in a modal. */
export function NewPlaylistButton( export function NewPlaylistButton(
{ {
onCreated, onCreated,
toggleLabel, toggleLabel,
toggleClassName = "new-item-toggle", toggleClassName = "new-item-toggle",
}: NewPlaylistButtonProps, }: NewPlaylistButtonProps,
) { ): React.JSX.Element {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (

View File

@@ -1,3 +1,9 @@
/**
* A playlist, as a card in a list.
*
* @module
*/
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
@@ -12,15 +18,24 @@ import {
} from "../../utils/shared/visited.ts"; } from "../../utils/shared/visited.ts";
import { Tooltip } from "../shared/Tooltip.tsx"; import { Tooltip } from "../shared/Tooltip.tsx";
interface PlaylistCardProps { /** The props of {@link PlaylistCard}. */
export interface PlaylistCardProps {
/** The playlist. */
playlist: Playlist; playlist: Playlist;
/** Deletes it; no delete button without it. */
onDelete?: () => void; onDelete?: () => void;
/** Whether the viewer owns it: no owner link, no unread dot. */
isOwner?: boolean; 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.
*/
export function PlaylistCard( export function PlaylistCard(
{ playlist, onDelete, isOwner }: PlaylistCardProps, { playlist, onDelete, isOwner }: PlaylistCardProps,
) { ): React.JSX.Element {
const navigate = useNavigate(); const navigate = useNavigate();
const { mediaToken } = useAuth(); const { mediaToken } = useAuth();
const unread = !isOwner && isRecent(playlist.createdAt) && const unread = !isOwner && isRecent(playlist.createdAt) &&

View File

@@ -1,3 +1,9 @@
/**
* The playlist creation form.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
@@ -19,23 +25,32 @@ import { TextField } from "../form/TextField.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../form/SegmentedField.tsx"; import { VisibilityToggle } from "../form/SegmentedField.tsx";
interface PlaylistCreateFormProps { /** The props of {@link PlaylistCreateForm}. */
export interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */ /** If provided, the new playlist will have this dump added to it. */
dumpId?: string; dumpId?: string;
/** Called with the playlist created. */
onCreated: (playlist: Playlist) => void; onCreated: (playlist: Playlist) => void;
/** Called by the Cancel button. */
onCancel: () => void; onCancel: () => void;
/** An extra class for the form. */
className?: string; className?: string;
} }
/** The form's fields. */
interface Values { interface Values {
title: string; title: string;
description: string; description: string;
isPublic: boolean; isPublic: boolean;
} }
/**
* Renders the form that creates a playlist — title, description, visibility —
* and adds `dumpId` to it when given.
*/
export function PlaylistCreateForm( export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps, { dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
) { ): React.JSX.Element {
const { authFetch } = useAuth(); const { authFetch } = useAuth();
const form = useApiForm<Values>({ const form = useApiForm<Values>({
defaultValues: { title: "", description: "", isPublic: true }, defaultValues: { title: "", description: "", isPublic: true },

View File

@@ -1,23 +1,39 @@
/**
* The list of a user's playlists, each toggling whether it holds a dump.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import type { PlaylistMembership } from "../../../shared/playlist.ts"; import type { PlaylistMembership } from "../../../shared/playlist.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface PlaylistMembershipPanelProps { /** The props of {@link PlaylistMembershipPanel}. */
export interface PlaylistMembershipPanelProps {
/** The dump's id. */
dumpId: string; dumpId: string;
/** The user's playlists, and whether each holds the dump. */
memberships: PlaylistMembership[]; memberships: PlaylistMembership[];
/** Whether the list is loading. */
loading: boolean; loading: boolean;
/** Called with the playlist toggled. */
onToggle: (membership: PlaylistMembership) => void; onToggle: (membership: PlaylistMembership) => void;
/** Called with a playlist created from the panel, which holds the dump. */
onPlaylistCreated: (membership: PlaylistMembership) => void; 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.
*/
export function PlaylistMembershipPanel({ export function PlaylistMembershipPanel({
dumpId, dumpId,
memberships, memberships,
loading, loading,
onToggle, onToggle,
onPlaylistCreated, onPlaylistCreated,
}: PlaylistMembershipPanelProps) { }: PlaylistMembershipPanelProps): React.JSX.Element {
const [showNewForm, setShowNewForm] = useState(false); const [showNewForm, setShowNewForm] = useState(false);
return ( return (

View File

@@ -1,3 +1,9 @@
/**
* The password change modal.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -13,17 +19,27 @@ import { SubmitButton } from "../form/SubmitButton.tsx";
import { TextField } from "../form/TextField.tsx"; import { TextField } from "../form/TextField.tsx";
import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { useApiForm } from "../../hooks/form/useApiForm.ts";
interface ChangePasswordModalProps { /** The props of {@link ChangePasswordModal}. */
export interface ChangePasswordModalProps {
/** Called to close it. */
onClose: () => void; onClose: () => void;
} }
/** The form's fields. */
interface Values { interface Values {
currentPassword: string; currentPassword: string;
newPassword: string; newPassword: string;
confirmPassword: string; confirmPassword: string;
} }
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) { /**
* 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.
*/
export function ChangePasswordModal(
{ onClose }: ChangePasswordModalProps,
): React.JSX.Element {
const { authFetch, updateSession } = useAuth(); const { authFetch, updateSession } = useAuth();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const form = useApiForm<Values>({ const form = useApiForm<Values>({

View File

@@ -1,18 +1,33 @@
/**
* The header of a profile's subpage.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
interface ProfileSubpageHeaderProps { /** The props of {@link ProfileSubpageHeader}. */
export interface ProfileSubpageHeaderProps {
/** The name in the route, for the back link. */
username: string; username: string;
/** The profile's user. */
profileUser: PublicUser; profileUser: PublicUser;
/** The subpage's title. */
title: string; title: string;
/** Buttons on the title row. */
actions?: ReactNode; actions?: ReactNode;
} }
/**
* Renders a link back to the profile, and the subpage's title beside the user's
* avatar.
*/
export function ProfileSubpageHeader( export function ProfileSubpageHeader(
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps, { username, profileUser, title, actions }: ProfileSubpageHeaderProps,
) { ): React.JSX.Element {
return ( return (
<div className="profile-subpage-header"> <div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back"> <Link to={`/users/${username}`} className="profile-subpage-back">

View File

@@ -1,18 +1,35 @@
/**
* A user's avatar, or their initial.
*
* @module
*/
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts"; import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
interface AvatarProps { /** The props of {@link Avatar}. */
export interface AvatarProps {
/** The user's id. */
userId: string; userId: string;
/** The user's name, for the alt text and the initial. */
username: string; username: string;
/** Whether they have an avatar, as the surrounding data says. */
hasAvatar: boolean; hasAvatar: boolean;
/** The side, in px; 36 by default. */
size?: number; size?: number;
/** Cache-busts the avatar URL. */
version?: number; 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.
*/
export function Avatar( export function Avatar(
{ userId, username, hasAvatar, size = 36, version }: AvatarProps, { userId, username, hasAvatar, size = 36, version }: AvatarProps,
) { ): React.JSX.Element {
const [imgFailed, setImgFailed] = useState(false); const [imgFailed, setImgFailed] = useState(false);
const sizeStyle = { width: size, height: size }; const sizeStyle = { width: size, height: size };

View File

@@ -1,19 +1,34 @@
/**
* A confirmation dialog, for destructive actions.
*
* @module
*/
import { useEffect, useId } from "react"; import { useEffect, useId } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ConfirmModalProps { /** The props of {@link ConfirmModal}. */
export interface ConfirmModalProps {
/** The question asked. */
message: string; message: string;
/** The confirm button's label; "Delete" by default. */
confirmLabel?: string; confirmLabel?: string;
/** Called on confirm. */
onConfirm: () => void; onConfirm: () => void;
/** Called on cancel, Escape or a backdrop click. */
onCancel: () => void; onCancel: () => void;
} }
/**
* Renders a confirmation dialog over the page. It takes Escape before a {@link
* Modal} it sits on, so only the confirm goes.
*/
export function ConfirmModal( export function ConfirmModal(
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps, { message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) { ): React.ReactPortal {
const label = confirmLabel ?? t`Delete`; const label = confirmLabel ?? t`Delete`;
const messageId = useId(); const messageId = useId();
useScrollLock(); useScrollLock();

View File

@@ -1,14 +1,30 @@
import type React from "react"; /**
* A text input with a character counter.
*
* @module
*/
import { charCountClass } from "../../utils/shared/charCount.ts"; import { charCountClass } from "../../utils/shared/charCount.ts";
interface CountedInputProps /**
* The props of {@link CountedInput}: an input's, with a required value and
* limit.
*/
export interface CountedInputProps
extends extends
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> { Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
/** The input's value. */
value: string; value: string;
/** The most characters it takes. */
maxLength: number; maxLength: number;
} }
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) { /**
* Renders a text input with a counter that warns as the value nears its limit.
*/
export function CountedInput(
{ value, maxLength, ...rest }: CountedInputProps,
): React.JSX.Element {
const countClass = charCountClass(value.length, maxLength); const countClass = charCountClass(value.length, maxLength);
return ( return (

View File

@@ -1,10 +1,25 @@
/**
* An error, as a card.
*
* @module
*/
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export function ErrorCard({ title, message, actions }: { /** The props of {@link ErrorCard}. */
export interface ErrorCardProps {
/** What failed. */
title: string; title: string;
/** Why. */
message: string; message: string;
/** Buttons offered under it (retry, go back…). */
actions?: ReactNode; actions?: ReactNode;
}) { }
/** Renders an error as a card, with optional actions under it. */
export function ErrorCard(
{ title, message, actions }: ErrorCardProps,
): React.JSX.Element {
return ( return (
<div className="error-card-wrap"> <div className="error-card-wrap">
<div className="error-card" role="alert"> <div className="error-card" role="alert">

View File

@@ -1,16 +1,34 @@
/**
* An image that is also its own upload button.
*
* @module
*/
import { useRef } from "react"; import { useRef } from "react";
interface ImagePickerProps { /** The props of {@link ImagePicker}. */
export interface ImagePickerProps {
/** The current image, null without one. */
src: string | null; src: string | null;
/** Its alt text. */
alt?: string; alt?: string;
/** The side, in px; 80 by default. */
size?: number; size?: number;
/** The corner radius, in px; 8 by default. */
borderRadius?: number; borderRadius?: number;
/** Called with the file picked. */
onChange: (file: File) => void; onChange: (file: File) => void;
/** Whether an upload is under way: the picker is disabled meanwhile. */
uploading?: boolean; uploading?: boolean;
/** The file types offered; JPEG, PNG, GIF and WebP by default. */
accept?: string; accept?: string;
/** An extra class. */
className?: string; className?: string;
} }
/**
* Renders an image (or a placeholder) that opens a file picker when clicked.
*/
export function ImagePicker({ export function ImagePicker({
src, src,
alt = "", alt = "",
@@ -20,7 +38,7 @@ export function ImagePicker({
uploading = false, uploading = false,
accept = "image/jpeg,image/png,image/gif,image/webp", accept = "image/jpeg,image/png,image/gif,image/webp",
className, className,
}: ImagePickerProps) { }: ImagePickerProps): React.JSX.Element {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const sizeStyle = { width: size, height: size, borderRadius }; const sizeStyle = { width: size, height: size, borderRadius };

View File

@@ -1,12 +1,23 @@
/**
* A modal dialog.
*
* @module
*/
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts"; import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
interface ModalProps { /** The props of {@link Modal}. */
export interface ModalProps {
/** The dialog's title. */
title: string; title: string;
/** Called to close it. */
onClose: () => void; onClose: () => void;
/** The dialog's body. */
children: ReactNode; children: ReactNode;
/** Whether it takes the wide layout. */
wide?: boolean; wide?: boolean;
/** /**
* Runs before every dismissal the user did not aim at the content — Escape, * Runs before every dismissal the user did not aim at the content — Escape,
@@ -16,9 +27,13 @@ interface ModalProps {
onBeforeClose?: () => boolean; 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.
*/
export function Modal( export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps, { title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) { ): React.ReactPortal {
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const titleId = useId(); const titleId = useId();

View File

@@ -1,25 +1,46 @@
/**
* A row of tabs.
*
* @module
*/
import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
interface Tab<T extends string> { /** A tab. */
export interface Tab<T extends string> {
/** What {@link TabBar} reports when it is chosen. */
key: T; key: T;
/** What it shows. */
label: ReactNode; label: ReactNode;
} }
interface TabBarProps<T extends string> { /** The props of {@link TabBar}. */
export interface TabBarProps<T extends string> {
/** The tabs, in order. */
tabs: Tab<T>[]; tabs: Tab<T>[];
/** The active tab's key. */
activeTab: T; activeTab: T;
/** Called with the key of the tab chosen. */
onChange: (key: T) => void; onChange: (key: T) => void;
/** An extra class for the scroller. */
className?: string; className?: string;
/** An extra class for the row. */
innerClassName?: string; innerClassName?: string;
// Optional content rendered inside the row, before the tabs (e.g. a scope /**
// selector). Sits in the same flex line so it scrolls and aligns with them. * Optional content rendered inside the row, before the tabs (e.g. a scope
* selector). Sits in the same flex line so it scrolls and aligns with them.
*/
leading?: ReactNode; leading?: ReactNode;
} }
/**
* Renders a row of tabs that scrolls when they don't all fit, keeping the
* active one in view.
*/
export function TabBar<T extends string>( export function TabBar<T extends string>(
{ tabs, activeTab, onChange, className, innerClassName, leading }: { tabs, activeTab, onChange, className, innerClassName, leading }:
TabBarProps<T>, TabBarProps<T>,
) { ): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false); const [overflowing, setOverflowing] = useState(false);

View File

@@ -1,29 +1,43 @@
import { type ComponentProps, useState } from "react"; /**
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts"; * An external thumbnail, with fallbacks down to a placeholder.
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx"; *
* @module
*/
interface ThumbnailProps { import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import {
ThumbnailPlaceholder,
type ThumbnailPlaceholderProps,
} from "./ThumbnailPlaceholder.tsx";
/** The props of {@link Thumbnail}. */
export interface ThumbnailProps {
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */ /** Raw image URL. Proxying is decided here — don't pre-proxy it. */
src?: string; src?: string;
/** Its alt text. */
alt?: string; alt?: string;
/** The image's class. */
className?: string; className?: string;
/** When to load it; lazily by default. */
loading?: "lazy" | "eager"; loading?: "lazy" | "eager";
/** Passed through to the placeholder when there's nothing to show. */ /** Passed through to the placeholder when there's nothing to show. */
placeholder: Omit<ComponentProps<typeof ThumbnailPlaceholder>, "className">; placeholder: Omit<ThumbnailPlaceholderProps, "className">;
/** Extra class for the placeholder element only. */ /** Extra class for the placeholder element only. */
placeholderClassName?: string; placeholderClassName?: string;
} }
/** /**
* An external thumbnail that always renders *something*. * Renders an external thumbnail, and always renders *something*.
* *
* Three attempts, in order: * 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 * 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
* 200; * one click) answers a cross-site referrer with 403, and an empty one with
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts * 200;
* that reject an empty referrer too; * 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
* 3. fall back to the generated placeholder. * 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 * 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. * an empty framed box and no signal anywhere that a load had failed.
@@ -37,7 +51,7 @@ export function Thumbnail(
placeholder, placeholder,
placeholderClassName, placeholderClassName,
}: ThumbnailProps, }: ThumbnailProps,
) { ): React.JSX.Element {
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">( const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
"direct", "direct",
); );

View File

@@ -1,8 +1,15 @@
/**
* Generated artwork for a page with no preview image.
*
* @module
*/
import { useState } from "react"; import { useState } from "react";
import { proxyImageUrl } from "../../utils/shared/proxyImage.ts"; import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts"; import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
interface ThumbnailPlaceholderProps { /** The props of {@link ThumbnailPlaceholder}. */
export interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */ /** The dumped URL — seeds the fallback hue and the initial. */
url?: string; url?: string;
/** Explicit hue seed for dumps with no URL to hash (file dumps). */ /** Explicit hue seed for dumps with no URL to hash (file dumps). */
@@ -15,13 +22,14 @@ interface ThumbnailPlaceholderProps {
siteName?: string; siteName?: string;
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */ /** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
glyph?: string; glyph?: string;
/** An extra class. */
className?: string; className?: string;
} }
/** /**
* Stand-in artwork for pages that offer no preview image: the site's own color * Renders stand-in artwork for pages that offer no preview image: the site's
* washed over the theme surface, with its favicon centered on top — or its * own color washed over the theme surface, with its favicon centered on top —
* initial when the favicon is missing or fails to load. * 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 * 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. * compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
@@ -29,7 +37,7 @@ interface ThumbnailPlaceholderProps {
export function ThumbnailPlaceholder( export function ThumbnailPlaceholder(
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }: { url, seed, accentColor, faviconUrl, siteName, glyph, className }:
ThumbnailPlaceholderProps, ThumbnailPlaceholderProps,
) { ): React.JSX.Element {
const [iconFailed, setIconFailed] = useState(false); const [iconFailed, setIconFailed] = useState(false);
// A different icon is a fresh thing to try — clear the prior failure during // A different icon is a fresh thing to try — clear the prior failure during

View File

@@ -1,12 +1,22 @@
/**
* A tooltip.
*
* @module
*/
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
interface TooltipProps { /** The props of {@link Tooltip}. */
export interface TooltipProps {
/** The tooltip's text. */
text: string; text: string;
/** What it is shown over. */
children: React.ReactNode; children: React.ReactNode;
} }
export function Tooltip({ text, children }: TooltipProps) { /** Renders `children` with a tooltip above them on hover. */
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
const [rect, setRect] = useState<DOMRect | null>(null); const [rect, setRect] = useState<DOMRect | null>(null);
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);

View File

@@ -1,21 +1,37 @@
/**
* The follow buttons, for a user and for a playlist.
*
* @module
*/
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFollows } from "../../hooks/social/useFollows.ts"; import { useFollows } from "../../hooks/social/useFollows.ts";
interface FollowUserButtonProps { /** The props of {@link FollowUserButton}. */
export interface FollowUserButtonProps {
/** The user's id. */
targetUserId: string; targetUserId: string;
/** The user's name, for the button's label. */
targetUsername: string; targetUsername: string;
} }
interface FollowPlaylistButtonProps { /** The props of {@link FollowPlaylistButton}. */
export interface FollowPlaylistButtonProps {
/** The playlist's id. */
targetPlaylistId: string; targetPlaylistId: string;
/** Whether it is public; only a public playlist can be followed. */
isPublic: boolean; isPublic: boolean;
} }
/**
* Renders the button that follows or unfollows a user; nothing when signed out
* or on one's own profile.
*/
export function FollowUserButton( export function FollowUserButton(
{ targetUserId, targetUsername }: FollowUserButtonProps, { targetUserId, targetUsername }: FollowUserButtonProps,
) { ): React.JSX.Element | null {
const { user } = useAuth(); const { user } = useAuth();
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows(); const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
@@ -39,9 +55,13 @@ export function FollowUserButton(
); );
} }
/**
* Renders the button that follows or unfollows a playlist; nothing when signed
* out or for a private playlist.
*/
export function FollowPlaylistButton( export function FollowPlaylistButton(
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps, { targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
) { ): React.JSX.Element | null {
const { user } = useAuth(); const { user } = useAuth();
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } = const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
useFollows(); useFollows();

View File

@@ -1,20 +1,37 @@
/**
* A comment's like button.
*
* @module
*/
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx"; import { UserListPopover } from "./UserListPopover.tsx";
interface LikeButtonProps { /** The props of {@link LikeButton}. */
export interface LikeButtonProps {
/** The comment's id. */
commentId: string; commentId: string;
/** Its like count. */
count: number; count: number;
/** Whether the user likes it. */
liked: boolean; liked: boolean;
/** Whether it is disabled (signed out). */
disabled?: boolean; disabled?: boolean;
/** Likes the comment; `shownCount` is the count on screen. */
onLike: (commentId: string, shownCount?: number) => void; onLike: (commentId: string, shownCount?: number) => void;
/** Withdraws the like; `shownCount` is the count on screen. */
onUnlike: (commentId: string, shownCount?: number) => void; onUnlike: (commentId: string, shownCount?: number) => void;
} }
/**
* Renders a comment's like button with its count, which opens the list of who
* likes it.
*/
export function LikeButton( export function LikeButton(
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps, { commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
) { ): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null); const countRef = useRef<HTMLSpanElement>(null);

View File

@@ -1,3 +1,9 @@
/**
* A popover listing users: who voted for a dump, who likes a comment.
*
* @module
*/
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -11,12 +17,17 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts"; import { parseAPIResponse } from "../../utils/shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
interface UserListPopoverProps { /** The props of {@link UserListPopover}. */
export interface UserListPopoverProps {
/** The element it opens against. */
anchorRef: React.RefObject<HTMLElement | null>; anchorRef: React.RefObject<HTMLElement | null>;
/** Called to close it: an outside click, Escape, or a scroll. */
onClose: () => void; onClose: () => void;
/** The paginated endpoint listing the users. */
fetchUrl: string; fetchUrl: string;
} }
/** The list's state: loading, failed, or loaded with a page of users. */
type State = type State =
| { status: "loading" } | { status: "loading" }
| { status: "error"; error: string } | { status: "error"; error: string }
@@ -28,6 +39,7 @@ type State =
loadingMore: boolean; loadingMore: boolean;
}; };
/** Where the popover sits, in viewport coordinates. */
interface Placement { interface Placement {
left: number; left: number;
top?: number; top?: number;
@@ -35,13 +47,21 @@ interface Placement {
maxHeight: number; maxHeight: number;
} }
/** The popover's preferred largest height, in px. */
const PREFERRED_MAX_HEIGHT = 280; const PREFERRED_MAX_HEIGHT = 280;
/** The space kept clear of the viewport's edges, in px. */
const VIEWPORT_MARGIN = 8; const VIEWPORT_MARGIN = 8;
/** The least height worth opening below the anchor, in px. */
const MIN_USABLE_HEIGHT = 100; 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.
*/
export function UserListPopover( export function UserListPopover(
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps, { anchorRef, onClose, fetchUrl }: UserListPopoverProps,
) { ): React.ReactPortal | null {
const { token } = useAuth(); const { token } = useAuth();
const [state, setState] = useState<State>({ status: "loading" }); const [state, setState] = useState<State>({ status: "loading" });
const [placement, setPlacement] = useState<Placement | null>(null); const [placement, setPlacement] = useState<Placement | null>(null);

View File

@@ -1,20 +1,36 @@
/**
* A dump's vote button.
*
* @module
*/
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { API_URL } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { UserListPopover } from "./UserListPopover.tsx"; import { UserListPopover } from "./UserListPopover.tsx";
interface VoteButtonProps { /** The props of {@link VoteButton}. */
export interface VoteButtonProps {
/** The dump's id. */
dumpId: string; dumpId: string;
/** Its vote count. */
count: number; count: number;
/** Whether the user upvoted it. */
voted: boolean; voted: boolean;
/** Whether it is disabled (signed out). */
disabled?: boolean; disabled?: boolean;
/** Upvotes the dump; `shownCount` is the count on screen. */
onCast: (dumpId: string, shownCount?: number) => void; onCast: (dumpId: string, shownCount?: number) => void;
/** Withdraws the vote; `shownCount` is the count on screen. */
onRemove: (dumpId: string, shownCount?: number) => void; onRemove: (dumpId: string, shownCount?: number) => void;
} }
/**
* Renders a dump's vote button with its count, which opens the list of voters.
*/
export function VoteButton( export function VoteButton(
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps, { dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
) { ): React.JSX.Element {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const countRef = useRef<HTMLSpanElement>(null); const countRef = useRef<HTMLSpanElement>(null);

View File

@@ -1,15 +1,20 @@
// TypeScript triple-slash directive /**
// include type declarations from the package vite/client * Where the API and the socket live, and the page sizes the feeds ask for.
*
* In dev (Vite dev server), the frontend and API run on different ports, so the
* URLs are absolute; `VITE_API_*` variables can override the defaults. In prod
* (same container), the frontend is served by the API server itself, so both
* share the same origin and the URLs are relative (""): no build-time
* configuration is needed and the image works on any domain.
*
* @module
*/
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// In dev (Vite dev server), the frontend and API run on different ports, so we
// need an absolute URL. VITE_API_* vars can override the defaults.
//
// In prod (same container), the frontend is served by the API server itself, so
// both share the same origin. We use relative URLs ("") so no build-time
// configuration is needed and the image works on any domain.
const apiHostname = import.meta.env.VITE_API_HOSTNAME; const apiHostname = import.meta.env.VITE_API_HOSTNAME;
/** The API's origin, empty when it is the page's own. */
export const API_URL: string = apiHostname export const API_URL: string = apiHostname
? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${ ? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${
import.meta.env.VITE_API_PORT || "8000" import.meta.env.VITE_API_PORT || "8000"
@@ -18,10 +23,14 @@ export const API_URL: string = apiHostname
? "http://localhost:8000" ? "http://localhost:8000"
: ""; : "";
/** The socket's origin, always absolute. */
export const WS_URL: string = API_URL export const WS_URL: string = API_URL
? API_URL.replace(/^http/, "ws") ? API_URL.replace(/^http/, "ws")
: `${location.protocol.replace("http", "ws")}//${location.host}`; : `${location.protocol.replace("http", "ws")}//${location.host}`;
/** The search endpoint. */
export const SEARCH_URL = `${API_URL}/api/search`; export const SEARCH_URL = `${API_URL}/api/search`;
/** How many items a feed page asks for. */
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
/** How many notifications a page asks for. */
export const NOTIFICATIONS_PAGE_SIZE = 30; export const NOTIFICATIONS_PAGE_SIZE = 30;

View File

@@ -1,5 +1,15 @@
/**
* The index's feed tabs, and the one it opens on.
*
* @module
*/
/** The index's feed tabs, in the tab bar's order. */
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const; export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
/** One of the {@link FEED_TABS}. */
export type FeedTab = (typeof FEED_TABS)[number]; export type FeedTab = (typeof FEED_TABS)[number];
// Tab the index opens on for visitors and for users who haven't picked one. /**
* The tab the index opens on for visitors and for users who haven't picked one.
*/
export const DEFAULT_FEED_TAB: FeedTab = "journal"; export const DEFAULT_FEED_TAB: FeedTab = "journal";

View File

@@ -7,13 +7,22 @@
* *
* Defaults to "embed" whenever the value is missing, unsubstituted or * Defaults to "embed" whenever the value is missing, unsubstituted or
* unrecognised, so a misconfigured deployment keeps the iframe it has today. * unrecognised, so a misconfigured deployment keeps the iframe it has today.
*
* @module
*/
/**
* A Bandcamp playback path: Bandcamp's own iframe, or the site's player on the
* resolved streams.
*/ */
export type BandcampPlayer = "embed" | "native"; export type BandcampPlayer = "embed" | "native";
/** Reads the playback path from the page's `<meta>` tag. */
function readBandcampPlayer(): BandcampPlayer { function readBandcampPlayer(): BandcampPlayer {
const meta = document.querySelector('meta[name="bandcamp-player"]') const meta = document.querySelector('meta[name="bandcamp-player"]')
?.getAttribute("content")?.trim(); ?.getAttribute("content")?.trim();
return meta === "native" ? "native" : "embed"; return meta === "native" ? "native" : "embed";
} }
/** This deployment's Bandcamp playback path. */
export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer(); export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer();

View File

@@ -1,13 +1,26 @@
/**
* The session context: the signed-in session and its setter, provided by
* AuthProvider and read through `useAuth`.
*
* @module
*/
import { createContext, type Dispatch, type SetStateAction } from "react"; import { createContext, type Dispatch, type SetStateAction } from "react";
import type { Session } from "../../utils/auth/storage.ts"; import type { Session } from "../../utils/auth/storage.ts";
/** What {@link AuthContext} holds. */
export interface AuthContextValue { export interface AuthContextValue {
/** The signed-in session, null when signed out. */
authResponse: Session | null; authResponse: Session | null;
/** Replaces the session; AuthProvider keeps the stored one in step. */
setAuthResponse: Dispatch<SetStateAction<Session | null>>; setAuthResponse: Dispatch<SetStateAction<Session | null>>;
} }
export const AuthContext = createContext<AuthContextValue>({ /** The session context. */
export const AuthContext: React.Context<AuthContextValue> = createContext<
AuthContextValue
>({
authResponse: null, authResponse: null,
setAuthResponse: () => {}, setAuthResponse: () => {},
}); });

View File

@@ -1,3 +1,10 @@
/**
* The session's provider: holds it, keeps it in step with the other tabs and
* the server.
*
* @module
*/
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode, useEffect, useState } from "react";
import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
@@ -12,7 +19,22 @@ import {
} from "../../utils/auth/storage.ts"; } from "../../utils/auth/storage.ts";
import type { Session } from "../../utils/auth/storage.ts"; import type { Session } from "../../utils/auth/storage.ts";
export function AuthProvider({ children }: { children: ReactNode }) { /** The props of {@link AuthProvider}. */
export interface AuthProviderProps {
/** The app, which reads the session. */
children: ReactNode;
}
/**
* Provides {@link AuthContext}. The session starts as the stored one, follows
* the stored one when another tab signs in, out or changes the password, is
* checked with the server on mount and whenever the tab regains focus (a 401
* ends it; a changed role is picked up), and gets a media token when it
* predates them.
*/
export function AuthProvider(
{ children }: AuthProviderProps,
): React.JSX.Element {
const [authResponse, setAuthResponse] = useState<Session | null>( const [authResponse, setAuthResponse] = useState<Session | null>(
readStoredSession, readStoredSession,
); );

View File

@@ -1,18 +1,31 @@
/**
* The categories context: every category, loaded once by CategoriesProvider.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
import type { Category } from "../../../shared/category.ts"; import type { Category } from "../../../shared/category.ts";
/** What {@link CategoriesContext} holds. */
export interface CategoriesContextValue { export interface CategoriesContextValue {
/** Every category, in order. */
categories: Category[]; categories: Category[];
// Lookup by id for resolving dump category chips. /** The categories by id, for resolving dump category chips. */
byId: Map<string, Category>; byId: Map<string, Category>;
/** Whether the list has been fetched. */
isLoaded: boolean; isLoaded: boolean;
// Re-fetch the list (used after admin create/update/delete). /** Re-fetches the list (used after admin create/update/delete). */
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
export const CategoriesContext = createContext<CategoriesContextValue>({ /** The categories context. */
categories: [], export const CategoriesContext: React.Context<CategoriesContextValue> =
byId: new Map(), createContext<
isLoaded: false, CategoriesContextValue
refresh: async () => {}, >({
}); categories: [],
byId: new Map(),
isLoaded: false,
refresh: async () => {},
});

View File

@@ -1,3 +1,9 @@
/**
* The categories' provider: fetches the list once, and again on request.
*
* @module
*/
import { import {
type ReactNode, type ReactNode,
useCallback, useCallback,
@@ -14,6 +20,11 @@ import type { Category } from "../../../shared/category.ts";
import { deserializeCategory } from "../../utils/shared/deserialize.ts"; import { deserializeCategory } from "../../utils/shared/deserialize.ts";
import type { Wire } from "../../../shared/api.ts"; import type { Wire } from "../../../shared/api.ts";
/**
* Returns every category, or null when the API answers a failure.
*
* @throws {Error} When the request fails.
*/
async function fetchCategories( async function fetchCategories(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Category[] | null> { ): Promise<Category[] | null> {
@@ -24,7 +35,16 @@ async function fetchCategories(
return (body.data as Wire<Category>[]).map(deserializeCategory); return (body.data as Wire<Category>[]).map(deserializeCategory);
} }
export function CategoriesProvider({ children }: { children: ReactNode }) { /** The props of {@link CategoriesProvider}. */
export interface CategoriesProviderProps {
/** The app, which reads the categories. */
children: ReactNode;
}
/** Provides {@link CategoriesContext}, fetching the categories on mount. */
export function CategoriesProvider(
{ children }: CategoriesProviderProps,
): React.JSX.Element {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);

View File

@@ -1,12 +1,25 @@
/**
* The chat context: whether the chatbox is open, provided by ChatProvider.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
/** What {@link ChatContext} holds. */
export interface ChatContextValue { export interface ChatContextValue {
/** Whether the chatbox is open. */
isChatOpen: boolean; isChatOpen: boolean;
/** Opens the chatbox. */
openChat: () => void; openChat: () => void;
/** Closes the chatbox. */
closeChat: () => void; closeChat: () => void;
} }
export const ChatContext = createContext<ChatContextValue>({ /** The chat context. */
export const ChatContext: React.Context<ChatContextValue> = createContext<
ChatContextValue
>({
isChatOpen: false, isChatOpen: false,
openChat: () => {}, openChat: () => {},
closeChat: () => {}, closeChat: () => {},

View File

@@ -1,3 +1,9 @@
/**
* The chat's provider, which owns the one chat modal.
*
* @module
*/
import { import {
lazy, lazy,
type ReactNode, type ReactNode,
@@ -10,19 +16,29 @@ import { useSearchParams } from "react-router";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { ChatContext, type ChatContextValue } from "./ChatContext.ts"; import { ChatContext, type ChatContextValue } from "./ChatContext.ts";
/** The chat modal, loaded on first open. */
const ChatModal = lazy(() => const ChatModal = lazy(() =>
import("../../components/chat/ChatModal.tsx").then((m) => ({ import("../../components/chat/ChatModal.tsx").then((m) => ({
default: m.ChatModal, default: m.ChatModal,
})) }))
); );
/** The props of {@link ChatProvider}. */
export interface ChatProviderProps {
/** The app, which opens and closes the chat. */
children: ReactNode;
}
/** /**
* Owns the single live-chat modal instance so the header button, the floating * Provides {@link ChatContext} and owns the single live-chat modal instance so
* button, and notification deep-links all drive one modal. Open state is derived * the header button, the floating button, and notification deep-links all drive
* (no effects): a manual open, or a `?chat=open` query param (used by mention * one modal. Open state is derived (no effects): a manual open, or a
* notifications), opens it — and it's only ever open for signed-in users. * `?chat=open` query param (used by mention notifications), opens it — and it's
* only ever open for signed-in users.
*/ */
export function ChatProvider({ children }: { children: ReactNode }) { export function ChatProvider(
{ children }: ChatProviderProps,
): React.JSX.Element {
const { user } = useAuth(); const { user } = useAuth();
const [manuallyOpen, setManuallyOpen] = useState(false); const [manuallyOpen, setManuallyOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();

View File

@@ -1,26 +1,49 @@
/**
* The player contexts: what plays, the queue and the controls, and apart from
* them the playhead. PlayerProvider provides both; GlobalPlayer draws them.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
/** Fields every playable item shares, whatever its source. */ /** Fields every playable item shares, whatever its source. */
interface PlayerItemBase { export interface PlayerItemBase {
/** The item's title. */
title?: string; title?: string;
/** The page of the dump it was played from. */
dumpHref?: string; dumpHref?: string;
/** Square image for the player header — provider thumbnail, album art, or a /**
* video still. Raw URL: Thumbnail decides whether to proxy it. */ * Square image for the player header — provider thumbnail, album art, or a
* video still. Raw URL: Thumbnail decides whether to proxy it.
*/
artworkUrl?: string; artworkUrl?: string;
/** Secondary line under the title: the artist, the site name, the filename — /**
* whatever identifies the source at a glance. */ * Secondary line under the title: the artist, the site name, the filename —
* whatever identifies the source at a glance.
*/
subtitle?: string; subtitle?: string;
} }
/**
* Something the player can play: a provider's embed, an uploaded file, or an
* expiring stream.
*/
export type PlayerItem = export type PlayerItem =
| (PlayerItemBase & { | (PlayerItemBase & {
/** A provider's iframe player. */
kind: "embed"; kind: "embed";
/** The embed's URL. */
embedUrl: string; embedUrl: string;
/** The provider, e.g. "youtube". */
type: string; type: string;
}) })
| (PlayerItemBase & { | (PlayerItemBase & {
/** An uploaded file, played by a media element. */
kind: "file"; kind: "file";
/** The file's URL. */
fileUrl: string; fileUrl: string;
/** The file's type. */
mimeType: string; mimeType: string;
}) })
/** /**
@@ -33,14 +56,19 @@ export type PlayerItem =
* re-resolve. * re-resolve.
*/ */
| (PlayerItemBase & { | (PlayerItemBase & {
/** An expiring stream. */
kind: "stream"; kind: "stream";
/** The signed stream URL. */
streamUrl: string; streamUrl: string;
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */ /** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
type: string; type: string;
/** Known before playback from the tracklist, so rows can show a length. */ /** Known before playback from the tracklist, so rows can show a length. */
duration?: number; duration?: number;
/** The track's number on its release. */
trackNum?: number; trackNum?: number;
/** The page the stream was resolved from. */
resolveUrl: string; resolveUrl: string;
/** The track's position in that page's tracklist. */
resolveIndex: number; resolveIndex: number;
/** Epoch ms of the resolution that produced `streamUrl`. */ /** Epoch ms of the resolution that produced `streamUrl`. */
resolvedAt: number; resolvedAt: number;
@@ -49,9 +77,9 @@ export type PlayerItem =
}); });
/** /**
* Stable identity for an item, used for active-state comparisons and to decide * Returns a stable identity for `item`, used for active-state comparisons and
* when the player is showing something new. Deliberately independent of * to decide when the player is showing something new. Deliberately independent
* `streamUrl` so a re-resolved track is still recognised as the same track. * of `streamUrl` so a re-resolved track is still recognised as the same track.
*/ */
export function playerItemKey(item: PlayerItem): string { export function playerItemKey(item: PlayerItem): string {
switch (item.kind) { switch (item.kind) {
@@ -64,44 +92,69 @@ export function playerItemKey(item: PlayerItem): string {
} }
} }
/**
* What {@link PlayerContext} holds: playback state, readable by any consumer,
* and controls, callable by any consumer.
*/
export interface PlayerContextValue { export interface PlayerContextValue {
// Playback state — readable by any consumer /** The item playing or paused; always `queue[queueIndex]`. */
current: PlayerItem | null; current: PlayerItem | null;
/** Whether it is playing. */
playing: boolean; playing: boolean;
// Queue. Every playback is a queue of at least one item, so single tracks and /**
// albums share one code path. `current` is always `queue[queueIndex]`. * The queue. Every playback is a queue of at least one item, so single tracks
* and albums share one code path.
*/
queue: PlayerItem[]; queue: PlayerItem[];
/** The position of `current` in the queue. */
queueIndex: number; queueIndex: number;
/** Whether an item follows `current`. */
hasNext: boolean; hasNext: boolean;
/** Whether an item precedes `current`. */
hasPrevious: boolean; hasPrevious: boolean;
/** True while a stream is being (re-)resolved, for spinner affordances. */ /** True while a stream is being (re-)resolved, for spinner affordances. */
resolving: boolean; resolving: boolean;
// Initial seek offset for the active item (non-zero only for an item restored /**
// from a previous session, or resumed after a re-resolve) and whether the * Initial seek offset for the active item: non-zero only for an item restored
// active MediaPlayer should autoplay. A restored item appears paused; a fresh * from a previous session, or resumed after a re-resolve.
// play() autoplays on the user gesture. */
startTime: number; startTime: number;
/**
* Whether the active MediaPlayer should autoplay. A restored item appears
* paused; a fresh play() autoplays on the user gesture.
*/
autoplay: boolean; autoplay: boolean;
// Control — callable by any consumer /** Plays `item` alone. */
play(item: PlayerItem): void; play(item: PlayerItem): void;
/** Plays `items` as the queue, from `startIndex`. */
playQueue(items: PlayerItem[], startIndex?: number): void; playQueue(items: PlayerItem[], startIndex?: number): void;
/** Plays the queue's item at `index`. */
playAt(index: number): void; playAt(index: number): void;
/** Plays the next item. */
next(): void; next(): void;
/** Plays the previous item. */
previous(): void; previous(): void;
/** Stops and closes the player. */
stop(): void; stop(): void;
/** Seeks to `time` seconds. */
seekTo(time: number): void; seekTo(time: number): void;
/** Pauses or resumes. */
togglePlay(): void; togglePlay(): void;
// Internal: GlobalPlayer registers MediaPlayer's imperative handles here /**
// so seekTo / togglePlay can reach into the actual media element. * Internal: where GlobalPlayer registers MediaPlayer's seek handle, so seekTo
* can reach into the actual media element.
*/
seekRef: { current: ((t: number) => void) | null }; seekRef: { current: ((t: number) => void) | null };
/** Internal: MediaPlayer's play/pause handle, for togglePlay. */
toggleRef: { current: (() => void) | null }; toggleRef: { current: (() => void) | null };
// Internal: GlobalPlayer calls these to push state back into the provider /** Internal: GlobalPlayer reports whether the media is playing. */
onPlayStateChange(playing: boolean): void; onPlayStateChange(playing: boolean): void;
/** Internal: GlobalPlayer reports the playhead and the duration. */
onTimeUpdate(time: number, duration: number): void; onTimeUpdate(time: number, duration: number): void;
/** Current item finished — advances the queue, or stops at the end. */ /** Current item finished — advances the queue, or stops at the end. */
onEnded(): void; onEnded(): void;
@@ -136,7 +189,10 @@ export const defaultPlayerValue: PlayerContextValue = {
onError: () => {}, onError: () => {},
}; };
export const PlayerContext = createContext<PlayerContextValue>( /** The player context. */
export const PlayerContext: React.Context<PlayerContextValue> = createContext<
PlayerContextValue
>(
defaultPlayerValue, defaultPlayerValue,
); );
@@ -146,15 +202,21 @@ export const PlayerContext = createContext<PlayerContextValue>(
* not every card that reads `current` or `playing`. * not every card that reads `current` or `playing`.
*/ */
export interface PlayerTimeValue { export interface PlayerTimeValue {
/** The playhead, in seconds. */
currentTime: number; currentTime: number;
/** The current item's length, in seconds; 0 until known. */
duration: number; duration: number;
} }
/** What a consumer outside any provider gets. */
export const defaultPlayerTimeValue: PlayerTimeValue = { export const defaultPlayerTimeValue: PlayerTimeValue = {
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
}; };
export const PlayerTimeContext = createContext<PlayerTimeValue>( /** The playhead context. */
export const PlayerTimeContext: React.Context<PlayerTimeValue> = createContext<
PlayerTimeValue
>(
defaultPlayerTimeValue, defaultPlayerTimeValue,
); );

View File

@@ -1,3 +1,10 @@
/**
* The player's provider: the queue, the playback state and the controls, kept
* across reloads, and the expiring streams kept fresh.
*
* @module
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
PlayerContext, PlayerContext,
@@ -11,6 +18,7 @@ import {
} from "../../utils/player/streamSources.ts"; } from "../../utils/player/streamSources.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
/** The localStorage key of the stored session. */
const STORAGE_KEY = "player"; const STORAGE_KEY = "player";
/** /**
@@ -21,9 +29,13 @@ const STORAGE_KEY = "player";
* so a session written by the previous build survives the upgrade. * so a session written by the previous build survives the upgrade.
*/ */
interface StoredSession { interface StoredSession {
/** The format's version. */
v: 2; v: 2;
/** The queue. */
queue: PlayerItem[]; queue: PlayerItem[];
/** The position of the entry that was playing. */
index: number; index: number;
/** How far in it was, in seconds. */
time: number; time: number;
/** /**
* Whose queue this is (null for a guest). File URLs carry the listener's * Whose queue this is (null for a guest). File URLs carry the listener's
@@ -32,12 +44,17 @@ interface StoredSession {
owner?: string | null; owner?: string | null;
} }
/** Checks whether `value` looks like a {@link PlayerItem}. */
function isPlayerItem(value: unknown): value is PlayerItem { function isPlayerItem(value: unknown): value is PlayerItem {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const kind = (value as { kind?: unknown }).kind; const kind = (value as { kind?: unknown }).kind;
return kind === "embed" || kind === "file" || kind === "stream"; return kind === "embed" || kind === "file" || kind === "stream";
} }
/**
* Returns the stored session of `owner` (null for a guest), or null when there
* is none, it is someone else's, or it is unreadable.
*/
function readSession( function readSession(
owner: string | null, owner: string | null,
): { queue: PlayerItem[]; index: number; time: number } | null { ): { queue: PlayerItem[]; index: number; time: number } | null {
@@ -81,7 +98,22 @@ function readSession(
/** Don't retry the same failing track more than once per window. */ /** Don't retry the same failing track more than once per window. */
const RERESOLVE_COOLDOWN_MS = 30_000; const RERESOLVE_COOLDOWN_MS = 30_000;
export function PlayerProvider({ children }: { children: React.ReactNode }) { /** The props of {@link PlayerProvider}. */
export interface PlayerProviderProps {
/** The app, which plays through the player. */
children: React.ReactNode;
}
/**
* Provides {@link PlayerContext} and {@link PlayerTimeContext}. The queue and
* the position are stored as they change and restored on load, paused; signing
* out, or in as someone else, ends the session. A Bandcamp stream whose signed
* URL expires is re-resolved and resumed where it was, falling back to the
* embed if that fails.
*/
export function PlayerProvider(
{ children }: PlayerProviderProps,
): React.JSX.Element {
const { user } = useAuth(); const { user } = useAuth();
const owner = user?.id ?? null; const owner = user?.id ?? null;
// Lazy initialiser: readSession runs exactly once, on first render. // Lazy initialiser: readSession runs exactly once, on first render.

View File

@@ -1,12 +1,27 @@
/**
* The follow context: whom and what the signed-in user follows, provided by
* FollowProvider.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
/** What {@link FollowContext} holds. */
export interface FollowContextValue { export interface FollowContextValue {
/** The ids of the users they follow. */
followedUserIds: Set<string>; followedUserIds: Set<string>;
/** The ids of the playlists they follow. */
followedPlaylistIds: Set<string>; followedPlaylistIds: Set<string>;
/** Follows a user. */
followUser: (userId: string) => Promise<void>; followUser: (userId: string) => Promise<void>;
/** Stops following a user. */
unfollowUser: (userId: string) => Promise<void>; unfollowUser: (userId: string) => Promise<void>;
/** Follows a playlist. */
followPlaylist: (playlistId: string) => Promise<void>; followPlaylist: (playlistId: string) => Promise<void>;
/** Stops following a playlist. */
unfollowPlaylist: (playlistId: string) => Promise<void>; unfollowPlaylist: (playlistId: string) => Promise<void>;
/** Whether the follow status has been fetched. */
isLoaded: boolean; isLoaded: boolean;
} }
@@ -21,6 +36,9 @@ export const defaultFollowValue: FollowContextValue = {
isLoaded: false, isLoaded: false,
}; };
export const FollowContext = createContext<FollowContextValue>( /** The follow context. */
export const FollowContext: React.Context<FollowContextValue> = createContext<
FollowContextValue
>(
defaultFollowValue, defaultFollowValue,
); );

View File

@@ -1,3 +1,10 @@
/**
* The follows' provider: what the signed-in user follows, changed
* optimistically.
*
* @module
*/
import { import {
type ReactNode, type ReactNode,
useCallback, useCallback,
@@ -10,7 +17,20 @@ import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import type { FollowStatus } from "../../../shared/follow.ts"; import type { FollowStatus } from "../../../shared/follow.ts";
export function FollowProvider({ children }: { children: ReactNode }) { /** The props of {@link FollowProvider}. */
export interface FollowProviderProps {
/** The app, which reads and changes the follows. */
children: ReactNode;
}
/**
* Provides {@link FollowContext}: fetches the follow status when someone signs
* in, clears it when they sign out. A follow or unfollow applies at once and is
* rolled back if the request fails.
*/
export function FollowProvider(
{ children }: FollowProviderProps,
): React.JSX.Element {
const { token, authFetch } = useAuth(); const { token, authFetch } = useAuth();
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>( const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
new Set(), new Set(),

View File

@@ -1,5 +1,12 @@
/**
* The theme context: the style and the color scheme, provided by ThemeProvider.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
/** A site style, one stylesheet each under src/themes/. */
export type StyleName = export type StyleName =
| "smooth" | "smooth"
| "brutalist" | "brutalist"
@@ -7,16 +14,25 @@ export type StyleName =
| "nyt" | "nyt"
| "kanagawa" | "kanagawa"
| "melange"; | "melange";
/** Light, dark, or following the system. */
export type ColorScheme = "auto" | "light" | "dark"; export type ColorScheme = "auto" | "light" | "dark";
/** What {@link ThemeContext} holds. */
export interface ThemeContextValue { export interface ThemeContextValue {
/** The chosen style. */
style: StyleName; style: StyleName;
/** The chosen color scheme. */
colorScheme: ColorScheme; colorScheme: ColorScheme;
/** Chooses a style. */
setStyle(s: StyleName): void; setStyle(s: StyleName): void;
/** Chooses a color scheme. */
setColorScheme(c: ColorScheme): void; setColorScheme(c: ColorScheme): void;
} }
export const ThemeContext = createContext<ThemeContextValue>({ /** The theme context. */
export const ThemeContext: React.Context<ThemeContextValue> = createContext<
ThemeContextValue
>({
style: "smooth", style: "smooth",
colorScheme: "auto", colorScheme: "auto",
setStyle: () => {}, setStyle: () => {},

View File

@@ -1,3 +1,10 @@
/**
* The theme's provider: the chosen style and color scheme, stored and applied
* to the document.
*
* @module
*/
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
type ColorScheme, type ColorScheme,
@@ -5,7 +12,21 @@ import {
ThemeContext, ThemeContext,
} from "./ThemeContext.ts"; } from "./ThemeContext.ts";
export function ThemeProvider({ children }: { children: React.ReactNode }) { /** The props of {@link ThemeProvider}. */
export interface ThemeProviderProps {
/** The app. */
children: React.ReactNode;
}
/**
* Provides {@link ThemeContext}. The style and color scheme are read from
* localStorage, stored back when they change, and set on the root element as
* `data-style` and `data-color-scheme` (absent for the defaults, `smooth` and
* `auto`), which the stylesheets key on.
*/
export function ThemeProvider(
{ children }: ThemeProviderProps,
): React.JSX.Element {
const [style, setStyleState] = useState<StyleName>(() => { const [style, setStyleState] = useState<StyleName>(() => {
const stored = localStorage.getItem("style"); const stored = localStorage.getItem("style");
// migrate legacy "default" value // migrate legacy "default" value

View File

@@ -1,20 +1,30 @@
/**
* The avatar override context: avatars changed live, provided by WSProvider.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
/** /**
* Live avatar state learned from `user_updated` WS events, keyed by userId. * Live avatar state learned from `user_updated` WS events. Kept in its own
* Kept in its own context (not WSContext) so that `Avatar` — rendered many * context (not WSContext) so that `Avatar` — rendered many times across feeds
* times across feeds and comment threads — only re-renders when an avatar * and comment threads — only re-renders when an avatar actually changes, not on
* actually changes, not on every vote/comment/dump event. * every vote/comment/dump event.
*
* - `hasAvatar` reflects whether the user currently has an avatar (so a freshly
* added or removed avatar flips even when the surrounding list data is stale).
* - `version` cache-busts the avatar URL (derived from the user's `updatedAt`).
*/ */
export interface AvatarOverride { export interface AvatarOverride {
/**
* Whether the user currently has an avatar, so a freshly added or removed
* avatar flips even when the surrounding list data is stale.
*/
hasAvatar: boolean; hasAvatar: boolean;
/** Cache-busts the avatar URL (derived from the user's `updatedAt`). */
version: number; version: number;
} }
export const AvatarOverrideContext = createContext< /** The avatar override context, keyed by user id. */
export const AvatarOverrideContext: React.Context<
Record<string, AvatarOverride>
> = createContext<
Record<string, AvatarOverride> Record<string, AvatarOverride>
>({}); >({});

View File

@@ -1,3 +1,12 @@
/**
* The socket contexts, split three ways by how often each part changes, so a
* consumer re-renders only with what it reads: typing and presence update every
* few seconds while people are around, chat with every message, and a feed has
* no use for either. WSProvider provides all three.
*
* @module
*/
import { createContext } from "react"; import { createContext } from "react";
import type { ChatMessage } from "../../../shared/chat.ts"; import type { ChatMessage } from "../../../shared/chat.ts";
import type { Comment } from "../../../shared/comment.ts"; import type { Comment } from "../../../shared/comment.ts";
@@ -7,94 +16,155 @@ import type { OnlineUser } from "../../../shared/ws.ts";
import type { Playlist } from "../../../shared/playlist.ts"; import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
/** A vote someone cast or withdrew. */
export interface VoteEvent { export interface VoteEvent {
/** The dump's id. */
dumpId: string; dumpId: string;
/** Who voted. */
voterId: string; voterId: string;
/** Whether the vote was cast or withdrawn. */
action: "cast" | "remove"; action: "cast" | "remove";
} }
/** A like someone cast or withdrew. */
export interface LikeEvent { export interface LikeEvent {
/** The comment's id. */
commentId: string; commentId: string;
/** Who liked it. */
likerId: string; likerId: string;
/** Whether the like was cast or withdrawn. */
action: "cast" | "remove"; action: "cast" | "remove";
} }
/** A playlist created, changed, deleted, or whose dumps changed. */
export interface PlaylistEvent { export interface PlaylistEvent {
/** What happened. */
type: "created" | "updated" | "deleted" | "dumps_updated"; type: "created" | "updated" | "deleted" | "dumps_updated";
/** The playlist's id. */
playlistId: string; playlistId: string;
/** The playlist as it is now, when created or updated. */
playlist?: Playlist; playlist?: Playlist;
/** The owner's id, when deleted. */
userId?: string; userId?: string;
/** The dumps the user can see in it, in order, when its dumps changed. */
dumpIds?: string[]; dumpIds?: string[];
/** How many dumps it holds, when its dumps changed. */
dumpCount?: number; dumpCount?: number;
} }
/** A comment posted, edited or deleted. */
export interface CommentEvent { export interface CommentEvent {
/** What happened. */
type: "created" | "deleted" | "updated"; type: "created" | "deleted" | "updated";
/** The id of the dump it is under. */
dumpId: string; dumpId: string;
/** The comment as it is now, when posted or edited. */
comment?: Comment; comment?: Comment;
/** The comment's id, when deleted. */
commentId?: string; commentId?: string;
} }
/** A user whose public profile changed. */
export interface UserEvent { export interface UserEvent {
/** The user as they are now. */
user: PublicUser; user: PublicUser;
} }
/** The replied-to message, as shown in the reply bar and on a pending send. */ /** The replied-to message, as shown in the reply bar and on a pending send. */
export interface ChatReplyTarget { export interface ChatReplyTarget {
/** The message's id. */
id: string; id: string;
/** Its author's name. */
author: string; author: string;
/** Its text. */
body: string; body: string;
} }
/** A message this client sent that the server hasn't echoed back yet — shown /**
* in the list as "sending", or "failed" with a way to retry or discard it. */ * A message this client sent that the server hasn't echoed back yet — shown in
* the list as "sending", or "failed" with a way to retry or discard it.
*/
export interface PendingChatMessage { export interface PendingChatMessage {
/** The id the echo will carry back. */
nonce: string; nonce: string;
/** The message. */
body: string; body: string;
/** The message it replies to. */
replyTo?: ChatReplyTarget; replyTo?: ChatReplyTarget;
/** When it was sent. */
createdAt: Date; createdAt: Date;
/** Whether it is on its way or failed. */
status: "sending" | "failed"; status: "sending" | "failed";
/** Why it failed. */
error?: string; error?: string;
} }
/** A failed chat edit or delete, surfaced in the chat until dismissed. */ /** A failed chat edit or delete, surfaced in the chat until dismissed. */
export interface ChatError { export interface ChatError {
/** Distinguishes one error from the next. */
id: number; id: number;
/** What went wrong. */
message: string; message: string;
} }
/** /**
* The socket's state, split three ways by how often each part changes, so a * What {@link WSContext} holds: the connection, votes, likes, and the live
* consumer re-renders only with what it reads: typing and presence update * dump, playlist, comment, user and notification events.
* every few seconds while people are around, chat with every message, and a
* feed has no use for either.
*/ */
export interface WSContextValue { export interface WSContextValue {
/** The connection's state. */
wsStatus: "connecting" | "connected" | "disconnected"; wsStatus: "connecting" | "connected" | "disconnected";
/** Why the socket is down, while it is. */
wsErrorMessage: string | null; wsErrorMessage: string | null;
/** Live vote counts, by dump id. */
voteCounts: Record<string, number>; voteCounts: Record<string, number>;
/** The ids of the dumps the user upvoted. */
myVotes: Set<string>; myVotes: Set<string>;
/** Live like counts, by comment id. */
likeCounts: Record<string, number>; likeCounts: Record<string, number>;
/** The ids of the comments the user likes. */
myLikes: Set<string>; myLikes: Set<string>;
/** Dumps posted (or made public) this session, newest first (capped). */
recentDumps: Dump[]; recentDumps: Dump[];
/** Ids of dumps deleted this session. */
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
/** The latest vote. */
lastVoteEvent: VoteEvent | null; lastVoteEvent: VoteEvent | null;
/** The latest like. */
lastLikeEvent: LikeEvent | null; lastLikeEvent: LikeEvent | null;
/** The latest edited dump. */
lastDumpEvent: Dump | null; lastDumpEvent: Dump | null;
/** The latest playlist change. */
lastPlaylistEvent: PlaylistEvent | null; lastPlaylistEvent: PlaylistEvent | null;
/** Ids of playlists deleted this session. */
deletedPlaylistIds: Set<string>; deletedPlaylistIds: Set<string>;
/** The latest comment change. */
lastCommentEvent: CommentEvent | null; lastCommentEvent: CommentEvent | null;
/** The latest profile change. */
lastUserEvent: UserEvent | null; lastUserEvent: UserEvent | null;
/** How many notifications the user hasn't read. */
unreadNotificationCount: number; unreadNotificationCount: number;
/** The latest notification received. */
lastNotification: Notification | null; lastNotification: Notification | null;
/** Increments on each WS reconnect so pages can backfill missed events. */ /** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number; connectionEpoch: number;
/** `shownCount`: the count on screen, used until a live one arrives. */ /**
* Upvotes a dump, optimistically. `shownCount`: the count on screen, used
* until a live one arrives.
*/
castVote: (dumpId: string, shownCount?: number) => void; castVote: (dumpId: string, shownCount?: number) => void;
/** Withdraws an upvote, optimistically; `shownCount` as for `castVote`. */
removeVote: (dumpId: string, shownCount?: number) => void; removeVote: (dumpId: string, shownCount?: number) => void;
/** Likes a comment, optimistically; `shownCount` as for `castVote`. */
castCommentLike: (commentId: string, shownCount?: number) => void; castCommentLike: (commentId: string, shownCount?: number) => void;
/** Withdraws a like, optimistically; `shownCount` as for `castVote`. */
removeCommentLike: (commentId: string, shownCount?: number) => void; removeCommentLike: (commentId: string, shownCount?: number) => void;
/**
* Adds a dump this client just posted to `recentDumps`, without waiting for
* its broadcast.
*/
injectDump: (dump: Dump) => void; injectDump: (dump: Dump) => void;
/** Sets the unread notification count to zero. */
clearUnreadNotifications: () => void; clearUnreadNotifications: () => void;
} }
@@ -102,42 +172,65 @@ export interface WSContextValue {
export interface WSChatContextValue { export interface WSChatContextValue {
/** Live buffer of chat messages received this session (capped). */ /** Live buffer of chat messages received this session (capped). */
chatMessages: ChatMessage[]; chatMessages: ChatMessage[];
/** Ids of chat messages deleted live this session, so fetched history can /**
* filter them out. */ * Ids of chat messages deleted live this session, so fetched history can
* filter them out.
*/
deletedChatIds: Set<string>; deletedChatIds: Set<string>;
/** Messages from others since the read marker — kept by the server, so it /**
* survives reloads and agrees across tabs and devices. */ * Messages from others since the read marker — kept by the server, so it
* survives reloads and agrees across tabs and devices.
*/
unreadChatCount: number; unreadChatCount: number;
/** The server's read marker: created_at of the newest message the user has /**
* seen. Null until the socket's welcome. */ * The server's read marker: created_at of the newest message the user has
* seen. Null until the socket's welcome.
*/
chatReadAt: Date | null; chatReadAt: Date | null;
/** Messages sent and not yet echoed back. */
pendingChatMessages: PendingChatMessage[]; pendingChatMessages: PendingChatMessage[];
/** The latest failed edit or delete, until dismissed. */
chatError: ChatError | null; chatError: ChatError | null;
/** Users currently composing a chat message (excludes nobody — the chatbox /**
* filters out the current user itself). Shaped like presence for avatars. */ * Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars.
*/
typingUsers: OnlineUser[]; typingUsers: OnlineUser[];
/** Sends a message, replying to `replyTo` if given. */
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void; sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
/** Resends the failed message `nonce`. */
retryChatMessage: (nonce: string) => void; retryChatMessage: (nonce: string) => void;
/** Drops the failed message `nonce`. */
discardChatMessage: (nonce: string) => void; discardChatMessage: (nonce: string) => void;
/** Edits a message. */
editChatMessage: (id: string, body: string) => void; editChatMessage: (id: string, body: string) => void;
/** Deletes a message. */
deleteChatMessage: (id: string) => void; deleteChatMessage: (id: string) => void;
/** Clears `chatError`. */
dismissChatError: () => void; dismissChatError: () => void;
/** Moves the read marker up to the message created at `at`. */ /** Moves the read marker up to the message created at `at`. */
markChatRead: (at: Date) => void; markChatRead: (at: Date) => void;
/** Folds an authoritative fetch of the stream between `from` and `to` into /**
* the live buffer after a reconnect: buffered messages in that range which * Folds an authoritative fetch of the stream between `from` and `to` into the
* the server no longer has were deleted while the socket was down. */ * live buffer after a reconnect: buffered messages in that range which the
* server no longer has were deleted while the socket was down.
*/
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void; reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/** Inform the server whether the user is currently composing a message, so it /**
* can show/hide the "is typing" indicator to other clients. */ * Informs the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients.
*/
setChatTyping: (typing: boolean) => void; setChatTyping: (typing: boolean) => void;
/** Inform the server whether the chatbox is open (suppresses redundant /**
* mention notifications while the user is reading chat). */ * Informs the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat).
*/
setChatOpen: (open: boolean) => void; setChatOpen: (open: boolean) => void;
} }
/** Who's connected. */ /** Who's connected. */
export interface WSPresenceContextValue { export interface WSPresenceContextValue {
/** The signed-in users with a socket open. */
onlineUsers: OnlineUser[]; onlineUsers: OnlineUser[];
} }
@@ -169,6 +262,7 @@ export const defaultWSValue: WSContextValue = {
clearUnreadNotifications: () => {}, clearUnreadNotifications: () => {},
}; };
/** What a consumer outside any provider gets. */
export const defaultWSChatValue: WSChatContextValue = { export const defaultWSChatValue: WSChatContextValue = {
chatMessages: [], chatMessages: [],
deletedChatIds: new Set(), deletedChatIds: new Set(),
@@ -189,14 +283,25 @@ export const defaultWSChatValue: WSChatContextValue = {
setChatOpen: () => {}, setChatOpen: () => {},
}; };
/** What a consumer outside any provider gets. */
export const defaultWSPresenceValue: WSPresenceContextValue = { export const defaultWSPresenceValue: WSPresenceContextValue = {
onlineUsers: [], onlineUsers: [],
}; };
export const WSContext = createContext<WSContextValue>(defaultWSValue); /** The socket context: connection, votes, likes and live events. */
export const WSChatContext = createContext<WSChatContextValue>( export const WSContext: React.Context<WSContextValue> = createContext<
WSContextValue
>(defaultWSValue);
/** The chat context. */
export const WSChatContext: React.Context<WSChatContextValue> = createContext<
WSChatContextValue
>(
defaultWSChatValue, defaultWSChatValue,
); );
export const WSPresenceContext = createContext<WSPresenceContextValue>( /** The presence context. */
defaultWSPresenceValue, export const WSPresenceContext: React.Context<WSPresenceContextValue> =
); createContext<
WSPresenceContextValue
>(
defaultWSPresenceValue,
);

View File

@@ -1,3 +1,11 @@
/**
* The socket's provider: one connection per session, and the live state it
* feeds — presence, votes, likes, dumps, playlists, comments, users,
* notifications and the chat.
*
* @module
*/
import { import {
type ReactNode, type ReactNode,
useCallback, useCallback,
@@ -51,40 +59,70 @@ import { setTitleBadge } from "../../hooks/shared/useDocumentTitle.ts";
import { playChime } from "../../utils/shared/sound.ts"; import { playChime } from "../../utils/shared/sound.ts";
import { mentionsUser } from "../../utils/chat/text.ts"; import { mentionsUser } from "../../utils/chat/text.ts";
interface WSProviderProps { /** The props of {@link WSProvider}. */
export interface WSProviderProps {
/** The app, which reads the live state. */
children: ReactNode; children: ReactNode;
} }
/** The longest wait between reconnection attempts, in ms. */
const MAX_BACKOFF = 30_000; const MAX_BACKOFF = 30_000;
/**
* How long an optimistic vote or like waits for its acknowledgement before it
* is rolled back, in ms.
*/
const ACK_TIMEOUT = 5_000; const ACK_TIMEOUT = 5_000;
/**
* How long a connection attempt may take before the socket is shown as down, in
* ms.
*/
const CONNECT_TIMEOUT = 2_500; const CONNECT_TIMEOUT = 2_500;
// Cap the live "new dumps" buffer so a long-lived session can't grow it without /**
// bound. Feeds merge these on top of their own paginated list, so a generous * Cap on the live "new dumps" buffer, so a long-lived session can't grow it
// cap is plenty — older live arrivals are reachable via normal pagination. * without bound. Feeds merge these on top of their own paginated list, so a
* generous cap is plenty — older live arrivals are reachable via normal
* pagination.
*/
const MAX_RECENT_DUMPS = 100; const MAX_RECENT_DUMPS = 100;
// Cap the live chat buffer so a long-lived session can't grow it without bound. /**
// Older messages remain reachable via the /api/chat history endpoint. * Cap on the live chat buffer, so a long-lived session can't grow it without
* bound. Older messages remain reachable via the /api/chat history endpoint.
*/
const MAX_CHAT_MESSAGES = 200; const MAX_CHAT_MESSAGES = 200;
// How long a sent chat message may go without its echo before it's shown as /**
// failed, and how often the read marker may go out. * How long a sent chat message may go without its echo before it's shown as
* failed, in ms.
*/
const CHAT_SEND_TIMEOUT = 10_000; const CHAT_SEND_TIMEOUT = 10_000;
/** How often the read marker may go out, in ms. */
const CHAT_READ_THROTTLE = 1_000; const CHAT_READ_THROTTLE = 1_000;
// A failed send whose text turns up in the resynced history within this long /**
// of being sent did reach the server — only its echo was lost. * A failed send whose text turns up in the resynced history within this long of
* being sent did reach the server — only its echo was lost.
*/
const CHAT_ECHO_WINDOW = 60_000; const CHAT_ECHO_WINDOW = 60_000;
/** An optimistic vote awaiting its acknowledgement. */
interface PendingVote { interface PendingVote {
/** The timer that rolls it back. */
timeout: ReturnType<typeof setTimeout>; timeout: ReturnType<typeof setTimeout>;
/** Undoes it. */
rollback: () => void; rollback: () => void;
} }
/** An optimistic like awaiting its acknowledgement. */
interface PendingLike { interface PendingLike {
/** The timer that rolls it back. */
timeout: ReturnType<typeof setTimeout>; timeout: ReturnType<typeof setTimeout>;
/** Undoes it. */
rollback: () => void; rollback: () => void;
} }
// Minimal runtime check: verify the `type` field is a known string so we can /**
// safely cast to the discriminated union and let TypeScript narrow from there. * Returns `data` as a server message, or null when it isn't one. The runtime
* check is minimal: the `type` field must be a string, so the cast to the
* discriminated union is safe and TypeScript narrows from there.
*/
function parseWSMessage(data: string): Wire<ServerToClientMessage> | null { function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
try { try {
const msg = JSON.parse(data); const msg = JSON.parse(data);
@@ -97,7 +135,16 @@ function parseWSMessage(data: string): Wire<ServerToClientMessage> | null {
} }
} }
export function WSProvider({ children }: WSProviderProps) { /**
* Provides {@link WSContext}, {@link WSChatContext}, {@link WSPresenceContext}
* and {@link AvatarOverrideContext}. It connects with the session's token and
* reconnects with a growing backoff; each reconnect bumps `connectionEpoch` so
* pages backfill what they missed. Votes, likes and chat messages apply at once
* and are confirmed or rolled back by the server's answer. A new notification
* or chat message chimes, and the unread count goes in the tab title. A
* `force_logout` signs the user out; a change of session clears the live state.
*/
export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const { token, user, logout } = useAuth(); const { token, user, logout } = useAuth();
const userId = user?.id ?? null; const userId = user?.id ?? null;
const [wsStatus, setWSStatus] = useState< const [wsStatus, setWSStatus] = useState<

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More