From 364c498a2445ef29949ac33d5c1e07ec18dce7d3 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 16:05:29 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20dependencies=20upgraded=20and=20deno=20au?= =?UTF-8?q?dit=20clean,=20every=20API=20response=20typed=20end=20to=20end,?= =?UTF-8?q?=20no=20explicit=20any=20left=20=E2=80=94=20and=20the=20site=20?= =?UTF-8?q?search=20stops=20handing=20out=20password=20hashes=20=E2=80=94?= =?UTF-8?q?=20`deno=20audit`=20reported=2019=20advisories=20(11=20high);?= =?UTF-8?q?=20none=20remain.=20nodemailer=209=20=E2=86=92=2010.0.10=20clos?= =?UTF-8?q?es=20four=20of=20them=20(a=20quadratic=20address=20parser,=20tw?= =?UTF-8?q?o=20recipient-domain=20allow-list=20bypasses,=20resolveContent?= =?UTF-8?q?=20ignoring=20disableFileAccess/disableUrlAccess);=2010's=20one?= =?UTF-8?q?=20break=20is=20Node=20=E2=89=A5=2020,=20and=2010.0.11=20is=20a?= =?UTF-8?q?=20day=20too=20young=20for=20the=20minimum=20release=20age.=20r?= =?UTF-8?q?eact-router=208.0.1=20=E2=86=92=208.4.0=20closes=20the=20RSC-mo?= =?UTF-8?q?de=20CSRF=20bypass.=20Refreshing=20the=20lockfile=20clears=20th?= =?UTF-8?q?e=20transitive=20ones=20that=20came=20in=20through=20Vite,=20ES?= =?UTF-8?q?Lint=20and=20Lingui:=20postcss,=20nanoid,=20picomatch,=20browse?= =?UTF-8?q?rslist,=20brace-expansion=20and=20baseline-browser-mapping.=20E?= =?UTF-8?q?verything=20else=20moves=20to=20its=20latest=20too=20=E2=80=94?= =?UTF-8?q?=20Lingui=206.8=20(its=20SWC=20plugin=206.7),=20React=2019.3,?= =?UTF-8?q?=20Vite=208.3.1,=20react-hook-form=207.89,=20marked,=20jose,=20?= =?UTF-8?q?ESLint=2010.11,=20typescript-eslint=208.70,=20the=20@types=20?= =?UTF-8?q?=E2=80=94=20and=20frimousse=200.3=20=E2=86=92=200.4,=20whose=20?= =?UTF-8?q?declarations=20only=20add=20(custom=20emoji=20data=20resolvers,?= =?UTF-8?q?=20a=20cache).=20TypeScript=20stays=20on=206.0:=20typescript-es?= =?UTF-8?q?lint=20supports=20<=206.1,=20so=207.0=20would=20stop=20the=20li?= =?UTF-8?q?nt.=20allowScripts=20names=20@swc/core=201.16.2=20and=20drops?= =?UTF-8?q?=20esbuild,=20which=20Vite=208=20no=20longer=20installs.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested. Typed routes. Every JSON response is declared as APIResponse, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`. Socket protocol: routes/ws.ts had its own send() taking Record, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field. No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request() and get/post/put/patch/del() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape. No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature. Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated. Co-Authored-By: Claude Opus 5.5 --- api/lib/public-address.ts | 9 +- api/lib/site-icons.ts | 19 +- api/model/db.ts | 3 +- api/model/interfaces.ts | 77 +- api/routes/attachments.ts | 13 +- api/routes/avatars.ts | 21 +- api/routes/bandcamp.ts | 9 +- api/routes/categories.ts | 4 +- api/routes/comments.ts | 12 +- api/routes/dumps.ts | 13 +- api/routes/invites.ts | 17 +- api/routes/notifications.ts | 11 +- api/routes/playlists.ts | 40 +- api/routes/preview.ts | 15 +- api/routes/search.ts | 7 +- api/routes/users.ts | 188 ++-- api/routes/ws.ts | 4 +- api/routes/youtube.ts | 13 +- api/services/category-service.ts | 4 +- api/services/password-reset-service.ts | 4 +- api/services/user-service.ts | 21 +- api/services/ws-service.ts | 6 +- api/services/youtube-playlist-service.ts | 50 +- api/test/auth.test.ts | 43 +- api/test/categories-search.test.ts | 76 +- api/test/chat.test.ts | 6 +- api/test/comments.test.ts | 50 +- api/test/dumps.test.ts | 208 +++-- api/test/helpers.ts | 75 +- api/test/players.test.ts | 17 +- api/test/playlists.test.ts | 76 +- api/test/social.test.ts | 96 +- api/test/uploads.test.ts | 27 +- api/test/users.test.ts | 53 +- api/test/ws.test.ts | 62 +- deno.json | 8 +- deno.lock | 877 ++++++++----------- package.json | 42 +- src/App.css | 100 ++- src/App.tsx | 5 +- src/components/AppHeader.tsx | 9 +- src/components/CategoryManager.tsx | Bin 6981 -> 7010 bytes src/components/ChangePasswordModal.tsx | 6 +- src/components/DumpCreateModal.tsx | 18 +- src/components/DumpFab.tsx | 6 +- src/components/FeedTabBar.tsx | 13 +- src/components/JournalCard.tsx | 14 +- src/components/Markdown.tsx | 4 +- src/components/MediaPlayer.tsx | 3 +- src/components/Thumbnail.tsx | 10 +- src/components/ThumbnailPlaceholder.tsx | 5 +- src/components/form/FormError.tsx | 4 +- src/components/form/useApiForm.ts | 3 +- src/contexts/CategoriesProvider.tsx | 8 +- src/contexts/FollowContext.ts | 9 +- src/contexts/PlayerContext.ts | 9 +- src/contexts/WSContext.ts | 7 +- src/contexts/WSProvider.tsx | 141 +-- src/fonts.css | 54 +- src/hooks/useAnchoredFloat.ts | 16 +- src/hooks/useAuth.ts | 10 +- src/hooks/useDefaultFeedTab.ts | 6 +- src/hooks/useDocumentTitle.ts | 4 +- src/hooks/useMentionAutocomplete.ts | 22 +- src/hooks/useUserDumpFeed.ts | 4 +- src/hooks/useYouTubePlaylist.ts | 11 +- src/locales/en.po | 330 +++---- src/locales/fr.po | 330 +++---- src/pages/DumpEdit.tsx | 4 +- src/pages/Index.tsx | 22 +- src/pages/PlaylistDetail.tsx | 12 +- src/pages/UserRegister.tsx | 8 +- src/pages/index/JournalFeed.tsx | 6 +- src/test/components/ChatModal.test.tsx | 271 ++++-- src/test/components/CommentThread.test.tsx | 112 ++- src/test/components/DumpCreateModal.test.tsx | 185 +++- src/test/components/Modal.test.tsx | 23 +- src/test/components/TextEditor.test.tsx | 105 ++- src/test/components/buttons.test.tsx | 181 +++- src/test/components/content.test.tsx | 187 +++- src/test/components/header.test.tsx | 132 ++- src/test/components/media.test.tsx | 122 ++- src/test/components/player.test.tsx | 198 ++++- src/test/contexts/PlayerProvider.test.tsx | 41 +- src/test/contexts/WSProvider.test.tsx | 249 +++++- src/test/contexts/providers.test.tsx | 22 +- src/test/emojibase.ts | 20 +- src/test/hooks/data.test.tsx | 136 ++- src/test/hooks/editor.test.tsx | 31 +- src/test/hooks/misc.test.tsx | 117 ++- src/test/hooks/sync.test.tsx | 91 +- src/test/hooks/useAuth.test.tsx | 32 +- src/test/pages/Dump.test.tsx | 296 +++++-- src/test/pages/Index.test.tsx | 118 ++- src/test/pages/Notifications.test.tsx | 198 ++++- src/test/pages/PlaylistDetail.test.tsx | 237 ++++- src/test/pages/Search.test.tsx | 86 +- src/test/pages/UserPlaylists.test.tsx | 98 ++- src/test/pages/UserPublicProfile.test.tsx | 386 ++++++-- src/test/pages/UserSubpages.test.tsx | 90 +- src/test/pages/auth.test.tsx | 184 +++- src/test/render.tsx | 45 +- src/test/setup.ts | 88 +- src/test/utils/journal.test.ts | 22 +- src/test/utils/misc.test.ts | 8 +- src/test/utils/remote.test.ts | 67 +- src/test/utils/sound.test.ts | 13 +- src/test/utils/storage.test.ts | 11 +- src/test/ws.ts | 4 +- src/themes/brutalist.css | 29 +- src/themes/geocities.css | 23 +- src/themes/kanagawa.css | 5 +- src/themes/nyt.css | 5 +- src/utils/dumpDraft.ts | 4 +- src/utils/journalLayout.ts | 3 +- src/utils/proxyImage.ts | 5 +- 116 files changed, 5266 insertions(+), 2503 deletions(-) diff --git a/api/lib/public-address.ts b/api/lib/public-address.ts index 768c912d..51013135 100644 --- a/api/lib/public-address.ts +++ b/api/lib/public-address.ts @@ -45,7 +45,11 @@ function parseIPv6(s: string): number[] | null { const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : []; const fill = 8 - head.length - rest.length; if (halves.length === 1 ? fill !== 0 : fill < 1) return null; - const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest]; + const groups = [ + ...head, + ...Array(halves.length === 2 ? fill : 0).fill("0"), + ...rest, + ]; if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null; return groups.flatMap((g) => { const n = parseInt(g, 16); @@ -62,7 +66,8 @@ function isPrivateIPv6(b: number[]): boolean { if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d return true; } - if ( // 64:ff9b::/96 NAT64 + if ( + // 64:ff9b::/96 NAT64 b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0) ) return isPrivateIPv4(b.slice(12)); diff --git a/api/lib/site-icons.ts b/api/lib/site-icons.ts index c4ffec8a..b7a94de6 100644 --- a/api/lib/site-icons.ts +++ b/api/lib/site-icons.ts @@ -27,7 +27,9 @@ export function emojiToCodepoint(emoji: string): string { let wasmReady: Promise | undefined; function ensureWasm(): Promise { if (!wasmReady) { - const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm")); + const wasmUrl = new URL( + import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"), + ); wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes)); } return wasmReady; @@ -62,7 +64,10 @@ async function fetchGlyph(emoji: string): Promise { } function renderPng(svg: string, size: number, background?: string): Uint8Array { - const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background }); + const resvg = new Resvg(svg, { + fitTo: { mode: "width", value: size }, + background, + }); return resvg.render().asPng(); } @@ -152,10 +157,16 @@ export async function generateSiteIcons( await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!); } // Apple touch icon gets a solid background — iOS doesn't honor transparency. - await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor)); + await Deno.writeFile( + out("apple-touch-icon.png"), + renderPng(svg, APPLE_SIZE, bgColor), + ); await Deno.writeFile( out("favicon.ico"), buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))), ); - await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor)); + await Deno.writeTextFile( + out("manifest.webmanifest"), + buildManifest(siteName, bgColor), + ); } diff --git a/api/model/db.ts b/api/model/db.ts index 3508d094..06eab58f 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -7,6 +7,7 @@ import { Dump, isRole, type Notification, + type NotificationData, type NotificationType, type Playlist, type RichContent, @@ -494,7 +495,7 @@ export function notificationRowToApi(row: NotificationRow): Notification { id: row.id, userId: row.user_id, type: row.type as NotificationType, - data: JSON.parse(row.data), + data: JSON.parse(row.data) as NotificationData, read: Boolean(row.read), createdAt: new Date(row.created_at), }; diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index a6857c20..0074f29c 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -118,6 +118,38 @@ export interface User { email: string; } +/** A user as anyone may see them: no password hash, no email. */ +export type PublicUser = Omit; + +/** A user as they see themselves: everything but the password hash. */ +export type SelfUser = Omit; + +export function toPublicUser( + { passwordHash: _, email: _email, ...user }: User, +): PublicUser { + return user; +} + +export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { + return user; +} + +/** A match for the @mention autocomplete. */ +export interface UserSuggestion { + id: string; + username: string; + avatarMime: string | null; +} + +/** Someone a user invited, directly or down the line. */ +export interface Invitee { + id: string; + username: string; + avatarMime?: string; + invitedById: string; + createdAt: Date; +} + export interface LoginUserRequest { username: string; password: string; @@ -144,6 +176,13 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { "password" in obj && typeof obj.password === "string"; } +/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */ +export function fieldOf(obj: unknown, key: string): unknown { + return obj !== null && typeof obj === "object" && key in obj + ? (obj as Record)[key] + : undefined; +} + export function isRegisterUserRequest( obj: unknown, ): obj is RegisterUserRequest { @@ -210,9 +249,24 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { return true; } -export interface AuthResponse { +/** The session token, plus the media-only one for file and thumbnail URLs. */ +export interface AuthTokens { token: string; - user: User; + mediaToken: string; +} + +export interface AuthResponse extends AuthTokens { + user: SelfUser; +} + +export type MediaTokenResponse = Pick; + +export interface InviteResponse { + token: string; +} + +export interface AttachmentResponse { + id: string; } export interface AuthPayload { @@ -288,6 +342,22 @@ export interface PaginatedData { hasMore: boolean; } +export interface SearchResults { + dumps: PaginatedData; + users: PublicUser[]; + playlists: Playlist[]; +} + +/** + * `reached` is separate because a failed fetch still yields a usable stub + * (hostname only): without it the create form can't tell "this page has no + * preview" from "this link is dead". + */ +export interface LinkPreview { + reached: boolean; + richContent: RichContent | null; +} + export class APIException extends Error { readonly code: APIErrorCode; readonly status: number; @@ -747,7 +817,7 @@ export interface PlaylistDumpsUpdatedMessage { export interface UserUpdatedMessage { type: "user_updated"; - user: Omit; + user: PublicUser; } export interface CommentCreatedMessage { @@ -822,6 +892,7 @@ export interface ChatReadUpdateMessage { export type ServerToClientMessage = | PingMessage + | PongMessage | WelcomeMessage | PresenceUpdateMessage | VotesUpdateMessage diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index 45655b20..1040ec26 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -4,7 +4,12 @@ import { createAttachment, getAttachment, } from "../services/attachment-service.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { + APIErrorCode, + APIException, + type APIResponse, + type AttachmentResponse, +} from "../model/interfaces.ts"; import { ATTACHMENTS_DIR, serveUploadedFile, @@ -43,8 +48,12 @@ router.post("/api/attachments", authMiddleware, async (ctx) => { throw err; } + const responseBody: APIResponse = { + success: true, + data: { id: attachmentId }, + }; ctx.response.status = 201; - ctx.response.body = { success: true, data: { id: attachmentId } }; + ctx.response.body = responseBody; }); router.get("/api/attachments/:attachmentId", async (ctx) => { diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 0fd16367..0331b06e 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -5,7 +5,14 @@ import { broadcastUserUpdated, updateClientAvatar, } from "../services/ws-service.ts"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { + APIErrorCode, + APIException, + type APIResponse, + type SelfUser, + toPublicUser, + toSelfUser, +} from "../model/interfaces.ts"; import { AVATARS_DIR, serveUploadedFile, @@ -47,14 +54,16 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { } updateClientAvatar(authPayload.userId, mime); - const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId); + const user = getUserById(authPayload.userId); // Let every client refresh this user's avatar wherever it's shown (feeds, // comments, OP, profiles) — not just the presence row. updated_at, bumped on // the avatar write, doubles as the cache-busting version. - const { email: _email, ...broadcastUser } = publicUser; - broadcastUserUpdated(broadcastUser); - ctx.response.status = 200; - ctx.response.body = { success: true, data: publicUser }; + broadcastUserUpdated(toPublicUser(user)); + const responseBody: APIResponse = { + success: true, + data: toSelfUser(user), + }; + ctx.response.body = responseBody; }); router.get("/api/avatars/:userId", async (ctx) => { diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts index 0fb59705..47a08454 100644 --- a/api/routes/bandcamp.ts +++ b/api/routes/bandcamp.ts @@ -1,5 +1,7 @@ import { Router } from "@oak/oak"; +import type { APIResponse } from "../model/interfaces.ts"; import { resolveBandcamp } from "../services/bandcamp-stream-service.ts"; +import type { Tralbum } from "../services/bandcamp-tralbum.ts"; const bandcampRouter = new Router(); @@ -15,12 +17,15 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; const force = ctx.request.url.searchParams.get("force") === "1"; - const album = await resolveBandcamp(url, { force }); + const responseBody: APIResponse = { + success: true, + data: await resolveBandcamp(url, { force }), + }; // Private: the signed URLs in the body are per-fetch, so they must not be // held in a shared cache. Well inside the 24h signature life either way. ctx.response.headers.set("Cache-Control", "private, max-age=3600"); - ctx.response.body = { success: true, data: album }; + ctx.response.body = responseBody; }); export default bandcampRouter; diff --git a/api/routes/categories.ts b/api/routes/categories.ts index bf6b1b94..c26b5a96 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -63,7 +63,7 @@ router.post( authMiddleware, requirePermission("category:manage"), async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isCreateCategoryRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -86,7 +86,7 @@ router.put( authMiddleware, requirePermission("category:manage"), async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isUpdateCategoryRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 88a3311e..bb0d6846 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -6,6 +6,9 @@ import { type Comment, isCreateCommentRequest, isUpdateCommentRequest, + type PaginatedData, + type PublicUser, + toPublicUser, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; @@ -44,7 +47,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => { router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; const dump = getDump(ctx.params.dumpId, userId); - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isCreateCommentRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -68,7 +71,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { router.patch("/comments/:commentId", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; const canModerate = can(ctx.state.user, "comment:moderate"); - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isUpdateCommentRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -98,14 +101,15 @@ router.get("/comments/:commentId/likers", async (ctx) => { page, limit, ); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, data: { - items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), + items: items.map(toPublicUser), total, hasMore: page * limit < total, }, }; + ctx.response.body = responseBody; }); // DELETE /api/comments/:commentId — auth required diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index cf835131..bf65e49b 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -10,6 +10,8 @@ import { isUpdateDumpRequest, type PaginatedData, type Playlist, + type PublicUser, + toPublicUser, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; @@ -45,7 +47,7 @@ const router = new Router({ prefix: "/api/dumps" }); function parseCategoryIds(value: FormDataEntryValue | null): string[] { if (typeof value !== "string" || !value) return []; try { - const parsed = JSON.parse(value); + const parsed: unknown = JSON.parse(value); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; @@ -87,7 +89,7 @@ router.post( parseCategoryIds(formData.get("categoryIds")), ); } else { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isCreateUrlDumpRequest(body)) { throw new APIException( @@ -129,14 +131,15 @@ router.get("/:dumpId/voters", async (ctx) => { const dump = getDump(ctx.params.dumpId, requestingUserId); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getDumpVoters(dump.id, page, limit); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, data: { - items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), + items: items.map(toPublicUser), total, hasMore: page * limit < total, }, }; + ctx.response.body = responseBody; }); router.get("/:dumpId/related", async (ctx) => { @@ -215,7 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { router.put("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user.userId; - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isUpdateDumpRequest(body)) { throw new APIException( diff --git a/api/routes/invites.ts b/api/routes/invites.ts index 9077ae93..fb1cc32b 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -1,5 +1,10 @@ import { Router } from "@oak/oak"; -import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { + APIErrorCode, + APIException, + type APIResponse, + type InviteResponse, +} from "../model/interfaces.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts"; @@ -10,16 +15,20 @@ router.post("/", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); } - const token = createInvite(ctx.state.user.userId); + const responseBody: APIResponse = { + success: true, + data: { token: createInvite(ctx.state.user.userId) }, + }; ctx.response.status = 201; - ctx.response.body = { success: true, data: { token } }; + ctx.response.body = responseBody; }); // Validate an invite token (used by the register page before showing the form) router.get("/:token", (ctx) => { try { validateInvite(ctx.params.token); - ctx.response.body = { success: true, data: null }; + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; } catch { throw new APIException( APIErrorCode.NOT_FOUND, diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 8b7de9bc..30b0f36c 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -2,6 +2,8 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException, + type APIResponse, + type Notification, type PaginatedData, } from "../model/interfaces.ts"; import { parsePagination } from "../lib/pagination.ts"; @@ -25,14 +27,11 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { page, limit, ); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, - data: { - items, - total, - hasMore: page * limit < total, - } satisfies PaginatedData, + data: { items, total, hasMore: page * limit < total }, }; + ctx.response.body = responseBody; }); // POST /api/notifications/read-all diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index a199506a..8ea1705b 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -3,9 +3,13 @@ import { parseOptionalAuth } from "../lib/auth.ts"; import { APIErrorCode, APIException, + type APIResponse, isCreatePlaylistRequest, isReorderPlaylistRequest, isUpdatePlaylistRequest, + type Playlist, + type PlaylistMembership, + type PlaylistWithDumps, } from "../model/interfaces.ts"; import { authMiddleware, type AuthState } from "../middleware/auth.ts"; import { can } from "../lib/permissions.ts"; @@ -34,13 +38,16 @@ const router = new Router({ prefix: "/api/playlists" }); router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { const { dumpId } = ctx.params; const userId = ctx.state.user.userId; - const memberships = getPlaylistMembershipsForDump(dumpId, userId); - ctx.response.body = { success: true, data: memberships }; + const responseBody: APIResponse = { + success: true, + data: getPlaylistMembershipsForDump(dumpId, userId), + }; + ctx.response.body = responseBody; }); // POST /api/playlists — create router.post("/", authMiddleware, async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -48,21 +55,27 @@ router.post("/", authMiddleware, async (ctx) => { "Invalid request", ); } - const playlist = createPlaylist(body, ctx.state.user.userId); + const responseBody: APIResponse = { + success: true, + data: createPlaylist(body, ctx.state.user.userId), + }; ctx.response.status = 201; - ctx.response.body = { success: true, data: playlist }; + ctx.response.body = responseBody; }); // GET /api/playlists/:playlistId — optional auth router.get("/:playlistId", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); - const playlist = getPlaylist(ctx.params.playlistId, requestingUserId); - ctx.response.body = { success: true, data: playlist }; + const responseBody: APIResponse = { + success: true, + data: getPlaylist(ctx.params.playlistId, requestingUserId), + }; + ctx.response.body = responseBody; }); // PUT /api/playlists/:playlistId — update metadata router.put("/:playlistId", authMiddleware, async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isUpdatePlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -76,7 +89,8 @@ router.put("/:playlistId", authMiddleware, async (ctx) => { ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); - ctx.response.body = { success: true, data: playlist }; + const responseBody: APIResponse = { success: true, data: playlist }; + ctx.response.body = responseBody; }); // DELETE /api/playlists/:playlistId @@ -158,7 +172,8 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); - ctx.response.body = { success: true, data: playlist }; + const responseBody: APIResponse = { success: true, data: playlist }; + ctx.response.body = responseBody; }); // GET /api/playlists/:playlistId/image — serve playlist image @@ -177,7 +192,7 @@ router.get("/:playlistId/image", async (ctx) => { // PUT /api/playlists/:playlistId/order — reorder router.put("/:playlistId/order", authMiddleware, async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isReorderPlaylistRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -191,7 +206,8 @@ router.put("/:playlistId/order", authMiddleware, async (ctx) => { ctx.state.user.userId, can(ctx.state.user, "playlist:moderate"), ); - ctx.response.body = { success: true, data: null }; + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; }); export default router; diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 46a584b8..30232545 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -4,7 +4,12 @@ import { isValidHttpUrl, tryFetchRichContent, } from "../services/rich-content-service.ts"; -import { APIErrorCode } from "../model/interfaces.ts"; +import { + APIErrorCode, + type APIFailure, + type APIResponse, + type LinkPreview, +} from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; const previewRouter = new Router(); @@ -25,11 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { - ctx.response.status = 400; - ctx.response.body = { + const responseBody: APIFailure = { success: false, error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" }, }; + ctx.response.status = 400; + ctx.response.body = responseBody; return; } // `reached` is reported separately because a failed fetch still yields a @@ -37,10 +43,11 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { // "this page has no preview" from "this link is dead", and shows the same // bare card for both. const { ok, content } = await tryFetchRichContent(url); - ctx.response.body = { + const responseBody: APIResponse = { success: true, data: { reached: ok, richContent: content ?? null }, }; + ctx.response.body = responseBody; }); /** diff --git a/api/routes/search.ts b/api/routes/search.ts index 280be50c..d85199a9 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -4,6 +4,7 @@ import { parsePagination } from "../lib/pagination.ts"; import { searchDumps } from "../services/dump-service.ts"; import { searchUsersGlobal } from "../services/user-service.ts"; import { searchPlaylists } from "../services/playlist-service.ts"; +import type { APIResponse, SearchResults } from "../model/interfaces.ts"; const router = new Router({ prefix: "/api/search" }); @@ -14,7 +15,7 @@ router.get("/", async (ctx) => { const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined; if (!q) { - ctx.response.body = { + const responseBody: APIResponse = { success: true, data: { dumps: { items: [], total: 0, hasMore: false }, @@ -22,6 +23,7 @@ router.get("/", async (ctx) => { playlists: [], }, }; + ctx.response.body = responseBody; return; } @@ -34,7 +36,7 @@ router.get("/", async (ctx) => { const users = searchUsersGlobal(q, 20); const playlists = searchPlaylists(q, 20, requestingUserId); - ctx.response.body = { + const responseBody: APIResponse = { success: true, data: { dumps: { @@ -46,6 +48,7 @@ router.get("/", async (ctx) => { playlists, }, }; + ctx.response.body = responseBody; }); export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts index be360ebc..81d839c8 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -3,11 +3,25 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException, + type APIResponse, + type AuthResponse, + type AuthTokens, + type Dump, + fieldOf, + type Invitee, isLoginUserRequest, + isRegisterUserRequest, isRole, isUpdateUserRequest, + type MediaTokenResponse, type PaginatedData, + type Playlist, + type PublicUser, + type SelfUser, + toPublicUser, + toSelfUser, type User, + type UserSuggestion, validateRegisterUserRequest, } from "../model/interfaces.ts"; @@ -60,7 +74,7 @@ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; // The session token, plus the media-only token that goes in file and // thumbnail URLs (see createMediaToken). -async function issueTokens(user: User) { +async function issueTokens(user: User): Promise { return { token: await createJWT({ userId: user.id, @@ -73,11 +87,14 @@ async function issueTokens(user: User) { // Register a new user (requires a valid invite token) router.post("/register", async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); - const registerError = validateRegisterUserRequest(body); - if (registerError) { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError); + if (!isRegisterUserRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + validateRegisterUserRequest(body) ?? "Invalid request", + ); } // Claim the invite up front (throws 404/409 if bad) and hand it back if the @@ -106,17 +123,17 @@ router.post("/register", async (ctx) => { }).catch((err) => console.error("[register] welcome email failed:", err)); } - const { passwordHash: _, ...publicUser } = user; - ctx.response.status = 201; - ctx.response.body = { + const responseBody: APIResponse = { success: true, - data: { ...await issueTokens(user), user: publicUser }, + data: { ...await issueTokens(user), user: toSelfUser(user) }, }; + ctx.response.status = 201; + ctx.response.body = responseBody; }); // Login router.post("/login", async (ctx) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException( @@ -157,11 +174,11 @@ router.post("/login", async (ctx) => { ); } - const { passwordHash: _, ...publicUser } = user; - ctx.response.body = { + const responseBody: APIResponse = { success: true, - data: { ...await issueTokens(user), user: publicUser }, + data: { ...await issueTokens(user), user: toSelfUser(user) }, }; + ctx.response.body = responseBody; }); // Get current user profile @@ -175,14 +192,11 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { ); } - const { passwordHash: _, ...publicUser } = getUserById( - ctx.state.user.userId, - ); - - ctx.response.body = { + const responseBody: APIResponse = { success: true, - data: publicUser, + data: toSelfUser(getUserById(ctx.state.user.userId)), }; + ctx.response.body = responseBody; } catch (err) { console.error(err); @@ -196,28 +210,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { // A media token for a session that predates them (see createMediaToken). router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { - ctx.response.body = { + const responseBody: APIResponse = { success: true, data: { mediaToken: await createMediaToken(ctx.state.user.userId) }, }; + ctx.response.body = responseBody; }); // Request a password reset email (unauthenticated; always returns 200) router.post("/request-password-reset", async (ctx) => { - const body = await ctx.request.body.json(); - const email = typeof body?.email === "string" ? body.email.trim() : ""; - if (email) { - await requestPasswordReset(email).catch((err) => + const body: unknown = await ctx.request.body.json(); + const email = fieldOf(body, "email"); + const trimmed = typeof email === "string" ? email.trim() : ""; + if (trimmed) { + await requestPasswordReset(trimmed).catch((err) => console.error("[request-password-reset]", err) ); } - ctx.response.body = { success: true }; + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; }); // Consume a reset token and set a new password (unauthenticated) router.post("/reset-password", async (ctx) => { - const body = await ctx.request.body.json(); - const { token, newPassword } = (body ?? {}) as Record; + const body: unknown = await ctx.request.body.json(); + const token = fieldOf(body, "token"); + const newPassword = fieldOf(body, "newPassword"); if (typeof token !== "string" || typeof newPassword !== "string") { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -226,16 +244,15 @@ router.post("/reset-password", async (ctx) => { ); } await resetPassword(token, newPassword); - ctx.response.body = { success: true }; + const responseBody: APIResponse = { success: true, data: null }; + ctx.response.body = responseBody; }); // Change current user's password (requires current password verification) router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { - const body = await ctx.request.body.json(); - const { currentPassword, newPassword } = (body ?? {}) as Record< - string, - unknown - >; + const body: unknown = await ctx.request.body.json(); + const currentPassword = fieldOf(body, "currentPassword"); + const newPassword = fieldOf(body, "newPassword"); if (typeof currentPassword !== "string" || typeof newPassword !== "string") { throw new APIException( @@ -271,12 +288,16 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { const updated = await updateUser(ctx.state.user.userId, { password: newPassword, }); - ctx.response.body = { success: true, data: await issueTokens(updated) }; + const responseBody: APIResponse = { + success: true, + data: await issueTokens(updated), + }; + ctx.response.body = responseBody; }); // Update current user profile (description, etc.) router.patch("/me", authMiddleware, async (ctx: AuthContext) => { - const body = await ctx.request.body.json(); + const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -292,9 +313,13 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { description: body.description, email: body.email, }); - const { passwordHash: _, email: _email, ...publicUser } = updated; + const publicUser = toPublicUser(updated); broadcastUserUpdated(publicUser); - ctx.response.body = { success: true, data: publicUser }; + const responseBody: APIResponse = { + success: true, + data: publicUser, + }; + ctx.response.body = responseBody; }); // Set another user's role. Requires the user:manage permission. @@ -303,10 +328,14 @@ router.patch( authMiddleware, requirePermission("user:manage"), async (ctx: AuthContext<"/:userId/role">) => { - const body = await ctx.request.body.json(); - const role: unknown = body?.role; + const body: unknown = await ctx.request.body.json(); + const role = fieldOf(body, "role"); if (!isRole(role)) { - throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role"); + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid role", + ); } // Disallow changing your own role: prevents an admin from accidentally // locking themselves out, and self-promotion is meaningless anyway. @@ -318,24 +347,33 @@ router.patch( ); } const updated = await updateUser(ctx.params.userId, { role }); - const { passwordHash: _, email: _email, ...publicUser } = updated; + const publicUser = toPublicUser(updated); broadcastUserUpdated(publicUser); - ctx.response.body = { success: true, data: publicUser }; + const responseBody: APIResponse = { + success: true, + data: publicUser, + }; + ctx.response.body = responseBody; }, ); // User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); - const results = searchUsers(q, 8); - ctx.response.body = { success: true, data: results }; + const responseBody: APIResponse = { + success: true, + data: searchUsers(q, 8), + }; + ctx.response.body = responseBody; }); // Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { - const user = getUserById(ctx.params.userId); - const { passwordHash: _, email: _email, ...publicUser } = user; - ctx.response.body = { success: true, data: publicUser }; + const responseBody: APIResponse = { + success: true, + data: toPublicUser(getUserById(ctx.params.userId)), + }; + ctx.response.body = responseBody; }); // Followed users for a user (public) @@ -343,14 +381,15 @@ router.get("/:username/followed-users", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getFollowedUsersByUser(user.id, page, limit); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, data: { - items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub), + items: items.map(toPublicUser), total, hasMore: page * limit < total, }, }; + ctx.response.body = responseBody; }); // Followed playlists for a user (public only) @@ -358,14 +397,11 @@ router.get("/:username/followed-playlists", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, - data: { - items, - total, - hasMore: page * limit < total, - } satisfies PaginatedData, + data: { items, total, hasMore: page * limit < total }, }; + ctx.response.body = responseBody; }); // Playlists by user (optional auth: include private only if requester === owner) @@ -379,28 +415,30 @@ router.get("/:username/playlists", async (ctx) => { page, limit, ); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, - data: { - items, - total, - hasMore: page * limit < total, - } satisfies PaginatedData, + data: { items, total, hasMore: page * limit < total }, }; + ctx.response.body = responseBody; }); // Invite tree for a user (public) router.get("/:username/invitees", (ctx) => { const user = getUserByUsername(ctx.params.username); - const tree = getInviteTree(user.id); - ctx.response.body = { success: true, data: tree }; + const responseBody: APIResponse = { + success: true, + data: getInviteTree(user.id), + }; + ctx.response.body = responseBody; }); // Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { - const user = getUserByUsername(ctx.params.username); - const { passwordHash: _, email: _email, ...publicUser } = user; - ctx.response.body = { success: true, data: publicUser }; + const responseBody: APIResponse = { + success: true, + data: toPublicUser(getUserByUsername(ctx.params.username)), + }; + ctx.response.body = responseBody; }); // Dumps posted by user (optional auth: owner sees their private dumps) @@ -410,14 +448,11 @@ router.get("/:username/dumps", async (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); const includePrivate = requestingUserId === user.id; const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, - data: { - items, - total, - hasMore: page * limit < total, - } satisfies PaginatedData, + data: { items, total, hasMore: page * limit < total }, }; + ctx.response.body = responseBody; }); // Dumps upvoted by user (optional auth: hide private dump entries for non-owners) @@ -431,14 +466,11 @@ router.get("/:username/votes", async (ctx) => { limit, requestingUserId, ); - ctx.response.body = { + const responseBody: APIResponse> = { success: true, - data: { - items, - total, - hasMore: page * limit < total, - } satisfies PaginatedData, + data: { items, total, hasMore: page * limit < total }, }; + ctx.response.body = responseBody; }); export default router; diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 1951f058..68a4c2d2 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts"; import { APIException, type ClientToServerMessage, + type ServerToClientMessage, type User, } from "../model/interfaces.ts"; @@ -193,7 +194,7 @@ function handleMessage(client: WsClient, data: unknown): void { } // Sends only while the socket is open: `send` on a closing socket throws. -function send(socket: WebSocket, data: Record): void { +function send(socket: WebSocket, data: ServerToClientMessage): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data)); } @@ -219,7 +220,6 @@ function handleVote( type: "vote_ack", dumpId, action, - success: true, voteCount, }); diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts index e2712df6..cecd82b7 100644 --- a/api/routes/youtube.ts +++ b/api/routes/youtube.ts @@ -1,5 +1,9 @@ import { Router } from "@oak/oak"; -import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts"; +import type { APIResponse } from "../model/interfaces.ts"; +import { + resolveYouTubePlaylist, + type YouTubePlaylist, +} from "../services/youtube-playlist-service.ts"; const youtubeRouter = new Router(); @@ -11,10 +15,13 @@ const youtubeRouter = new Router(); */ youtubeRouter.get("/api/youtube/playlist", async (ctx) => { const listId = ctx.request.url.searchParams.get("list") ?? ""; - const playlist = await resolveYouTubePlaylist(listId); + const responseBody: APIResponse = { + success: true, + data: await resolveYouTubePlaylist(listId), + }; ctx.response.headers.set("Cache-Control", "public, max-age=3600"); - ctx.response.body = { success: true, data: playlist }; + ctx.response.body = responseBody; }); export default youtubeRouter; diff --git a/api/services/category-service.ts b/api/services/category-service.ts index dbe47274..b639f023 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -147,7 +147,9 @@ export function updateCategory( if (req.slug !== undefined && req.slug !== existing.slug) { validateSlug(req.slug); } - const newName = req.name !== undefined ? requireName(req.name) : existing.name; + const newName = req.name !== undefined + ? requireName(req.name) + : existing.name; const newPosition = req.position ?? existing.position; const now = new Date(); diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index c6c86157..b8eb2d7f 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -115,7 +115,9 @@ export async function resetPassword( try { await updateUser(payload.userId, { password: newPassword }); } catch (err) { - db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`) + db.prepare( + `UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`, + ) .run(token); throw err; } diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 00a157b9..b86a009b 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -1,9 +1,13 @@ import { APIErrorCode, APIException, + type Invitee, + type PublicUser, type RegisterUserRequest, + toPublicUser, type UpdateUserRequest, type User, + type UserSuggestion, } from "../model/interfaces.ts"; import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; import { closeUserSockets, disconnectUser } from "./ws-service.ts"; @@ -129,7 +133,7 @@ export function getUserByEmail(email: string): User | null { export function searchUsers( query: string, limit: number, -): { id: string; username: string; avatarMime: string | null }[] { +): UserSuggestion[] { if (!query) return []; const rows = db.prepare( `SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`, @@ -145,13 +149,14 @@ export function searchUsers( })); } -export function searchUsersGlobal(query: string, limit: number): User[] { +// For the public search page: profiles only, never hashes or emails. +export function searchUsersGlobal(query: string, limit: number): PublicUser[] { if (!query.trim()) return []; const pattern = `%${query}%`; const rows = db.prepare( `${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`, ).all(pattern, pattern, limit); - return rows.filter(isUserRow).map(userRowToApi); + return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); } export function listUsers(): User[] { @@ -231,15 +236,7 @@ export function updateUserAvatar(userId: string, mime: string): void { } } -export function getInviteTree( - userId: string, -): { - id: string; - username: string; - avatarMime?: string; - invitedById: string; - createdAt: Date; -}[] { +export function getInviteTree(userId: string): Invitee[] { const rows = db.prepare(` WITH RECURSIVE tree AS ( SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 1686aa6b..911869b4 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -4,9 +4,9 @@ import type { Dump, OnlineUser, Playlist, + PublicUser, Role, ServerToClientMessage, - User, } from "../model/interfaces.ts"; import { isMemberVisibleTo, @@ -223,9 +223,7 @@ export function broadcastPlaylistDumpsUpdated( } } -export function broadcastUserUpdated( - user: Omit, -): void { +export function broadcastUserUpdated(user: PublicUser): void { for (const client of clients) { send(client.socket, { type: "user_updated", user }); } diff --git a/api/services/youtube-playlist-service.ts b/api/services/youtube-playlist-service.ts index d02ee600..c03f1a06 100644 --- a/api/services/youtube-playlist-service.ts +++ b/api/services/youtube-playlist-service.ts @@ -72,29 +72,43 @@ function firstString(node: unknown, key: string): string | undefined { return found.find((v): v is string => typeof v === "string"); } -// deno-lint-ignore no-explicit-any -type Json = any; +/** Follows `path` into parsed JSON; undefined wherever a step is missing. */ +function at(node: unknown, ...path: (string | number)[]): unknown { + let cur = node; + for (const key of path) { + if (cur === null || typeof cur !== "object") return undefined; + cur = (cur as Record)[key]; + } + return cur; +} -function fromLockup(lockup: Json): YouTubePlaylistTrack | null { - if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; - const videoId = lockup.contentId; - const title = lockup.metadata?.lockupMetadataViewModel?.title?.content; +function fromLockup(lockup: unknown): YouTubePlaylistTrack | null { + if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; + const videoId = at(lockup, "contentId"); + const title = at( + lockup, + "metadata", + "lockupMetadataViewModel", + "title", + "content", + ); if (typeof videoId !== "string" || typeof title !== "string") return null; const badges: unknown[] = []; - collect(lockup.contentImage, "thumbnailBadgeViewModel", badges); + collect(at(lockup, "contentImage"), "thumbnailBadgeViewModel", badges); const duration = badges - .map((b) => (b as Json)?.text) + .map((b) => at(b, "text")) .filter((t): t is string => typeof t === "string") .map(parseClock) .find((d) => d !== undefined); return { videoId, title, duration }; } -function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null { - const videoId = r?.videoId; - const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText; +function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null { + const videoId = at(r, "videoId"); + const title = at(r, "title", "runs", 0, "text") ?? + at(r, "title", "simpleText"); if (typeof videoId !== "string" || typeof title !== "string") return null; - const secs = Number(r.lengthSeconds); + const secs = Number(at(r, "lengthSeconds")); return { videoId, title, @@ -111,7 +125,7 @@ export function parsePlaylistPage( const end = html.indexOf(";", start); if (end < 0) return null; - let data: Json; + let data: unknown; try { data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end)); } catch { @@ -119,9 +133,9 @@ export function parsePlaylistPage( } const lockups: unknown[] = []; - collect(data?.contents, "lockupViewModel", lockups); + collect(at(data, "contents"), "lockupViewModel", lockups); const legacy: unknown[] = []; - collect(data?.contents, "playlistVideoRenderer", legacy); + collect(at(data, "contents"), "playlistVideoRenderer", legacy); // A video can appear in a playlist twice; both rows are kept, so the list // stays positional and matches what the embed plays. @@ -131,8 +145,10 @@ export function parsePlaylistPage( ].filter((t): t is YouTubePlaylistTrack => t !== null); if (tracks.length === 0) return null; - const title = data?.metadata?.playlistMetadataRenderer?.title ?? - firstString(data?.header, "content"); + const metaTitle = at(data, "metadata", "playlistMetadataRenderer", "title"); + const title = typeof metaTitle === "string" + ? metaTitle + : firstString(at(data, "header"), "content"); return { listId, title, tracks }; } diff --git a/api/test/auth.test.ts b/api/test/auth.test.ts index 0d6f9acf..72eaffb3 100644 --- a/api/test/auth.test.ts +++ b/api/test/auth.test.ts @@ -1,5 +1,10 @@ // Tokens, passwords and the checks that turn a token into a user. import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts"; +import type { + AuthResponse, + AuthTokens, + MediaTokenResponse, +} from "../model/interfaces.ts"; import { assert, assertEquals, @@ -137,7 +142,7 @@ test("optional auth: a missing or placeholder token is anonymous, a bad one is 4 ); const bad = await get(path, { headers: { Authorization: "Bearer nope" } }); assertEquals(bad.status, 401); - assertEquals(bad.body.error.code, "UNAUTHORIZED"); + assertEquals(bad.error.code, "UNAUTHORIZED"); }); test("required auth: missing, malformed and forged tokens are 401", async () => { @@ -160,15 +165,15 @@ test("required auth: missing, malformed and forged tokens are 401", async () => test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { const user = await createUser(); - const ok = await post("/api/users/login", { + const ok = await post("/api/users/login", { json: { username: user.username, password: PASSWORD }, }); assertEquals(ok.status, 200); - assertEquals(ok.body.data.user.id, user.id); - assertEquals(ok.body.data.user.passwordHash, undefined); - assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id); + assertEquals(ok.data.user.id, user.id); + assertFalse("passwordHash" in ok.data.user); + assertEquals((await verifyJWT(ok.data.token))?.userId, user.id); assertEquals( - (await verifyMediaToken(ok.body.data.mediaToken))?.userId, + (await verifyMediaToken(ok.data.mediaToken))?.userId, user.id, ); @@ -180,7 +185,7 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which }); assertEquals(wrong.status, 401); assertEquals(unknown.status, 401); - assertEquals(wrong.body.error.message, unknown.body.error.message); + assertEquals(wrong.error.message, unknown.error.message); assertEquals( (await post("/api/users/login", { json: { username: user.username } })) @@ -222,7 +227,7 @@ function invite(inviterId: string, createdAt = new Date()): string { test("POST /api/users/register: an invite opens one account", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); - const res = await post("/api/users/register", { + const res = await post("/api/users/register", { json: { username: "newbie_reg", password: "longenough", @@ -231,10 +236,10 @@ test("POST /api/users/register: an invite opens one account", async () => { }, }); assertEquals(res.status, 201); - assertEquals(res.body.data.user.username, "newbie_reg"); - assertEquals(res.body.data.user.role, "user"); - assertEquals(res.body.data.user.passwordHash, undefined); - assert(res.body.data.token); + assertEquals(res.data.user.username, "newbie_reg"); + assertEquals(res.data.user.role, "user"); + assertFalse("passwordHash" in res.data.user); + assert(res.data.token); const row = db.prepare( `SELECT invited_by FROM users WHERE username = 'newbie_reg';`, ).get() as { invited_by: string }; @@ -271,7 +276,7 @@ test("POST /api/users/register: validation and unknown invites", async () => { for (const [json, message] of cases) { const res = await post("/api/users/register", { json }); assertEquals(res.status, 400, JSON.stringify(json)); - assert(message.test(res.body.error.message), res.body.error.message); + assert(message.test(res.error.message), res.error.message); } const unknown = await post("/api/users/register", { json: { ...base, inviteToken: "made-up" }, @@ -372,16 +377,16 @@ test("change-password: needs the current one, revokes old tokens, returns new on // iat has one-second resolution: make sure the old token is from an earlier // second than the change. await new Promise((r) => setTimeout(r, 1100)); - const ok = await post("/api/users/me/change-password", { + const ok = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, }); assertEquals(ok.status, 200); assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals(await authenticateMedia(user.mediaToken), null); - const fresh = { Authorization: `Bearer ${ok.body.data.token}` }; + const fresh = { Authorization: `Bearer ${ok.data.token}` }; assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); - assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id); + assertEquals(await authenticateMedia(ok.data.mediaToken), user.id); const login = await post("/api/users/login", { json: { username: user.username, password: "brand-new-pass" }, @@ -452,7 +457,9 @@ test("reset-password: an unstored, forged or wrong-purpose token fails", async ( test("GET /api/users/me/media-token issues a media token for the caller", async () => { const user = await createUser(); - const res = await get("/api/users/me/media-token", { auth: user.auth }); + const res = await get("/api/users/me/media-token", { + auth: user.auth, + }); assertEquals(res.status, 200); - assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id); + assertEquals(await authenticateMedia(res.data.mediaToken), user.id); }); diff --git a/api/test/categories-search.test.ts b/api/test/categories-search.test.ts index f6631876..741ee2ab 100644 --- a/api/test/categories-search.test.ts +++ b/api/test/categories-search.test.ts @@ -10,19 +10,28 @@ import { put, test, } from "./helpers.ts"; -import { assertEquals, assertStringIncludes } from "@std/assert"; +import type { + Category, + Dump, + PaginatedData, + SearchResults, +} from "../model/interfaces.ts"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; test("categories: listed by position, then slug; found by slug", async () => { insertCategory({ slug: "zeta", position: 0 }); insertCategory({ slug: "alpha", position: 1 }); insertCategory({ slug: "beta", position: 0 }); - const list = await get("/api/categories"); - assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [ + const list = await get("/api/categories"); + assertEquals(list.data.map((c) => c.slug), [ "beta", "zeta", "alpha", ]); - assertEquals((await get("/api/categories/alpha")).body.data.slug, "alpha"); + assertEquals( + (await get("/api/categories/alpha")).data.slug, + "alpha", + ); assertEquals((await get("/api/categories/nope")).status, 404); }); @@ -35,9 +44,12 @@ test("categories: only category:manage may create, update or delete", async () = (await post("/api/categories", { auth: mod.auth, json })).status, 403, ); - const created = await post("/api/categories", { auth: admin.auth, json }); + const created = await post("/api/categories", { + auth: admin.auth, + json, + }); assertEquals(created.status, 201); - const id = created.body.data.id; + const id = created.data.id; assertEquals( (await put(`/api/categories/${id}`, { auth: mod.auth, @@ -62,7 +74,7 @@ test("categories: only category:manage may create, update or delete", async () = test("categories: slug and name rules, and friendly conflicts", async () => { const admin = await createUser({ role: "admin" }); const create = (json: unknown) => - post("/api/categories", { auth: admin.auth, json }); + post("/api/categories", { auth: admin.auth, json }); for ( const [json, message] of [ [{ slug: "Bad Slug", name: "x" }, "lowercase"], @@ -74,32 +86,32 @@ test("categories: slug and name rules, and friendly conflicts", async () => { ) { const res = await create(json); assertEquals(res.status, 400, JSON.stringify(json)); - assertStringIncludes(res.body.error.message, message); + assertStringIncludes(res.error.message, message); } assertEquals((await create({ slug: "ok" })).status, 400); const first = await create({ slug: "music", name: " Music " }); - assertEquals(first.body.data.name, "Music"); + assertEquals(first.data.name, "Music"); const dupSlug = await create({ slug: "music", name: "Other" }); assertEquals(dupSlug.status, 409); - assertStringIncludes(dupSlug.body.error.message, "Slug"); + assertStringIncludes(dupSlug.error.message, "Slug"); const dupName = await create({ slug: "other", name: "Music" }); assertEquals(dupName.status, 409); - assertStringIncludes(dupName.body.error.message, "Name"); + assertStringIncludes(dupName.error.message, "Name"); const second = await create({ slug: "films", name: "Films" }); - const rename = await put(`/api/categories/${second.body.data.id}`, { + const rename = await put(`/api/categories/${second.data.id}`, { auth: admin.auth, json: { slug: "music" }, }); assertEquals(rename.status, 409); - const ok = await put(`/api/categories/${second.body.data.id}`, { + const ok = await put(`/api/categories/${second.data.id}`, { auth: admin.auth, json: { name: "Movies", position: 3 }, }); - assertEquals(ok.body.data.slug, "films"); - assertEquals(ok.body.data.name, "Movies"); - assertEquals(ok.body.data.position, 3); + assertEquals(ok.data.slug, "films"); + assertEquals(ok.data.name, "Movies"); + assertEquals(ok.data.position, 3); }); test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => { @@ -118,13 +130,15 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy ) .run(d.id, cat); } - const anon = await get("/api/categories/music/dumps"); - assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [ + const anon = await get>("/api/categories/music/dumps"); + assertEquals(anon.data.items.map((d) => d.title), [ "in", ]); - assertEquals(anon.body.data.items[0].categoryIds, [cat]); + assertEquals(anon.data.items[0].categoryIds, [cat]); assertEquals( - (await get("/api/categories/music/dumps", { auth: owner.auth })).body.data + (await get>("/api/categories/music/dumps", { + auth: owner.auth, + })).data .total, 2, ); @@ -133,7 +147,10 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy // Deleting the category unlinks its dumps without deleting them. const admin = await createUser({ role: "admin" }); await del(`/api/categories/${cat}`, { auth: admin.auth }); - assertEquals((await get(`/api/dumps/${inCat.id}`)).body.data.categoryIds, []); + assertEquals( + (await get(`/api/dumps/${inCat.id}`)).data.categoryIds, + [], + ); }); test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => { @@ -162,23 +179,28 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () = isPublic: false, }); - const anon = (await get("/api/search?q=banana")).body.data; + const anon = (await get("/api/search?q=banana")).data; assertEquals(anon.dumps.total, 3); - assertEquals(anon.users.map((u: { id: string }) => u.id), [other.id]); - assertEquals(anon.playlists.map((p: { title: string }) => p.title), [ + assertEquals(anon.users.map((u) => u.id), [other.id]); + // Profiles only: a search must never hand out hashes or emails. + assertFalse("passwordHash" in anon.users[0]); + assertFalse("email" in anon.users[0]); + assertEquals(anon.playlists.map((p) => p.title), [ "Banana hits", ]); const mine = - (await get("/api/search?q=banana", { auth: owner.auth })).body.data; + (await get("/api/search?q=banana", { auth: owner.auth })) + .data; assertEquals(mine.dumps.total, 4); assertEquals(mine.playlists.length, 2); - const paged = (await get("/api/search?q=banana&limit=2")).body.data.dumps; + const paged = + (await get("/api/search?q=banana&limit=2")).data.dumps; assertEquals(paged.items.length, 2); assertEquals(paged.hasMore, true); - assertEquals((await get("/api/search?q=%20%20")).body.data, { + assertEquals((await get("/api/search?q=%20%20")).data, { dumps: { items: [], total: 0, hasMore: false }, users: [], playlists: [], diff --git a/api/test/chat.test.ts b/api/test/chat.test.ts index 14cd3382..6187f648 100644 --- a/api/test/chat.test.ts +++ b/api/test/chat.test.ts @@ -11,7 +11,7 @@ import { markChatRead, updateChatMessage, } from "../services/chat-service.ts"; -import { APIException } from "../model/interfaces.ts"; +import { APIException, type ChatMessage } from "../model/interfaces.ts"; function insertMessage( userId: string, @@ -130,8 +130,8 @@ test("GET /api/chat: members only, with limit and both cursors", async () => { const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n))); assertEquals((await get("/api/chat")).status, 401); const bodies = async (q: string) => - (await get(`/api/chat${q}`, { auth: user.auth })).body.data.map( - (m: { body: string }) => m.body, + (await get(`/api/chat${q}`, { auth: user.auth })).data.map( + (m) => m.body, ); assertEquals(await bodies(""), ["m1", "m2", "m3"]); assertEquals(await bodies("?limit=1"), ["m3"]); diff --git a/api/test/comments.test.ts b/api/test/comments.test.ts index 94710382..0c8c3d85 100644 --- a/api/test/comments.test.ts +++ b/api/test/comments.test.ts @@ -10,35 +10,41 @@ import { post, test, } from "./helpers.ts"; -import { assertEquals, assertExists } from "@std/assert"; +import { assertEquals, assertExists, assertFalse } from "@std/assert"; import { castCommentLike, removeCommentLike, } from "../services/comment-like-service.ts"; -import { APIException } from "../model/interfaces.ts"; +import { + APIException, + type Comment, + type Dump, + type PaginatedData, + type PublicUser, +} from "../model/interfaces.ts"; test("comments: post, list in order, count on the dump", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: owner.id }); - const first = await post(`/api/dumps/${id}/comments`, { + const first = await post(`/api/dumps/${id}/comments`, { auth: other.auth, json: { body: " first " }, }); assertEquals(first.status, 201); - assertEquals(first.body.data.body, "first"); - assertEquals(first.body.data.authorUsername, other.username); - const reply = await post(`/api/dumps/${id}/comments`, { + assertEquals(first.data.body, "first"); + assertEquals(first.data.authorUsername, other.username); + const reply = await post(`/api/dumps/${id}/comments`, { auth: owner.auth, - json: { body: "reply", parentId: first.body.data.id }, + json: { body: "reply", parentId: first.data.id }, }); - assertEquals(reply.body.data.parentId, first.body.data.id); - const list = await get(`/api/dumps/${id}/comments`); - assertEquals(list.body.data.map((c: { body: string }) => c.body), [ + assertEquals(reply.data.parentId, first.data.id); + const list = await get(`/api/dumps/${id}/comments`); + assertEquals(list.data.map((c) => c.body), [ "first", "reply", ]); - assertEquals((await get(`/api/dumps/${id}`)).body.data.commentCount, 2); + assertEquals((await get(`/api/dumps/${id}`)).data.commentCount, 2); }); test("comments: validation, auth and unknown dumps", async () => { @@ -103,7 +109,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () => json: { body: "self" }, }); assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); - const c = await post(`/api/dumps/${id}/comments`, { + const c = await post(`/api/dumps/${id}/comments`, { auth: other.auth, json: { body: "nice" }, }); @@ -115,7 +121,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () => }; assertEquals(note.user_id, owner.id); assertEquals(note.type, "dump_commented"); - assertEquals(JSON.parse(note.data).commentId, c.body.data.id); + assertEquals(JSON.parse(note.data).commentId, c.data.id); assertEquals(JSON.parse(note.data).dumpTitle, "Mine"); }); @@ -133,13 +139,13 @@ test("PATCH /api/comments/:id: author and moderator edit, others can't", async ( .status, 401, ); - const edited = await patch(`/api/comments/${commentId}`, { + const edited = await patch(`/api/comments/${commentId}`, { auth: author.auth, json: { body: " edited " }, }); assertEquals(edited.status, 200); - assertEquals(edited.body.data.body, "edited"); - assertExists(edited.body.data.updatedAt); + assertEquals(edited.data.body, "edited"); + assertExists(edited.data.updatedAt); assertEquals( (await patch(`/api/comments/${commentId}`, { auth: mod.auth, @@ -182,10 +188,10 @@ test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edit (await del(`/api/comments/${commentId}`, { auth: author.auth })).status, 200, ); - const [c] = (await get(`/api/dumps/${dumpId}/comments`)).body.data; + const [c] = (await get(`/api/dumps/${dumpId}/comments`)).data; assertEquals(c.deleted, true); assertEquals(c.body, ""); - assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0); + assertEquals((await get(`/api/dumps/${dumpId}`)).data.commentCount, 0); assertEquals( (await patch(`/api/comments/${commentId}`, { auth: author.auth, @@ -218,9 +224,11 @@ test("comment likes: count, conflicts, likers list and notifications", async () 1, ); - const likers = await get(`/api/comments/${commentId}/likers`); - assertEquals(likers.body.data.total, 2); - assertEquals(likers.body.data.items[0].email, undefined); + const likers = await get>( + `/api/comments/${commentId}/likers`, + ); + assertEquals(likers.data.total, 2); + assertFalse("email" in likers.data.items[0]); assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1); try { diff --git a/api/test/dumps.test.ts b/api/test/dumps.test.ts index f88e080d..38c288ac 100644 --- a/api/test/dumps.test.ts +++ b/api/test/dumps.test.ts @@ -17,10 +17,18 @@ import { test, TEST_DIR, } from "./helpers.ts"; +import type { + Dump, + DumpUrlMatch, + PaginatedData, + Playlist, + PublicUser, +} from "../model/interfaces.ts"; import { assert, assertEquals, assertExists, + assertFalse, assertMatch, assertNotEquals, } from "@std/assert"; @@ -41,18 +49,18 @@ function readFile(path: string): Uint8Array | null { test("POST /api/dumps: a URL dump takes its title from the page", async () => { const user = await createUser(); using _ = stubNetwork(() => PAGE.clone()); - const res = await post("/api/dumps", { + const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/a?utm_source=x", comment: "look" }, }); assertEquals(res.status, 201); - const dump = res.body.data; + const dump = res.data; assertEquals(dump.kind, "url"); assertEquals(dump.title, "Scraped title"); - assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/); + assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/); assertEquals(dump.userId, user.id); assertEquals(dump.isPrivate, false); - assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg"); + assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg"); const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) .get(dump.id) as { url: string; url_canonical: string }; // The exact URL is kept; the canonical key drops the tracking parameter. @@ -64,22 +72,22 @@ test("POST /api/dumps: a typed title wins; an unreachable page still posts", asy const user = await createUser(); { using _ = stubNetwork(() => PAGE.clone()); - const res = await post("/api/dumps", { + const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/b", title: " My title " }, }); - assertEquals(res.body.data.title, "My title"); + assertEquals(res.data.title, "My title"); } { using _ = stubNetwork(() => { throw new TypeError("offline"); }); - const res = await post("/api/dumps", { + const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://www.offline.example/c" }, }); assertEquals(res.status, 201); - assertEquals(res.body.data.title, "offline.example"); + assertEquals(res.data.title, "offline.example"); } }); @@ -108,14 +116,14 @@ test("POST /api/dumps: categories are stored and deduplicated", async () => { const user = await createUser(); const music = insertCategory({ slug: "music" }); using _ = stubNetwork(() => PAGE.clone()); - const res = await post("/api/dumps", { + const res = await post("/api/dumps", { auth: user.auth, json: { url: "https://example.com/d", categoryIds: [music, music] }, }); assertEquals(res.status, 201); - assertEquals(res.body.data.categoryIds, [music]); + assertEquals(res.data.categoryIds, [music]); assertEquals( - (await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds, + (await get(`/api/dumps/${res.data.id}`)).data.categoryIds, [music], ); }); @@ -133,12 +141,12 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind test("POST /api/dumps: a file dump is stored and served back", async () => { const user = await createUser(); - const res = await post("/api/dumps", { + const res = await post("/api/dumps", { auth: user.auth, form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }), }); assertEquals(res.status, 201); - const dump = res.body.data; + const dump = res.data; assertEquals(dump.kind, "file"); assertEquals(dump.title, "pixel.png"); assertEquals(dump.fileMime, "image/png"); @@ -202,7 +210,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones" json: { url: "https://example.com/private", isPrivate: true }, }); assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); - const pub = await post("/api/dumps", { + const pub = await post("/api/dumps", { auth: poster.auth, json: { url: "https://example.com/public" }, }); @@ -212,7 +220,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones" assertEquals(notes.length, 1); assertEquals(notes[0].user_id, follower.id); assertEquals(notes[0].type, "user_dump_posted"); - assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id); + assertEquals(JSON.parse(notes[0].data).dumpId, pub.data.id); }); // ── Read ───────────────────────────────────────────────────────────────────── @@ -220,8 +228,8 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones" test("GET /api/dumps/:id works by id and by slug", async () => { const user = await createUser(); const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); - assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id); - assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id); + assertEquals((await get(`/api/dumps/${id}`)).data.id, id); + assertEquals((await get(`/api/dumps/${slug}`)).data.id, id); assertEquals((await get(`/api/dumps/nope`)).status, 404); assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); }); @@ -265,22 +273,25 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only", isPrivate: true, createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(), }); - const page1 = await get("/api/dumps?limit=2"); - assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [ + const page1 = await get>("/api/dumps?limit=2"); + assertEquals(page1.data.items.map((d) => d.title), [ "d4", "d3", ]); - assertEquals(page1.body.data.total, 5); - assertEquals(page1.body.data.hasMore, true); - const page3 = await get("/api/dumps?limit=2&page=3"); - assertEquals(page3.body.data.items.length, 1); - assertEquals(page3.body.data.hasMore, false); + assertEquals(page1.data.total, 5); + assertEquals(page1.data.hasMore, true); + const page3 = await get>("/api/dumps?limit=2&page=3"); + assertEquals(page3.data.items.length, 1); + assertEquals(page3.data.hasMore, false); - const mine = await get("/api/dumps?limit=1", { auth: owner.auth }); - assertEquals(mine.body.data.items[0].title, "secret"); - assertEquals(mine.body.data.total, 6); + const mine = await get>("/api/dumps?limit=1", { + auth: owner.auth, + }); + assertEquals(mine.data.items[0].title, "secret"); + assertEquals(mine.data.total, 6); assertEquals( - (await get("/api/dumps", { auth: other.auth })).body.data.total, + (await get>("/api/dumps", { auth: other.auth })).data + .total, 5, ); }); @@ -300,19 +311,26 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as isPrivate: true, }); const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); - const anon = await get(`/api/dumps/by-url?url=${q}`); - assertEquals(anon.body.data.map((d: { title: string }) => d.title), [ + const anon = await get(`/api/dumps/by-url?url=${q}`); + assertEquals(anon.data.map((d) => d.title), [ "public", ]); - assertEquals(anon.body.data[0].username, owner.username); - const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth }); - assertEquals(mine.body.data.length, 2); + assertEquals(anon.data[0].username, owner.username); + const mine = await get(`/api/dumps/by-url?url=${q}`, { + auth: owner.auth, + }); + assertEquals(mine.data.length, 2); assertEquals( - (await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data + (await get(`/api/dumps/by-url?url=${q}`, { + auth: other.auth, + })).data .length, 1, ); - assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []); + assertEquals( + (await get(`/api/dumps/by-url?url=garbage`)).data, + [], + ); }); test("GET /api/dumps/:id/voters lists voters without private fields", async () => { @@ -322,11 +340,11 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () = db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ).run(id, voter.id, new Date().toISOString()); - const res = await get(`/api/dumps/${id}/voters`); - assertEquals(res.body.data.total, 1); - assertEquals(res.body.data.items[0].username, voter.username); - assertEquals(res.body.data.items[0].email, undefined); - assertEquals(res.body.data.items[0].passwordHash, undefined); + const res = await get>(`/api/dumps/${id}/voters`); + assertEquals(res.data.total, 1); + assertEquals(res.data.items[0].username, voter.username); + assertFalse("email" in res.data.items[0]); + assertFalse("passwordHash" in res.data.items[0]); }); test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => { @@ -345,7 +363,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones" using _ = stubNetwork(() => PAGE.clone()); // A new dump whose comment links the target by its URL and the secret dump by // its page — and itself, which is ignored. - const src = await post("/api/dumps", { + const src = await post("/api/dumps", { auth: other.auth, json: { url: "https://example.com/src", @@ -353,21 +371,21 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones" `see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, }, }); - const srcId = src.body.data.id; - const fromSrc = await get(`/api/dumps/${srcId}/related`); - assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [ + const srcId = src.data.id; + const fromSrc = await get(`/api/dumps/${srcId}/related`); + assertEquals(fromSrc.data.map((d) => d.title), [ "target", ]); - const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, { + const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, { auth: other.auth, }); - assertEquals(fromSrcAsOwner.body.data.length, 2); - const toTarget = await get(`/api/dumps/${target.id}/related`); - assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]); + assertEquals(fromSrcAsOwner.data.length, 2); + const toTarget = await get(`/api/dumps/${target.id}/related`); + assertEquals(toTarget.data.map((d) => d.id), [srcId]); // Editing the comment away removes the links. await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } }); - assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []); + assertEquals((await get(`/api/dumps/${target.id}/related`)).data, []); }); test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => { @@ -386,12 +404,14 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ).run(p.id, id, new Date().toISOString()); } assertEquals( - (await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data - .map((p: { title: string }) => p.title), + (await get(`/api/dumps/${id}/playlists`, { auth: other.auth })) + .data + .map((p) => p.title), ["Public"], ); assertEquals( - (await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data + (await get(`/api/dumps/${id}/playlists`, { auth: user.auth })) + .data .length, 2, ); @@ -413,21 +433,21 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async ( (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, 401, ); - const res = await put(`/api/dumps/${slug}`, { + const res = await put(`/api/dumps/${slug}`, { auth: owner.auth, json: { title: "New title", comment: "hello" }, }); assertEquals(res.status, 200); - assertEquals(res.body.data.title, "New title"); - assertMatch(res.body.data.slug, /^new-title-/); - assertNotEquals(res.body.data.slug, slug); - assertExists(res.body.data.updatedAt); + assertEquals(res.data.title, "New title"); + assertMatch(res.data.slug ?? "", /^new-title-/); + assertNotEquals(res.data.slug, slug); + assertExists(res.data.updatedAt); const byMod = await put(`/api/dumps/${id}`, { auth: mod.auth, json: { title: "Moderated" }, }); assertEquals(byMod.status, 200); - assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated"); + assertEquals((await get(`/api/dumps/${id}`)).data.title, "Moderated"); }); test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => { @@ -453,11 +473,11 @@ test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", a // Same URL: no fetch, title kept. await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } }); assertEquals(net.calls.length, 0); - const moved = await put(`/api/dumps/${id}`, { + const moved = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { url: "https://b.test/?utm_medium=z" }, }); - assertEquals(moved.body.data.title, "Scraped title"); + assertEquals(moved.data.title, "Scraped title"); assertEquals(net.calls.length, 1); const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get( id, @@ -476,21 +496,21 @@ test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async auth: owner.auth, json: { categoryIds: [a] }, }); - const kept = await put(`/api/dumps/${id}`, { + const kept = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "t" }, }); - assertEquals(kept.body.data.categoryIds, [a]); - const replaced = await put(`/api/dumps/${id}`, { + assertEquals(kept.data.categoryIds, [a]); + const replaced = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [b] }, }); - assertEquals(replaced.body.data.categoryIds, [b]); - const cleared = await put(`/api/dumps/${id}`, { + assertEquals(replaced.data.categoryIds, [b]); + const cleared = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { categoryIds: [] }, }); - assertEquals(cleared.body.data.categoryIds, []); + assertEquals(cleared.data.categoryIds, []); }); test("PUT /api/dumps/:id: an unknown category changes nothing", async () => { @@ -506,7 +526,7 @@ test("PUT /api/dumps/:id: an unknown category changes nothing", async () => { json: { title: "After", categoryIds: [a, "nope"] }, }); assertEquals(res.status, 400); - const dump = (await get(`/api/dumps/${id}`)).body.data; + const dump = (await get(`/api/dumps/${id}`)).data; assertEquals(dump.title, "Before"); assertEquals(dump.categoryIds, [a]); }); @@ -528,7 +548,7 @@ test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async () }); assertEquals(res.status, 400); assertEquals(readFile(`dumps/${id}`), original); - assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt"); + assertEquals((await get(`/api/dumps/${id}`)).data.fileName, "a.txt"); }); test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => { @@ -553,7 +573,7 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async ( title: "file", fileName: "a.txt", }); - const res = await put(`/api/dumps/${id}`, { + const res = await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: "Renamed", @@ -563,9 +583,9 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async ( }, }); assertEquals(res.status, 200); - assertEquals(res.body.data.title, "Renamed"); - assertEquals(res.body.data.isPrivate, true); - assertEquals(res.body.data.url, undefined); + assertEquals(res.data.title, "Renamed"); + assertEquals(res.data.isPrivate, true); + assertEquals(res.data.url, undefined); }); test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => { @@ -583,13 +603,13 @@ test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => { .status, 403, ); - const res = await put(`/api/dumps/${id}/file`, { + const res = await put(`/api/dumps/${id}/file`, { auth: owner.auth, form: form(), }); assertEquals(res.status, 200); - assertEquals(res.body.data.fileName, "b.png"); - assertEquals(res.body.data.title, "Replaced"); + assertEquals(res.data.fileName, "b.png"); + assertEquals(res.data.title, "Replaced"); assertEquals(readFile(`dumps/${id}`), PNG); const urlDump = insertDump({ userId: owner.id }); @@ -608,7 +628,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn const other = await createUser(); const { id } = insertDump({ userId: owner.id, fileName: "a.txt" }); const replace = (fields: Record) => - put(`/api/dumps/${id}/file`, { + put(`/api/dumps/${id}/file`, { auth: owner.auth, form: formWithFile(PNG, "b.png", "image/png", fields), }); @@ -616,15 +636,15 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn (await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200; let res = await replace({ isPrivate: "true" }); - assertEquals(res.body.data.isPrivate, true); + assertEquals(res.data.isPrivate, true); assertEquals(await visible(), false); assertEquals((await get(`/api/files/${id}`)).status, 404); // Left out, it stays private. res = await replace({}); - assertEquals(res.body.data.isPrivate, true); + assertEquals(res.data.isPrivate, true); assertEquals(await visible(), false); res = await replace({ isPrivate: "false" }); - assertEquals(res.body.data.isPrivate, false); + assertEquals(res.data.isPrivate, false); assertEquals(await visible(), true); // A refused replacement changes neither. res = await replace({ isPrivate: "true", categoryIds: '["nope"]' }); @@ -654,19 +674,21 @@ test("custom thumbnails: set, served, removed", async () => { })).status, 400, ); - const set = await put(`/api/dumps/${id}/thumbnail`, { + const set = await put(`/api/dumps/${id}/thumbnail`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals(set.status, 200); - assertEquals(set.body.data.thumbnailMime, "image/png"); + assertEquals(set.data.thumbnailMime, "image/png"); const thumb = await get(`/api/thumbnails/${id}`); assertEquals(thumb.status, 200); assertEquals(thumb.headers.get("content-type"), "image/png"); assertEquals(thumb.bytes, PNG); - const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth }); - assertEquals(removed.body.data.thumbnailMime, undefined); + const removed = await del(`/api/dumps/${id}/thumbnail`, { + auth: owner.auth, + }); + assertEquals(removed.data.thumbnailMime, undefined); assertEquals(readFile(`thumbnails/${id}-custom`), null); // A URL dump without its own thumbnail has none to serve. assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); @@ -688,19 +710,19 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai using _ = stubNetwork(() => { throw new TypeError("down"); }); - const res = await post(`/api/dumps/${id}/refresh-metadata`, { + const res = await post(`/api/dumps/${id}/refresh-metadata`, { auth: owner.auth, }); assertEquals(res.status, 502); - assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept"); + assertEquals((await get(`/api/dumps/${id}`)).data.title, "Kept"); } { using _ = stubNetwork(() => PAGE.clone()); - const res = await post(`/api/dumps/${id}/refresh-metadata`, { + const res = await post(`/api/dumps/${id}/refresh-metadata`, { auth: owner.auth, }); assertEquals(res.status, 200); - assertEquals(res.body.data.title, "Scraped title"); + assertEquals(res.data.title, "Scraped title"); } const file = insertDump({ userId: owner.id, fileName: "f.txt" }); assertEquals( @@ -760,14 +782,12 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => { } for (const sub of ["dumps", "votes"]) { const path = `/api/users/${owner.username}/${sub}`; - assertEquals((await get(path)).body.data.total, 1, sub); + const total = async (opts?: { auth: { Authorization: string } }) => + (await get>(path, opts)).data.total; + assertEquals(await total(), 1, sub); + assertEquals(await total({ auth: other.auth }), 1, sub); assertEquals( - (await get(path, { auth: other.auth })).body.data.total, - 1, - sub, - ); - assertEquals( - (await get(path, { auth: owner.auth })).body.data.total, + await total({ auth: owner.auth }), 2, sub, ); diff --git a/api/test/helpers.ts b/api/test/helpers.ts index c50aa475..81b885dd 100644 --- a/api/test/helpers.ts +++ b/api/test/helpers.ts @@ -7,7 +7,7 @@ import { db } from "../model/db.ts"; import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts"; import { makeSlug } from "../lib/slugify.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; -import type { Role } from "../model/interfaces.ts"; +import type { APIError, APIResponse, Role } from "../model/interfaces.ts"; export { app, db, TEST_DIR }; @@ -245,28 +245,35 @@ export function count(sql: string, ...params: (string | number)[]): number { export const BASE_URL = "http://gerbeur.test"; -export interface ApiResponse { +export interface ApiResponse { status: number; headers: Headers; - // deno-lint-ignore no-explicit-any - body: any; + /** The parsed body when it's JSON, null otherwise. */ + body: APIResponse | null; bytes: Uint8Array; + /** The body's `data`; throws if the request didn't succeed. */ + readonly data: T; + /** The body's `error`; throws if the request didn't fail. */ + readonly error: APIError; } +type RequestOptions = { + auth?: { Authorization: string }; + json?: unknown; + form?: FormData; + headers?: Record; +}; + /** * Sends a request through the full middleware stack in-process — no socket. - * `json` sends a JSON body, `form` a multipart one. + * `json` sends a JSON body, `form` a multipart one. `T` is what the endpoint + * answers with in `data`. */ -export async function request( +export async function request( method: string, path: string, - opts: { - auth?: { Authorization: string }; - json?: unknown; - form?: FormData; - headers?: Record; - } = {}, -): Promise { + opts: RequestOptions = {}, +): Promise> { const headers = new Headers({ ...opts.auth, ...opts.headers }); let body: BodyInit | undefined; if (opts.json !== undefined) { @@ -281,23 +288,41 @@ export async function request( if (!res) throw new Error(`No response for ${method} ${path}`); const bytes = new Uint8Array(await res.arrayBuffer()); const text = new TextDecoder().decode(bytes); - let parsed: unknown = null; + let parsed: APIResponse | null = null; if ((res.headers.get("content-type") ?? "").includes("json") && text) { parsed = JSON.parse(text); } - return { status: res.status, headers: res.headers, body: parsed, bytes }; + const what = `${method} ${path} (${res.status})`; + return { + status: res.status, + headers: res.headers, + body: parsed, + bytes, + get data() { + if (!parsed?.success) { + throw new Error(`${what} failed: ${parsed?.error.message ?? text}`); + } + return parsed.data; + }, + get error() { + if (parsed?.success !== false) { + throw new Error(`${what} didn't fail`); + } + return parsed.error; + }, + }; } -export const get = (path: string, opts?: Parameters[2]) => - request("GET", path, opts); -export const post = (path: string, opts?: Parameters[2]) => - request("POST", path, opts); -export const put = (path: string, opts?: Parameters[2]) => - request("PUT", path, opts); -export const patch = (path: string, opts?: Parameters[2]) => - request("PATCH", path, opts); -export const del = (path: string, opts?: Parameters[2]) => - request("DELETE", path, opts); +export const get = (path: string, opts?: RequestOptions) => + request("GET", path, opts); +export const post = (path: string, opts?: RequestOptions) => + request("POST", path, opts); +export const put = (path: string, opts?: RequestOptions) => + request("PUT", path, opts); +export const patch = (path: string, opts?: RequestOptions) => + request("PATCH", path, opts); +export const del = (path: string, opts?: RequestOptions) => + request("DELETE", path, opts); // ── Files ──────────────────────────────────────────────────────────────────── diff --git a/api/test/players.test.ts b/api/test/players.test.ts index 275b2782..87f9c12f 100644 --- a/api/test/players.test.ts +++ b/api/test/players.test.ts @@ -2,8 +2,11 @@ // their parsers and their routes. All network access is stubbed. import { get, html, stubNetwork } from "./helpers.ts"; import { assert, assertEquals, assertFalse } from "@std/assert"; -import { parseTralbum } from "../services/bandcamp-tralbum.ts"; -import { parsePlaylistPage } from "../services/youtube-playlist-service.ts"; +import { parseTralbum, type Tralbum } from "../services/bandcamp-tralbum.ts"; +import { + parsePlaylistPage, + type YouTubePlaylist, +} from "../services/youtube-playlist-service.ts"; function escapeAttr(s: string): string { return s.replaceAll("&", "&").replaceAll('"', """); @@ -147,10 +150,12 @@ Deno.test("parsePlaylistPage: current and legacy rows", () => { Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => { using net = stubNetwork(() => html(tralbumPage(ALBUM))); const url = "https://cached.bandcamp.com/album/x"; - const res = await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`); + const res = await get( + `/api/bandcamp/tracks?url=${encodeURIComponent(url)}`, + ); assertEquals(res.status, 200); assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); - assertEquals(res.body.data.tracks.length, 4); + assertEquals(res.data.tracks.length, 4); // Same page (different query/case/trailing slash) → cache hit. await get( `/api/bandcamp/tracks?url=${ @@ -188,9 +193,9 @@ Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () = ? new Response("", { status: 404 }) : html(ytPage(YT_DATA)); }); - const ok = await get("/api/youtube/playlist?list=PLgood"); + const ok = await get("/api/youtube/playlist?list=PLgood"); assertEquals(ok.status, 200); - assertEquals(ok.body.data.tracks.length, 2); + assertEquals(ok.data.tracks.length, 2); assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); // The pre-answered consent cookie keeps EU requests off the consent page. assertEquals(cookie, "SOCS=CAI"); diff --git a/api/test/playlists.test.ts b/api/test/playlists.test.ts index 41cd0f26..e8e5aa45 100644 --- a/api/test/playlists.test.ts +++ b/api/test/playlists.test.ts @@ -1,4 +1,5 @@ import { + type ApiResponse, count, createUser, db, @@ -13,29 +14,35 @@ import { test, TEST_DIR, } from "./helpers.ts"; +import type { + PaginatedData, + Playlist, + PlaylistMembership, + PlaylistWithDumps, +} from "../model/interfaces.ts"; import { assert, assertEquals, assertMatch } from "@std/assert"; -function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] { - return res.body.data.dumps.map((d) => d.id); +function ids(res: ApiResponse): string[] { + return res.data.dumps.map((d) => d.id); } test("POST /api/playlists creates; GET finds it by id or slug", async () => { const user = await createUser(); - const res = await post("/api/playlists", { + const res = await post("/api/playlists", { auth: user.auth, json: { title: "Road Trip", description: "songs", isPublic: true }, }); assertEquals(res.status, 201); - const { id, slug } = res.body.data; - assertMatch(slug, /^road-trip-/); + const { id, slug } = res.data; + assertMatch(slug ?? "", /^road-trip-/); assertEquals( - (await get(`/api/playlists/${id}`)).body.data.title, + (await get(`/api/playlists/${id}`)).data.title, "Road Trip", ); - const bySlug = await get(`/api/playlists/${slug}`); - assertEquals(bySlug.body.data.id, id); - assertEquals(bySlug.body.data.ownerUsername, user.username); - assertEquals(bySlug.body.data.dumps, []); + const bySlug = await get(`/api/playlists/${slug}`); + assertEquals(bySlug.data.id, id); + assertEquals(bySlug.data.ownerUsername, user.username); + assertEquals(bySlug.data.dumps, []); assertEquals( (await post("/api/playlists", { auth: user.auth, json: { title: "" } })) .status, @@ -63,8 +70,11 @@ test("private playlists are the owner's alone", async () => { ); insertPlaylist({ userId: owner.id }); const path = `/api/users/${owner.username}/playlists`; - assertEquals((await get(path)).body.data.total, 1); - assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2); + assertEquals((await get>(path)).data.total, 1); + assertEquals( + (await get>(path, { auth: owner.auth })).data.total, + 2, + ); }); test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => { @@ -109,7 +119,10 @@ test("adding dumps: newest on top, no duplicates, only dumps you can see", async .status, 403, ); - assertEquals((await get(`/api/playlists/${id}`)).body.data.dumpCount, 2); + assertEquals( + (await get(`/api/playlists/${id}`)).data.dumpCount, + 2, + ); }); test("a public playlist hides its private dumps from everyone but their owner", async () => { @@ -201,19 +214,19 @@ test("removing a dump; updating; deleting", async () => { })).status, 403, ); - const updated = await put(`/api/playlists/${id}`, { + const updated = await put(`/api/playlists/${id}`, { auth: owner.auth, json: { title: "New", description: "d", isPublic: false }, }); - assertEquals(updated.body.data.title, "New"); - assertMatch(updated.body.data.slug, /^new-/); - assertEquals(updated.body.data.isPublic, false); - const cleared = await put(`/api/playlists/${id}`, { + assertEquals(updated.data.title, "New"); + assertMatch(updated.data.slug ?? "", /^new-/); + assertEquals(updated.data.isPublic, false); + const cleared = await put(`/api/playlists/${id}`, { auth: owner.auth, json: { description: null }, }); - assertEquals(cleared.body.data.description, undefined); - assertEquals(cleared.body.data.title, "New"); + assertEquals(cleared.data.description, undefined); + assertEquals(cleared.data.title, "New"); assertEquals( (await del(`/api/playlists/${id}`, { auth: other.auth })).status, @@ -238,11 +251,14 @@ test("memberships: which of my playlists hold this dump", async () => { insertPlaylist({ userId: other.id, title: "theirs" }); const d = insertDump({ userId: owner.id }); await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth }); - const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, { - auth: owner.auth, - }); + const res = await get( + `/api/playlists/by-dump/${d.id}/memberships`, + { + auth: owner.auth, + }, + ); const byTitle = Object.fromEntries( - res.body.data.map(( + res.data.map(( m: { playlist: { title: string }; hasDump: boolean }, ) => [ m.playlist.title, @@ -292,14 +308,14 @@ test("playlist images: upload and serve", async () => { .status, 403, ); - const res = await post(`/api/playlists/${slug}/image`, { + const res = await post(`/api/playlists/${slug}/image`, { auth: owner.auth, form: formWithFile(PNG), }); assertEquals(res.status, 200); - assertEquals(res.body.data.imageMime, "image/png"); + assertEquals(res.data.imageMime, "image/png"); // A new cover counts as an edit, so image URLs versioned on it change. - assert(res.body.data.updatedAt); + assert(res.data.updatedAt); const served = await get(`/api/playlists/${slug}/image`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); @@ -356,17 +372,17 @@ test("playlist images: a failed write leaves the row and the old cover alone", a test("playlist images: a replaced cover is served under the new version", async () => { const owner = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); - const first = await post(`/api/playlists/${id}/image`, { + const first = await post(`/api/playlists/${id}/image`, { auth: owner.auth, form: formWithFile(PNG), }); await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at const other = new Uint8Array([...PNG, 1, 2, 3]); - const second = await post(`/api/playlists/${id}/image`, { + const second = await post(`/api/playlists/${id}/image`, { auth: owner.auth, form: formWithFile(other), }); - assert(second.body.data.updatedAt !== first.body.data.updatedAt); + assert(second.data.updatedAt !== first.data.updatedAt); assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other); }); diff --git a/api/test/social.test.ts b/api/test/social.test.ts index 02579eb1..74dbd5d8 100644 --- a/api/test/social.test.ts +++ b/api/test/social.test.ts @@ -1,5 +1,6 @@ // Follows, feeds, notifications and invites. import { + type ApiResponse, count, createUser, db, @@ -12,10 +13,20 @@ import { post, test, } from "./helpers.ts"; -import { assertEquals, assertMatch } from "@std/assert"; +import type { + Comment, + Dump, + FollowStatus, + InviteResponse, + Notification, + PaginatedData, + Playlist, + PublicUser, +} from "../model/interfaces.ts"; +import { assertEquals, assertFalse, assertMatch } from "@std/assert"; -function titles(res: { body: { data: { items: { title: string }[] } } }) { - return res.body.data.items.map((d) => d.title); +function titles(res: ApiResponse>): string[] { + return res.data.items.map((d) => d.title); } test("following users: status, idempotence, no self-follow, one notification", async () => { @@ -34,13 +45,13 @@ test("following users: status, idempotence, no self-follow, one notification", a 400, ); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).body.data, + (await get("/api/follows/status", { auth: me.auth })).data, { followedUserIds: [them.id], followedPlaylistIds: [] }, ); // Unfollow and follow again: still just the one notification. await del(`/api/follows/users/${them.id}`, { auth: me.auth }); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).body.data + (await get("/api/follows/status", { auth: me.auth })).data .followedUserIds, [], ); @@ -52,11 +63,13 @@ test("following users: status, idempotence, no self-follow, one notification", a ), 1, ); - const list = await get(`/api/users/${me.username}/followed-users`); - assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [ + const list = await get>( + `/api/users/${me.username}/followed-users`, + ); + assertEquals(list.data.items.map((u) => u.id), [ them.id, ]); - assertEquals(list.body.data.items[0].email, undefined); + assertFalse("email" in list.data.items[0]); }); test("following playlists: public ones only, and not your own notification", async () => { @@ -86,13 +99,15 @@ test("following playlists: public ones only, and not your own notification", asy 1, ); assertEquals( - (await get(`/api/users/${me.username}/followed-playlists`)).body.data + (await get>( + `/api/users/${me.username}/followed-playlists`, + )).data .items[0].id, pub.id, ); await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); assertEquals( - (await get("/api/follows/status", { auth: me.auth })).body.data + (await get("/api/follows/status", { auth: me.auth })).data .followedPlaylistIds, [], ); @@ -121,9 +136,11 @@ test("followed-users feed: public dumps of the people I follow, newest first", a insertDump({ userId: stranger.id, title: "stranger" }); await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); - const feed = await get("/api/follows/feed/users", { auth: me.auth }); + const feed = await get>("/api/follows/feed/users", { + auth: me.auth, + }); assertEquals(titles(feed), ["new", "old"]); - assertEquals(feed.body.data.items[0].categoryIds, [music]); + assertEquals(feed.data.items[0].categoryIds, [music]); assertEquals( titles( await get("/api/follows/feed/users?category=music", { auth: me.auth }), @@ -132,8 +149,9 @@ test("followed-users feed: public dumps of the people I follow, newest first", a ); // An unknown category filters everything out rather than nothing. assertEquals( - (await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body - .data, + (await get>("/api/follows/feed/users?category=nope", { + auth: me.auth, + })).data, { items: [], total: 0, hasMore: false }, ); assertEquals((await get("/api/follows/feed/users")).status, 401); @@ -155,14 +173,18 @@ test("followed-playlists feed: each dump once, public playlists only", async () await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); } await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); - const feed = await get("/api/follows/feed/playlists", { auth: me.auth }); + const feed = await get>("/api/follows/feed/playlists", { + auth: me.auth, + }); assertEquals(titles(feed), ["shared"]); - assertEquals(feed.body.data.total, 1); + assertEquals(feed.data.total, 1); // A playlist gone private drops out of the feed. db.prepare(`UPDATE playlists SET is_public = 0;`).run(); assertEquals( - (await get("/api/follows/feed/playlists", { auth: me.auth })).body.data + (await get>("/api/follows/feed/playlists", { + auth: me.auth, + })).data .total, 0, ); @@ -180,36 +202,42 @@ test("notifications: listed newest first, marked read one or all, never someone }); await post(`/api/follows/users/${other.id}`, { auth: me.auth }); - const list = await get("/api/notifications", { auth: me.auth }); - assertEquals(list.body.data.total, 2); + const list = await get>("/api/notifications", { + auth: me.auth, + }); + assertEquals(list.data.total, 2); assertEquals( - list.body.data.items.map((n: { type: string }) => n.type), + list.data.items.map((n) => n.type), ["dump_commented", "user_followed"], ); assertEquals( - list.body.data.items.every((n: { read: boolean }) => !n.read), + list.data.items.every((n) => !n.read), true, ); - const theirs = - (await get("/api/notifications", { auth: other.auth })).body.data - .items[0].id; + const theirs = (await get>("/api/notifications", { + auth: other.auth, + })).data + .items[0].id; assertEquals( (await patch(`/api/notifications/${theirs}/read`, { auth: me.auth })) .status, 204, ); assertEquals( - (await get("/api/notifications", { auth: other.auth })).body.data.items[0] + (await get>("/api/notifications", { + auth: other.auth, + })).data.items[0] .read, false, ); - const first = list.body.data.items[0].id; + const first = list.data.items[0].id; await patch(`/api/notifications/${first}/read`, { auth: me.auth }); - const after = - (await get("/api/notifications", { auth: me.auth })).body.data.items; - assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]); + const after = (await get>("/api/notifications", { + auth: me.auth, + })).data.items; + assertEquals(after.map((n) => n.read), [true, false]); assertEquals( (await post("/api/notifications/read-all", { auth: me.auth })).status, @@ -232,7 +260,7 @@ test("mentions: notify the named, once per context, never the author or an email const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + `me @${author.username}, mail x@${named.username}.com, ` + `[@${named.username}](link) @nobody_known`; - const c = await post(`/api/dumps/${dumpId}/comments`, { + const c = await post(`/api/dumps/${dumpId}/comments`, { auth: author.auth, json: { body }, }); @@ -245,12 +273,12 @@ test("mentions: notify the named, once per context, never the author or an email mentionerId: author.id, mentionerUsername: author.username, contextType: "comment", - contextId: c.body.data.id, + contextId: c.data.id, contextTitle: "Topic", dumpId, }); // Editing the comment doesn't notify a second time. - await patch(`/api/comments/${c.body.data.id}`, { + await patch(`/api/comments/${c.data.id}`, { auth: author.auth, json: { body: `again @${named.username}` }, }); @@ -265,9 +293,9 @@ test("mentions: notify the named, once per context, never the author or an email test("invites: anyone signed in can make one; it validates until used or stale", async () => { const user = await createUser(); assertEquals((await post("/api/invites")).status, 401); - const res = await post("/api/invites", { auth: user.auth }); + const res = await post("/api/invites", { auth: user.auth }); assertEquals(res.status, 201); - const token = res.body.data.token; + const token = res.data.token; assertMatch(token, /^[A-Za-z0-9_-]{22}$/); assertEquals((await get(`/api/invites/${token}`)).status, 200); assertEquals((await get(`/api/invites/unknown`)).status, 404); diff --git a/api/test/uploads.test.ts b/api/test/uploads.test.ts index 9263c681..73aae6c4 100644 --- a/api/test/uploads.test.ts +++ b/api/test/uploads.test.ts @@ -12,7 +12,12 @@ import { test, TEST_DIR, } from "./helpers.ts"; -import { assertEquals, assertStringIncludes } from "@std/assert"; +import type { + AttachmentResponse, + Comment, + SelfUser, +} from "../model/interfaces.ts"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; const BYTES = new TextEncoder().encode("0123456789"); @@ -214,12 +219,12 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async test("attachments: upload an image, serve it back", async () => { const user = await createUser(); - const res = await post("/api/attachments", { + const res = await post("/api/attachments", { auth: user.auth, form: formWithFile(PNG), }); assertEquals(res.status, 201); - const id = res.body.data.id; + const id = res.data.id; const served = await get(`/api/attachments/${id}`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); @@ -265,13 +270,13 @@ test("attachments: refuses non-images, non-multipart and anonymous uploads", asy test("attachments are claimed by the first text that links them", async () => { const user = await createUser(); const { id: dumpId } = insertDump({ userId: user.id }); - const up = await post("/api/attachments", { + const up = await post("/api/attachments", { auth: user.auth, form: formWithFile(PNG), }); - const attachmentId = up.body.data.id; + const attachmentId = up.data.id; const link = `![img](/api/attachments/${attachmentId})`; - const comment = await post(`/api/dumps/${dumpId}/comments`, { + const comment = await post(`/api/dumps/${dumpId}/comments`, { auth: user.auth, json: { body: link }, }); @@ -279,26 +284,26 @@ test("attachments are claimed by the first text that links them", async () => { (db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get( attachmentId, ) as { resource_id: string | null }).resource_id; - assertEquals(owner(), comment.body.data.id); + assertEquals(owner(), comment.data.id); // A second text can't take it over. await post(`/api/dumps/${dumpId}/comments`, { auth: user.auth, json: { body: link }, }); - assertEquals(owner(), comment.body.data.id); + assertEquals(owner(), comment.data.id); }); test("avatars: upload, serve, and 404 when there is none", async () => { const user = await createUser(); assertEquals((await get(`/api/avatars/${user.id}`)).status, 404); assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404); - const res = await post("/api/avatars/me", { + const res = await post("/api/avatars/me", { auth: user.auth, form: formWithFile(PNG), }); assertEquals(res.status, 200); - assertEquals(res.body.data.avatarMime, "image/png"); - assertEquals(res.body.data.passwordHash, undefined); + assertEquals(res.data.avatarMime, "image/png"); + assertFalse("passwordHash" in res.data); const served = await get(`/api/avatars/${user.id}`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); diff --git a/api/test/users.test.ts b/api/test/users.test.ts index 0b1463da..02605541 100644 --- a/api/test/users.test.ts +++ b/api/test/users.test.ts @@ -7,37 +7,43 @@ import { patch, test, } from "./helpers.ts"; -import { assertEquals } from "@std/assert"; +import type { + Invitee, + PublicUser, + SelfUser, + UserSuggestion, +} from "../model/interfaces.ts"; +import { assertEquals, assertFalse } from "@std/assert"; test("public profile: by username or id, without private fields", async () => { const user = await createUser(); for ( const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`] ) { - const res = await get(path); + const res = await get(path); assertEquals(res.status, 200, path); - assertEquals(res.body.data.username, user.username); - assertEquals(res.body.data.email, undefined); - assertEquals(res.body.data.passwordHash, undefined); + assertEquals(res.data.username, user.username); + assertFalse("email" in res.data); + assertFalse("passwordHash" in res.data); } assertEquals((await get(`/api/users/no_one_here`)).status, 404); - const me = await get("/api/users/me", { auth: user.auth }); - assertEquals(me.body.data.email, user.email); - assertEquals(me.body.data.passwordHash, undefined); + const me = await get("/api/users/me", { auth: user.auth }); + assertEquals(me.data.email, user.email); + assertFalse("passwordHash" in me.data); }); test("PATCH /api/users/me: description and email only", async () => { const user = await createUser(); const taken = await createUser(); - const res = await patch("/api/users/me", { + const res = await patch("/api/users/me", { auth: user.auth, json: { description: "about me", email: "new@example.test" }, }); assertEquals(res.status, 200); - assertEquals(res.body.data.description, "about me"); - assertEquals(res.body.data.email, undefined); + assertEquals(res.data.description, "about me"); + assertFalse("email" in res.data); assertEquals( - (await get("/api/users/me", { auth: user.auth })).body.data.email, + (await get("/api/users/me", { auth: user.auth })).data.email, "new@example.test", ); // A role, username or password in the body is ignored, not applied. @@ -102,13 +108,13 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => { .status, 400, ); - const res = await patch(`/api/users/${user.id}/role`, { + const res = await patch(`/api/users/${user.id}/role`, { auth: admin.auth, json: { role: "moderator" }, }); assertEquals(res.status, 200); - assertEquals(res.body.data.role, "moderator"); - assertEquals(res.body.data.email, undefined); + assertEquals(res.data.role, "moderator"); + assertFalse("email" in res.data); // The promoted user's existing token now carries moderator powers. const { id: dumpId } = insertDump({ userId: admin.id }); assertEquals( @@ -127,14 +133,14 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => { test("GET /api/users/search: prefix match for mentions, capped at 8", async () => { for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` }); await createUser({ username: "other_zed" }); - const res = await get("/api/users/search?q=zed"); - assertEquals(res.body.data.length, 8); - assertEquals(res.body.data[0], { - id: res.body.data[0].id, + const res = await get("/api/users/search?q=zed"); + assertEquals(res.data.length, 8); + assertEquals(res.data[0], { + id: res.data[0].id, username: "zed_0", avatarMime: null, }); - assertEquals((await get("/api/users/search?q=")).body.data, []); + assertEquals((await get("/api/users/search?q=")).data, []); }); test("GET /api/users/:username/invitees: the invite tree below a user", async () => { @@ -147,13 +153,14 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async () invitedBy: child.id, createdAt: "2024-01-02T00:00:00Z", }); - const tree = (await get(`/api/users/${root.username}/invitees`)).body.data; + const tree = + (await get(`/api/users/${root.username}/invitees`)).data; assertEquals( tree.map(( n: { username: string; invitedById: string }, ) => [n.username, n.invitedById]), [[child.username, root.id], [grandchild.username, child.id]], ); - const profile = await get(`/api/users/${child.username}`); - assertEquals(profile.body.data.invitedByUsername, root.username); + const profile = await get(`/api/users/${child.username}`); + assertEquals(profile.data.invitedByUsername, root.username); }); diff --git a/api/test/ws.test.ts b/api/test/ws.test.ts index eb8c2fb1..fc084f96 100644 --- a/api/test/ws.test.ts +++ b/api/test/ws.test.ts @@ -18,17 +18,32 @@ import { type TestUser, } from "./helpers.ts"; import { assert, assertEquals, assertExists, assertFalse } from "@std/assert"; +import type { + AuthTokens, + Dump, + ServerToClientMessage, +} from "../model/interfaces.ts"; -// deno-lint-ignore no-explicit-any -type Msg = Record; +// deno.json's "dom" lib types WebSocket's second argument as protocols only, +// hiding Deno's own overload, which takes handshake headers. +const DenoWebSocket = WebSocket as unknown as new ( + url: string, + options: { headers: HeadersInit }, +) => WebSocket; + +type Msg = ServerToClientMessage; +type MsgOf = Extract; interface Client { ws: WebSocket; messages: Msg[]; /** Resolves with the next unread message of `type` (and matching `where`). */ - next(type: string, where?: (m: Msg) => boolean): Promise; + next( + type: T, + where?: (m: MsgOf) => boolean, + ): Promise>; /** Every message of `type` received so far. */ - all(type: string): Msg[]; + all(type: T): MsgOf[]; send(data: unknown): void; /** Round-trips a ping, so anything the server sent before it has arrived. */ sync(): Promise; @@ -53,9 +68,9 @@ async function withServer( const connect = (user?: TestUser, origin = "http://allowed.test") => { const query = user ? `?token=${user.token}` : ""; - const ws = new WebSocket( + const ws = new DenoWebSocket( `ws://127.0.0.1:${port}/ws${query}`, - { headers: { origin } } as unknown as string[], + { headers: { origin } }, ); const messages: Msg[] = []; const read = new Set(); @@ -71,11 +86,14 @@ async function withServer( ws, messages, closed, - async next(type, where = () => true) { + async next( + type: T, + where: (m: MsgOf) => boolean = () => true, + ) { const deadline = Date.now() + 3000; while (true) { - const hit = messages.find((m) => - !read.has(m) && m.type === type && where(m) + const hit = messages.find((m): m is MsgOf => + !read.has(m) && m.type === type && where(m as MsgOf) ); if (hit) { read.add(hit); @@ -94,7 +112,8 @@ async function withServer( ]); } }, - all: (type) => messages.filter((m) => m.type === type), + all: (type: T) => + messages.filter((m): m is MsgOf => m.type === type), send: (data) => ws.send(typeof data === "string" ? data : JSON.stringify(data)), async sync() { @@ -157,7 +176,7 @@ wsTest("welcome: anonymous and signed-in state", async (connect) => { const me = await connect(user); const [mine] = me.all("welcome"); assertEquals(mine.myVotes, [dumpId]); - assertEquals(mine.users.map((u: Msg) => u.username), [user.username]); + assertEquals(mine.users.map((u) => u.username), [user.username]); assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z"); // The anonymous client hears about the arrival. const presence = await anon.next( @@ -217,7 +236,6 @@ wsTest( type: "vote_ack", dumpId, action: "cast", - success: true, voteCount: 1, }); const update = await watcher.next("votes_update"); @@ -480,11 +498,11 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { const x = await connect(other); // A public file dump arrives for everyone; a private one for no one. - const pub = await post("/api/dumps", { + const pub = await post("/api/dumps", { auth: owner.auth, form: formWithFile(PNG), }); - assertEquals((await x.next("dump_created")).dump.id, pub.body.data.id); + assertEquals((await x.next("dump_created")).dump.id, pub.data.id); await post("/api/dumps", { auth: owner.auth, form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }), @@ -493,16 +511,16 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { assertEquals(x.all("dump_created").length, 1); // Going private is a deletion for everyone else; going public an arrival. - await put(`/api/dumps/${pub.body.data.id}`, { + await put(`/api/dumps/${pub.data.id}`, { auth: owner.auth, json: { isPrivate: true }, }); - assertEquals((await x.next("dump_deleted")).dumpId, pub.body.data.id); - await put(`/api/dumps/${pub.body.data.id}`, { + assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id); + await put(`/api/dumps/${pub.data.id}`, { auth: owner.auth, json: { isPrivate: false }, }); - await x.next("dump_created", (m) => m.dump.id === pub.body.data.id); + await x.next("dump_created", (m) => m.dump.id === pub.data.id); // Comments on a private dump stay quiet. const priv = insertDump({ userId: owner.id, isPrivate: true }); @@ -510,7 +528,7 @@ wsTest("REST writes broadcast to the right audience", async (connect) => { auth: owner.auth, json: { body: "shh" }, }); - await post(`/api/dumps/${pub.body.data.id}/comments`, { + await post(`/api/dumps/${pub.data.id}/comments`, { auth: owner.auth, json: { body: "hi" }, }); @@ -531,7 +549,7 @@ wsTest("a password change closes the user's sockets", async (connect) => { const user = await createUser(); const c = await connect(user); await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts - const res = await post("/api/users/me/change-password", { + const res = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "a-new-password" }, }); @@ -541,8 +559,8 @@ wsTest("a password change closes the user's sockets", async (connect) => { // Reconnecting with the old token is anonymous now. const again = await connect(user); assertEquals(again.all("welcome")[0].users, []); - assertExists(res.body.data.token); - assertFalse(res.body.data.token === user.token); + assertExists(res.data.token); + assertFalse(res.data.token === user.token); }); wsTest( diff --git a/deno.json b/deno.json index bc1f79f4..707638cf 100644 --- a/deno.json +++ b/deno.json @@ -31,11 +31,11 @@ "@std/assert": "jsr:@std/assert@^1.0.19", "@std/testing": "jsr:@std/testing@^1.0.21", "@oak/oak": "jsr:@oak/oak@^17.2.0", - "@panva/jose": "jsr:@panva/jose@^6.2.3", + "@panva/jose": "jsr:@panva/jose@^6.2.12", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", - "nodemailer": "npm:nodemailer@^9.0.1", - "marked": "npm:marked@^18.0.5", + "nodemailer": "npm:nodemailer@^10.0.10", + "marked": "npm:marked@^18.0.14", "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2" }, - "allowScripts": ["npm:@swc/core@1.15.41", "npm:esbuild@0.25.12"] + "allowScripts": ["npm:@swc/core@1.16.2"] } diff --git a/deno.lock b/deno.lock index 4bc257b2..dad8e3dc 100644 --- a/deno.lock +++ b/deno.lock @@ -6,7 +6,7 @@ "jsr:@oak/commons@1": "1.0.1", "jsr:@oak/oak@*": "17.2.0", "jsr:@oak/oak@^17.2.0": "17.2.0", - "jsr:@panva/jose@^6.2.3": "6.2.3", + "jsr:@panva/jose@^6.2.12": "6.2.12", "jsr:@std/assert@1": "1.0.19", "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/async@^1.5.1": "1.5.1", @@ -29,55 +29,53 @@ "jsr:@std/path@^1.1.6": "1.1.6", "jsr:@std/testing@^1.0.21": "1.0.21", "jsr:@tajpouria/cors@^1.2.1": "1.2.1", - "npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", + "npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", "npm:@fontsource-variable/fraunces@5.3.0": "5.3.0", "npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0", "npm:@fontsource-variable/saira@5.3.0": "5.3.0", "npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0", "npm:@fontsource/instrument-serif@5.3.0": "5.3.0", - "npm:@lingui/cli@*": "6.4.0", - "npm:@lingui/cli@6.4.0": "6.4.0", - "npm:@lingui/conf@6.4.0": "6.4.0", - "npm:@lingui/core@6.4.0": "6.4.0", - "npm:@lingui/format-po@6.4.0": "6.4.0", - "npm:@lingui/react@6.4.0": "6.4.0_react@19.2.7", - "npm:@lingui/swc-plugin@6.5.0": "6.5.0_@lingui+core@6.4.0", - "npm:@lingui/vite-plugin@6.4.0": "6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", + "npm:@lingui/cli@*": "6.8.0", + "npm:@lingui/cli@6.8.0": "6.8.0", + "npm:@lingui/conf@6.8.0": "6.8.0", + "npm:@lingui/core@6.8.0": "6.8.0_@babel+core@7.29.7", + "npm:@lingui/format-po@6.8.0": "6.8.0", + "npm:@lingui/react@6.8.0": "6.8.0_react@19.3.0_@babel+core@7.29.7", + "npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7", + "npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0", "npm:@resvg/resvg-wasm@2.6.2": "2.6.2", "npm:@resvg/resvg-wasm@^2.6.2": "2.6.2", "npm:@testing-library/dom@^10.4.2": "10.4.2", - "npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.0.1__jsdom@30.1.1__vite@8.1.0___@types+node@26.0.1___jiti@2.7.0_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0", - "npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.2.17_@types+react-dom@19.2.3__@types+react@19.2.17_react@19.2.7_react-dom@19.2.7__react@19.2.7", + "npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0", + "npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0", "npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2", - "npm:@types/node@^26.0.1": "26.0.1", - "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.17", - "npm:@types/react@^19.2.17": "19.2.17", - "npm:@vitejs/plugin-react-swc@^4.3.1": "4.3.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0", + "npm:@types/node@^26.6.3": "26.6.3", + "npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0", + "npm:@types/react@^19.3.0": "19.3.0", + "npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0", "npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0", - "npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", - "npm:eslint-plugin-react-refresh@~0.5.3": "0.5.3_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0", - "npm:eslint@*": "10.6.0_jiti@2.7.0", - "npm:eslint@^10.6.0": "10.6.0_jiti@2.7.0", - "npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3", - "npm:globals@^17.7.0": "17.7.0", + "npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", + "npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0", + "npm:eslint@^10.11.0": "10.11.0_jiti@2.7.0", + "npm:frimousse@0.4": "0.4.0_react@19.3.0_typescript@6.0.3", + "npm:globals@^17.12.0": "17.12.0", "npm:jiti@^2.7.0": "2.7.0", "npm:jsdom@^30.1.1": "30.1.1", - "npm:marked@^18.0.5": "18.0.5", - "npm:nodemailer@^9.0.1": "9.0.1", + "npm:marked@^18.0.14": "18.0.14", + "npm:nodemailer@^10.0.10": "10.0.10", "npm:path-to-regexp@^6.3.0": "6.3.0", - "npm:react-dom@^19.2.7": "19.2.7_react@19.2.7", - "npm:react-hook-form@^7.80.0": "7.80.0_react@19.2.7", - "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.2.17_react@19.2.7", - "npm:react-router@^8.0.1": "8.0.1_react@19.2.7_react-dom@19.2.7__react@19.2.7", - "npm:react@^19.2.7": "19.2.7", + "npm:react-dom@^19.3.0": "19.3.0_react@19.3.0", + "npm:react-hook-form@^7.89.0": "7.89.0_@types+react@19.3.0_react@19.3.0", + "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.3.0_react@19.3.0", + "npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0", + "npm:react@^19.3.0": "19.3.0", "npm:remark-gfm@^4.0.1": "4.0.1", - "npm:typescript-eslint@^8.62.0": "8.62.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0", - "npm:typescript@*": "6.0.3", + "npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0", "npm:typescript@~6.0.3": "6.0.3", - "npm:vite@*": "8.1.0_@types+node@26.0.1_jiti@2.7.0", - "npm:vite@^8.1.0": "8.1.0_@types+node@26.0.1_jiti@2.7.0", - "npm:vitest@*": "5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0", - "npm:vitest@^5.0.2": "5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0" + "npm:vite@*": "8.3.1_@types+node@26.6.3_jiti@2.7.0", + "npm:vite@^8.3.1": "8.3.1_@types+node@26.6.3_jiti@2.7.0", + "npm:vitest@*": "5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_jiti@2.7.0", + "npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_jiti@2.7.0" }, "jsr": { "@db/sqlite@0.13.0": { @@ -119,8 +117,8 @@ "npm:path-to-regexp" ] }, - "@panva/jose@6.2.3": { - "integrity": "57cc772f01b2ac60f847a7e6ec815059762768186266ed833fb59953e9611312" + "@panva/jose@6.2.12": { + "integrity": "45dd3888cff0c45dbd91ca5279f7fd4324227042d19df91504aa2c0678b3edcc" }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", @@ -358,6 +356,22 @@ ], "bin": true }, + "@cacheable/memory@2.2.0": { + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dependencies": [ + "@cacheable/utils", + "@keyv/bigmap", + "hookified@1.15.1", + "keyv" + ] + }, + "@cacheable/utils@2.5.0": { + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dependencies": [ + "hashery", + "keyv" + ] + }, "@colors/colors@1.5.0": { "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" }, @@ -398,156 +412,7 @@ "@csstools/css-tokenizer@4.0.2": { "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==" }, - "@emnapi/core@1.11.1": { - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dependencies": [ - "@emnapi/wasi-threads", - "tslib" - ] - }, - "@emnapi/runtime@1.11.1": { - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dependencies": [ - "tslib" - ] - }, - "@emnapi/wasi-threads@1.2.2": { - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dependencies": [ - "tslib" - ] - }, - "@esbuild/aix-ppc64@0.25.12": { - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/android-arm64@0.25.12": { - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm@0.25.12": { - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-x64@0.25.12": { - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/darwin-arm64@0.25.12": { - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-x64@0.25.12": { - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-arm64@0.25.12": { - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-x64@0.25.12": { - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/linux-arm64@0.25.12": { - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm@0.25.12": { - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-ia32@0.25.12": { - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-loong64@0.25.12": { - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-mips64el@0.25.12": { - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-ppc64@0.25.12": { - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-riscv64@0.25.12": { - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-s390x@0.25.12": { - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-x64@0.25.12": { - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.25.12": { - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-x64@0.25.12": { - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-arm64@0.25.12": { - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-x64@0.25.12": { - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openharmony-arm64@0.25.12": { - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@esbuild/sunos-x64@0.25.12": { - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/win32-arm64@0.25.12": { - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-ia32@0.25.12": { - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-x64@0.25.12": { - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@eslint-community/eslint-utils@4.9.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0": { + "@eslint-community/eslint-utils@4.9.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dependencies": [ "eslint", @@ -565,8 +430,8 @@ "minimatch" ] }, - "@eslint/config-helpers@0.6.0": { - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "@eslint/config-helpers@0.7.0": { + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dependencies": [ "@eslint/core" ] @@ -577,7 +442,7 @@ "@types/json-schema" ] }, - "@eslint/js@10.0.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0": { + "@eslint/js@10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dependencies": [ "eslint" @@ -589,8 +454,8 @@ "@eslint/object-schema@3.0.5": { "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==" }, - "@eslint/plugin-kit@0.7.2": { - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "@eslint/plugin-kit@0.7.3": { + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dependencies": [ "@eslint/core", "levn" @@ -681,23 +546,37 @@ "@jridgewell/sourcemap-codec" ] }, - "@lingui/babel-plugin-extract-messages@6.4.0": { - "integrity": "sha512-b9NatQFU1h0muPCC0QGdSVKE/OEdR4w9FQrKAOFYwH0eF7pD2ArafqyqG6xsw2E+7w4PlZQjzOhihQMxI8a6sA==", + "@keyv/bigmap@1.3.1_keyv@5.6.0": { + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dependencies": [ + "hashery", + "hookified@1.15.1", + "keyv" + ] + }, + "@keyv/serialize@1.1.1": { + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==" + }, + "@lingui/babel-plugin-extract-messages@6.8.0": { + "integrity": "sha512-PoNyRASfuWnJ57b0GEwsjPYzxiGBPubKtY/xW1ZNs4TygSubwhrLaRQD2eA+21qeXG0bdpV+fZ71nBCxQtTKuQ==", "dependencies": [ "@lingui/conf" ] }, - "@lingui/babel-plugin-lingui-macro@6.4.0": { - "integrity": "sha512-V15kARtjzWgLga/6LOTMMki5pv3F42m9BX8jdI1mBg4yCo1cS0B3szfoBqoveFs7x+nh0iuEPAKcM9lVdSYadw==", + "@lingui/babel-plugin-lingui-macro@6.8.0_@babel+core@7.29.7": { + "integrity": "sha512-/gecQy9KnOo7xTNtHB/DWmweXtV1xd9BFWSmHbIqxKo8k3zcBacyZ4Jt4+RVEJglJ4LvpANjF3fQH3YfHPoqvQ==", "dependencies": [ "@babel/core", "@babel/types", "@lingui/conf", "@lingui/message-utils" + ], + "optionalPeers": [ + "@babel/core" ] }, - "@lingui/cli@6.4.0": { - "integrity": "sha512-OqtEPHFmgQlyroQMmpnda6QRnfAqlAYnRVZpZSX3rZpwwaHI1pD7T1EQoK8n7kin9TGhitc1J33wFom7o/+yyg==", + "@lingui/cli@6.8.0": { + "integrity": "sha512-VChVP1DM7wsz5aWFWQ7FBZIly3jE8H5ULGb3qfOC5w3dIrhRijY5nap/yCPf8eAFXKuV7QPWmKj6JQdpHkGTlg==", "dependencies": [ "@babel/core", "@babel/generator", @@ -712,7 +591,6 @@ "chokidar", "cli-table3", "commander@14.0.3", - "esbuild", "jiti", "micromatch", "ms", @@ -724,8 +602,8 @@ ], "bin": true }, - "@lingui/conf@6.4.0": { - "integrity": "sha512-C+THkLbda72//6dL7QAVyaxIxOnag8mGWKqrMsyIUHgZpStE05KiF8HDwTCMNiaeQmQPym/D8fVm7m8jCau3EA==", + "@lingui/conf@6.8.0": { + "integrity": "sha512-igCpJuAA35AYHRB8w1Wy55HpcfVs9jQZCcs+7ovrRcQ0LZD7oJCTH2ZVboaIgr7B/xo4xWDF8osMrBwJf2ffag==", "dependencies": [ "jest-validate", "jiti", @@ -733,46 +611,47 @@ "normalize-path" ] }, - "@lingui/core@6.4.0": { - "integrity": "sha512-DVbgp9tn07t3iByH6snsn1WaM8PxjDacOqf45oLVXyIti98PmXadO2UkO+NgCybHSqm3+7GGV4zTr4777Cc9nA==", + "@lingui/core@6.8.0_@babel+core@7.29.7": { + "integrity": "sha512-MRjinsq93RHw5QpgKHNnDDzCQR2D+XvR/At5QbaR852JozggWYL6+IWFunQgAvz5vI3jjBtDRp+kJufXOtXQFQ==", "dependencies": [ "@lingui/babel-plugin-lingui-macro", "@lingui/message-utils" ] }, - "@lingui/format-po@6.4.0": { - "integrity": "sha512-tu5aNalW9u+xjmcAlNEjfw4ahzMHFeC4nKz+zYHcv44Drpy9/bWnrr5j7Pi4rc9u4PILtXikwAsLh28u284WiA==", + "@lingui/format-po@6.8.0": { + "integrity": "sha512-mHTItdc8Tj0AO88aJCRioIIEkbBMhwCSyQduSmzjVCtkzq0sax+SA+SScoizmVf3ecpDNufk5xexWWD4Zf0iWQ==", "dependencies": [ "@lingui/conf", "@lingui/message-utils", "pofile-ts" ] }, - "@lingui/message-utils@6.4.0": { - "integrity": "sha512-Bkt2V7vI57/CEVyJCO+93jssN2MhLJ07M6S0d16tHvigNftP5Ndl+2xX1HKWD6eozidc5lOeTZMtrEL9jzXEsg==", + "@lingui/message-utils@6.8.0": { + "integrity": "sha512-vT8mcuZ5sC0+xHF1i2iirFeg0rF0AzOEgnb7eOw+RJd4Q9tZhNQdGXxx2BeglopE9hjLM1lxVI0Wg54LSKMVCQ==", "dependencies": [ "@messageformat/date-skeleton", "@messageformat/parser", "js-sha256" ] }, - "@lingui/react@6.4.0_react@19.2.7": { - "integrity": "sha512-9ui7O/K/X+AXN7TdSEbKq//sNLAJ42dk1GftzAcHL58jKv3CnEyKOPJb2S0L/Ez6li8I0O4KKgRn1BK0Qk/Jqg==", + "@lingui/react@6.8.0_react@19.3.0_@babel+core@7.29.7": { + "integrity": "sha512-a4NZK+ol/w8z2q8Up9wRlA7rGRibTOaj4Kxe6d5eSRB4zRRaLXv4SzQUs3kPDWcsRbu2HFhoOvJQPFWjAJPRng==", "dependencies": [ "@lingui/babel-plugin-lingui-macro", "@lingui/core", - "react" + "react", + "use-sync-external-store" ] }, - "@lingui/swc-plugin@6.5.0_@lingui+core@6.4.0": { - "integrity": "sha512-cFbcAHvr6WqOjoQJmormXULzHa6MZSCYDqjXLR80YrraOefBj8qeSSylwFm0gJ7BlOm6N7lASwRRGOGEy4hBOA==", + "@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7": { + "integrity": "sha512-TRLHfeQT/q9JXy0cBGOtbfcEWJFF1szjPCVM4AT1HdZyfGj+dQ0r8hHq+08c4iXZLzlsYilzw+bZJ3TahG5g5w==", "dependencies": [ "@lingui/conf", "@lingui/core" ] }, - "@lingui/vite-plugin@6.4.0_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0": { - "integrity": "sha512-15ogZWydII5TbWwTJJGfJF9OpATxiIbJGearfBv+tj13uv3TlKwLGdmpn/GV8COY8v0irKr7dfdyfxYvxLxSZQ==", + "@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { + "integrity": "sha512-pyOGwY8llaXzBBDrbZpAV5a0z7ekz0zJxU+EGXxyqL/gLneqHgLhPCv8uNB+MyIGwrw0PC9CMtCpHVlS3oWguA==", "dependencies": [ "@babel/core", "@lingui/cli", @@ -792,167 +671,158 @@ "moo" ] }, - "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dependencies": [ - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util" - ] + "@oxc-project/types@0.151.0": { + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==" }, - "@oxc-project/types@0.137.0": { - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==" + "@remix-run/route-pattern@0.22.1": { + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==" }, "@resvg/resvg-wasm@2.6.2": { "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==" }, - "@rolldown/binding-android-arm64@1.1.3": { - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "@rolldown/binding-android-arm-eabi@1.2.11": { + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rolldown/binding-android-arm64@1.2.11": { + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "os": ["android"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-arm64@1.1.3": { - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "@rolldown/binding-darwin-arm64@1.2.11": { + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rolldown/binding-darwin-x64@1.1.3": { - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "@rolldown/binding-darwin-x64@1.2.11": { + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "os": ["darwin"], "cpu": ["x64"] }, - "@rolldown/binding-freebsd-x64@1.1.3": { - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "@rolldown/binding-freebsd-x64@1.2.11": { + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rolldown/binding-linux-arm-gnueabihf@1.1.3": { - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "@rolldown/binding-linux-arm-gnueabihf@1.2.11": { + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rolldown/binding-linux-arm64-gnu@1.1.3": { - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "@rolldown/binding-linux-arm64-gnu@1.2.11": { + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-arm64-musl@1.1.3": { - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "@rolldown/binding-linux-arm64-musl@1.2.11": { + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rolldown/binding-linux-ppc64-gnu@1.1.3": { - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "@rolldown/binding-linux-ppc64-gnu@1.2.11": { + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rolldown/binding-linux-s390x-gnu@1.1.3": { - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "@rolldown/binding-linux-s390x-gnu@1.2.11": { + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "os": ["linux"], "cpu": ["s390x"] }, - "@rolldown/binding-linux-x64-gnu@1.1.3": { - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "@rolldown/binding-linux-x64-gnu@1.2.11": { + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-linux-x64-musl@1.1.3": { - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "@rolldown/binding-linux-x64-musl@1.2.11": { + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "os": ["linux"], "cpu": ["x64"] }, - "@rolldown/binding-openharmony-arm64@1.1.3": { - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "@rolldown/binding-openharmony-arm64@1.2.11": { + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rolldown/binding-wasm32-wasi@1.1.3": { - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", - "dependencies": [ - "@emnapi/core", - "@emnapi/runtime", - "@napi-rs/wasm-runtime" - ], - "cpu": ["wasm32"] - }, - "@rolldown/binding-win32-arm64-msvc@1.1.3": { - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "@rolldown/binding-win32-arm64-msvc@1.2.11": { + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rolldown/binding-win32-x64-msvc@1.1.3": { - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "@rolldown/binding-win32-x64-msvc@1.2.11": { + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "os": ["win32"], "cpu": ["x64"] }, "@rolldown/pluginutils@1.0.1": { "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" }, - "@sinclair/typebox@0.27.10": { - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==" + "@sinclair/typebox@0.27.12": { + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==" }, - "@swc/core-darwin-arm64@1.15.41": { - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "@swc/core-darwin-arm64@1.16.2": { + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", "os": ["darwin"], "cpu": ["arm64"] }, - "@swc/core-darwin-x64@1.15.41": { - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "@swc/core-darwin-x64@1.16.2": { + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", "os": ["darwin"], "cpu": ["x64"] }, - "@swc/core-linux-arm-gnueabihf@1.15.41": { - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "@swc/core-linux-arm-gnueabihf@1.16.2": { + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", "os": ["linux"], "cpu": ["arm"] }, - "@swc/core-linux-arm64-gnu@1.15.41": { - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "@swc/core-linux-arm64-gnu@1.16.2": { + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", "os": ["linux"], "cpu": ["arm64"] }, - "@swc/core-linux-arm64-musl@1.15.41": { - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "@swc/core-linux-arm64-musl@1.16.2": { + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", "os": ["linux"], "cpu": ["arm64"] }, - "@swc/core-linux-ppc64-gnu@1.15.41": { - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "@swc/core-linux-ppc64-gnu@1.16.2": { + "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", "os": ["linux"], "cpu": ["ppc64"] }, - "@swc/core-linux-s390x-gnu@1.15.41": { - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "@swc/core-linux-s390x-gnu@1.16.2": { + "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", "os": ["linux"], "cpu": ["s390x"] }, - "@swc/core-linux-x64-gnu@1.15.41": { - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "@swc/core-linux-x64-gnu@1.16.2": { + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", "os": ["linux"], "cpu": ["x64"] }, - "@swc/core-linux-x64-musl@1.15.41": { - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "@swc/core-linux-x64-musl@1.16.2": { + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", "os": ["linux"], "cpu": ["x64"] }, - "@swc/core-win32-arm64-msvc@1.15.41": { - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "@swc/core-win32-arm64-msvc@1.16.2": { + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", "os": ["win32"], "cpu": ["arm64"] }, - "@swc/core-win32-ia32-msvc@1.15.41": { - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "@swc/core-win32-ia32-msvc@1.16.2": { + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", "os": ["win32"], "cpu": ["ia32"] }, - "@swc/core-win32-x64-msvc@1.15.41": { - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "@swc/core-win32-x64-msvc@1.16.2": { + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", "os": ["win32"], "cpu": ["x64"] }, - "@swc/core@1.15.41": { - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "@swc/core@1.16.2": { + "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", "dependencies": [ "@swc/counter", "@swc/types" @@ -976,8 +846,8 @@ "@swc/counter@0.1.3": { "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" }, - "@swc/types@0.1.27": { - "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "@swc/types@0.1.28": { + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", "dependencies": [ "@swc/counter" ] @@ -995,7 +865,7 @@ "pretty-format@27.5.1" ] }, - "@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.0.1__jsdom@30.1.1__vite@8.1.0___@types+node@26.0.1___jiti@2.7.0_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": { + "@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": { "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dependencies": [ "@adobe/css-tools", @@ -1011,7 +881,7 @@ "vitest" ] }, - "@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.2.17_@types+react-dom@19.2.3__@types+react@19.2.17_react@19.2.7_react-dom@19.2.7__react@19.2.7": { + "@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": { "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dependencies": [ "@babel/runtime", @@ -1032,12 +902,6 @@ "@testing-library/dom" ] }, - "@tybys/wasm-util@0.10.3": { - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dependencies": [ - "tslib" - ] - }, "@types/aria-query@5.0.4": { "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" }, @@ -1102,20 +966,20 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, - "@types/node@26.0.1": { - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "@types/node@26.6.3": { + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", "dependencies": [ "undici-types" ] }, - "@types/react-dom@19.2.3_@types+react@19.2.17": { - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "@types/react-dom@19.3.0_@types+react@19.3.0": { + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dependencies": [ "@types/react" ] }, - "@types/react@19.2.17": { - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "@types/react@19.3.0": { + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "dependencies": [ "csstype" ] @@ -1135,8 +999,8 @@ "@types/yargs-parser" ] }, - "@typescript-eslint/eslint-plugin@8.62.0_@typescript-eslint+parser@8.62.0__eslint@10.6.0___jiti@2.7.0__typescript@6.0.3__jiti@2.7.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1__eslint@10.11.0___jiti@2.7.0__typescript@6.0.3_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dependencies": [ "@eslint-community/regexpp", "@typescript-eslint/parser", @@ -1145,14 +1009,14 @@ "@typescript-eslint/utils", "@typescript-eslint/visitor-keys", "eslint", - "ignore@7.0.5", + "ignore@7.0.10", "natural-compare", "ts-api-utils", "typescript" ] }, - "@typescript-eslint/parser@8.62.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "@typescript-eslint/parser@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3": { + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dependencies": [ "@typescript-eslint/scope-manager", "@typescript-eslint/types", @@ -1163,8 +1027,8 @@ "typescript" ] }, - "@typescript-eslint/project-service@8.62.0_typescript@6.0.3": { - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "@typescript-eslint/project-service@8.70.1_typescript@6.0.3": { + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dependencies": [ "@typescript-eslint/tsconfig-utils", "@typescript-eslint/types", @@ -1172,21 +1036,21 @@ "typescript" ] }, - "@typescript-eslint/scope-manager@8.62.0": { - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "@typescript-eslint/scope-manager@8.70.1": { + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/visitor-keys" ] }, - "@typescript-eslint/tsconfig-utils@8.62.0_typescript@6.0.3": { - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "@typescript-eslint/tsconfig-utils@8.70.1_typescript@6.0.3": { + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dependencies": [ "typescript" ] }, - "@typescript-eslint/type-utils@8.62.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "@typescript-eslint/type-utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dependencies": [ "@typescript-eslint/types", "@typescript-eslint/typescript-estree", @@ -1197,11 +1061,11 @@ "typescript" ] }, - "@typescript-eslint/types@8.62.0": { - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==" + "@typescript-eslint/types@8.70.1": { + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==" }, - "@typescript-eslint/typescript-estree@8.62.0_typescript@6.0.3": { - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "@typescript-eslint/typescript-estree@8.70.1_typescript@6.0.3": { + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dependencies": [ "@typescript-eslint/project-service", "@typescript-eslint/tsconfig-utils", @@ -1215,8 +1079,8 @@ "typescript" ] }, - "@typescript-eslint/utils@8.62.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "@typescript-eslint/utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dependencies": [ "@eslint-community/eslint-utils", "@typescript-eslint/scope-manager", @@ -1226,8 +1090,8 @@ "typescript" ] }, - "@typescript-eslint/visitor-keys@8.62.0": { - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "@typescript-eslint/visitor-keys@8.70.1": { + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dependencies": [ "@typescript-eslint/types", "eslint-visitor-keys@5.0.1" @@ -1237,15 +1101,15 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "deprecated": true }, - "@vitejs/plugin-react-swc@4.3.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0_@types+node@26.0.1_jiti@2.7.0": { - "integrity": "sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==", + "@vitejs/plugin-react-swc@4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { + "integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==", "dependencies": [ "@rolldown/pluginutils", "@swc/core", "vite" ] }, - "@vitest/mocker@5.0.2_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": { + "@vitest/mocker@5.0.2_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": { "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", "dependencies": [ "@jridgewell/trace-mapping", @@ -1261,14 +1125,14 @@ "@vitest/spy@5.0.2": { "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==" }, - "acorn-jsx@5.3.2_acorn@8.17.0": { + "acorn-jsx@5.3.2_acorn@8.18.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ "acorn" ] }, - "acorn@8.17.0": { - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "acorn@8.18.0": { + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "bin": true }, "ajv@6.15.0": { @@ -1283,8 +1147,8 @@ "ansi-regex@5.0.1": { "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, - "ansi-regex@6.2.2": { - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + "ansi-regex@6.3.0": { + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==" }, "ansi-styles@4.3.0": { "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", @@ -1310,8 +1174,8 @@ "balanced-match@4.0.4": { "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" }, - "baseline-browser-mapping@2.10.16": { - "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "baseline-browser-mapping@2.11.26": { + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", "bin": true }, "bidi-js@1.1.0": { @@ -1320,8 +1184,8 @@ "require-from-string" ] }, - "brace-expansion@5.0.4": { - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "brace-expansion@5.0.12": { + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dependencies": [ "balanced-match" ] @@ -1332,8 +1196,8 @@ "fill-range" ] }, - "browserslist@4.28.2": { - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "browserslist@4.29.1": { + "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", "dependencies": [ "baseline-browser-mapping", "caniuse-lite", @@ -1343,11 +1207,21 @@ ], "bin": true }, + "cacheable@2.5.0": { + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dependencies": [ + "@cacheable/memory", + "@cacheable/utils", + "hookified@1.15.1", + "keyv", + "qified" + ] + }, "camelcase@6.3.0": { "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" }, - "caniuse-lite@1.0.30001786": { - "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==" + "caniuse-lite@1.0.30001812": { + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==" }, "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" @@ -1413,8 +1287,8 @@ "comma-separated-tokens@2.0.3": { "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" }, - "commander@10.0.1": { - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==" + "commander@13.1.0": { + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==" }, "commander@14.0.3": { "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" @@ -1489,8 +1363,8 @@ "dom-accessibility-api@0.6.3": { "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" }, - "electron-to-chromium@1.5.332": { - "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==" + "electron-to-chromium@1.5.439": { + "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==" }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" @@ -1510,39 +1384,6 @@ "es-module-lexer@2.3.2": { "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==" }, - "esbuild@0.25.12": { - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "optionalDependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" - ], - "scripts": true, - "bin": true - }, "escalade@3.2.0": { "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" }, @@ -1552,7 +1393,7 @@ "escape-string-regexp@5.0.0": { "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, - "eslint-plugin-react-hooks@7.1.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0": { + "eslint-plugin-react-hooks@7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dependencies": [ "@babel/core", @@ -1563,8 +1404,8 @@ "zod-validation-error" ] }, - "eslint-plugin-react-refresh@0.5.3_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0": { - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "eslint-plugin-react-refresh@0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": { + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dependencies": [ "eslint" ] @@ -1584,8 +1425,8 @@ "eslint-visitor-keys@5.0.1": { "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==" }, - "eslint@10.6.0_jiti@2.7.0": { - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "eslint@10.11.0_jiti@2.7.0": { + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dependencies": [ "@eslint-community/eslint-utils", "@eslint-community/regexpp", @@ -1683,8 +1524,8 @@ "picomatch@4.0.7" ] }, - "file-entry-cache@8.0.0": { - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "file-entry-cache@11.1.5": { + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dependencies": [ "flat-cache" ] @@ -1702,18 +1543,19 @@ "path-exists" ] }, - "flat-cache@4.0.1": { - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "flat-cache@6.1.23": { + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dependencies": [ + "cacheable", "flatted", - "keyv" + "hookified@1.15.1" ] }, "flatted@3.4.2": { "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==" }, - "frimousse@0.3.0_react@19.2.7_typescript@6.0.3": { - "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", + "frimousse@0.4.0_react@19.3.0_typescript@6.0.3": { + "integrity": "sha512-wA/1Xt9UTk51wYNpy+EvHKPUA4jdm4NuEFzSNNZxqT0Nxrj4RFwYByjRMuaq4qSLOco/ikDCpLKesB8W1w2q+Q==", "dependencies": [ "react", "typescript" @@ -1730,8 +1572,8 @@ "gensync@1.0.0-beta.2": { "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, - "get-east-asian-width@1.6.0": { - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==" + "get-east-asian-width@1.7.0": { + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==" }, "glob-parent@6.0.2": { "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", @@ -1739,12 +1581,18 @@ "is-glob" ] }, - "globals@17.7.0": { - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==" + "globals@17.12.0": { + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==" }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "hashery@1.5.1": { + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dependencies": [ + "hookified@1.15.1" + ] + }, "hast-util-to-jsx-runtime@2.3.6": { "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", "dependencies": [ @@ -1780,6 +1628,12 @@ "hermes-estree" ] }, + "hookified@1.15.1": { + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==" + }, + "hookified@2.2.0": { + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==" + }, "html-encoding-sniffer@7.0.0": { "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dependencies": [ @@ -1792,8 +1646,8 @@ "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, - "ignore@7.0.5": { - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==" + "ignore@7.0.10": { + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==" }, "imurmurhash@0.1.4": { "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" @@ -1903,9 +1757,6 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": true }, - "json-buffer@3.0.1": { - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" - }, "json-schema-traverse@0.4.1": { "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, @@ -1916,10 +1767,10 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, - "keyv@4.5.4": { - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "keyv@5.6.0": { + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dependencies": [ - "json-buffer" + "@keyv/serialize" ] }, "leven@3.1.0": { @@ -1932,63 +1783,63 @@ "type-check" ] }, - "lightningcss-android-arm64@1.32.0": { - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "lightningcss-android-arm64@1.33.0": { + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "os": ["android"], "cpu": ["arm64"] }, - "lightningcss-darwin-arm64@1.32.0": { - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "lightningcss-darwin-arm64@1.33.0": { + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "os": ["darwin"], "cpu": ["arm64"] }, - "lightningcss-darwin-x64@1.32.0": { - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "lightningcss-darwin-x64@1.33.0": { + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "os": ["darwin"], "cpu": ["x64"] }, - "lightningcss-freebsd-x64@1.32.0": { - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "lightningcss-freebsd-x64@1.33.0": { + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "os": ["freebsd"], "cpu": ["x64"] }, - "lightningcss-linux-arm-gnueabihf@1.32.0": { - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "lightningcss-linux-arm-gnueabihf@1.33.0": { + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "os": ["linux"], "cpu": ["arm"] }, - "lightningcss-linux-arm64-gnu@1.32.0": { - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "lightningcss-linux-arm64-gnu@1.33.0": { + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "os": ["linux"], "cpu": ["arm64"] }, - "lightningcss-linux-arm64-musl@1.32.0": { - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "lightningcss-linux-arm64-musl@1.33.0": { + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "os": ["linux"], "cpu": ["arm64"] }, - "lightningcss-linux-x64-gnu@1.32.0": { - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "lightningcss-linux-x64-gnu@1.33.0": { + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "os": ["linux"], "cpu": ["x64"] }, - "lightningcss-linux-x64-musl@1.32.0": { - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "lightningcss-linux-x64-musl@1.33.0": { + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "os": ["linux"], "cpu": ["x64"] }, - "lightningcss-win32-arm64-msvc@1.32.0": { - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "lightningcss-win32-arm64-msvc@1.33.0": { + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "os": ["win32"], "cpu": ["arm64"] }, - "lightningcss-win32-x64-msvc@1.32.0": { - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "lightningcss-win32-x64-msvc@1.33.0": { + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "os": ["win32"], "cpu": ["x64"] }, - "lightningcss@1.32.0": { - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "lightningcss@1.33.0": { + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dependencies": [ "detect-libc" ], @@ -2047,8 +1898,8 @@ "markdown-table@3.0.4": { "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" }, - "marked@18.0.5": { - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "marked@18.0.14": { + "integrity": "sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==", "bin": true }, "mdast-util-find-and-replace@3.0.2": { @@ -2464,7 +2315,7 @@ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": [ "braces", - "picomatch@2.3.1" + "picomatch@2.3.2" ] }, "mimic-function@5.0.1": { @@ -2473,8 +2324,8 @@ "min-indent@1.0.1": { "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" }, - "minimatch@10.2.4": { - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "minimatch@10.2.6": { + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dependencies": [ "brace-expansion" ] @@ -2485,18 +2336,18 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "nanoid@3.3.14": { - "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "nanoid@3.3.19": { + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "bin": true }, "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, - "node-releases@2.0.36": { - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" + "node-releases@2.0.57": { + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==" }, - "nodemailer@9.0.1": { - "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==" + "nodemailer@10.0.10": { + "integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==" }, "normalize-path@3.0.0": { "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" @@ -2521,8 +2372,8 @@ "word-wrap" ] }, - "ora@9.4.0": { - "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "ora@9.4.1": { + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", "dependencies": [ "chalk@5.6.2", "cli-cursor", @@ -2531,7 +2382,7 @@ "is-unicode-supported", "log-symbols", "stdin-discarder", - "string-width@8.2.1" + "string-width@8.3.0" ] }, "p-limit@3.1.0": { @@ -2576,8 +2427,8 @@ "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, - "picomatch@2.3.1": { - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + "picomatch@2.3.2": { + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" }, "picomatch@4.0.7": { "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==" @@ -2585,8 +2436,8 @@ "pofile-ts@4.0.3": { "integrity": "sha512-sz1pnjgEfPyZ+QvaeX3NtCmbYnEvG01LZRLoN/uXoLtPZtxCIH5IctL7yXXc0fFyk/fqV6K8g3hlNfr6IJwupA==" }, - "postcss@8.5.15": { - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "postcss@8.5.28": { + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dependencies": [ "nanoid", "picocolors", @@ -2615,27 +2466,37 @@ "property-information@7.1.0": { "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" }, - "pseudolocale@2.2.0": { - "integrity": "sha512-O+D2eU7fO9wVLqrohvt9V/9fwMadnJQ4jxwiK+LeNEqhMx8JYx4xQHkArDCJFAdPPOp/pQq6z5L37eBvAoc8jw==", + "pseudolocale@3.1.0": { + "integrity": "sha512-jBidehQu5WO8zbFUod0ETJYoVPxZ0ixsdI9B8ilkE0eA+YTydxHA7ujNdU4YhTexD5yH5Griv9r5CuXx+nwFUA==", "dependencies": [ - "commander@10.0.1" + "commander@13.1.0" ], "bin": true }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, - "react-dom@19.2.7_react@19.2.7": { - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "qified@0.10.1": { + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dependencies": [ + "hookified@2.2.0" + ] + }, + "react-dom@19.3.0_react@19.3.0": { + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "dependencies": [ "react", "scheduler" ] }, - "react-hook-form@7.80.0_react@19.2.7": { - "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "react-hook-form@7.89.0_@types+react@19.3.0_react@19.3.0": { + "integrity": "sha512-vKcoCfy8RKZDrhSdqsRFp0uEOdS1AhLjOL6hR7Wmfzg1akNxEhaPf5WORx8SgEQPVXakdDyDkqQp+KNxVnnPHg==", "dependencies": [ + "@types/react", "react" + ], + "optionalPeers": [ + "@types/react" ] }, "react-is@17.0.2": { @@ -2644,7 +2505,7 @@ "react-is@18.3.1": { "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, - "react-markdown@10.1.0_@types+react@19.2.17_react@19.2.7": { + "react-markdown@10.1.0_@types+react@19.3.0_react@19.3.0": { "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "dependencies": [ "@types/hast", @@ -2662,9 +2523,10 @@ "vfile" ] }, - "react-router@8.0.1_react@19.2.7_react-dom@19.2.7__react@19.2.7": { - "integrity": "sha512-5EL/fANovVUhRK50NLS8RYfX0BxrimoKsHWUPPy8v5UEl8i6vzF7e4POo3u+AhPItDwccUAJjMfIOmydxBJmQw==", + "react-router@8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": { + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", "dependencies": [ + "@remix-run/route-pattern", "cookie-es", "react", "react-dom" @@ -2673,11 +2535,11 @@ "react-dom" ] }, - "react@19.2.7": { - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==" + "react@19.3.0": { + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==" }, - "readdirp@5.0.0": { - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==" + "readdirp@5.1.1": { + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==" }, "redent@3.0.0": { "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", @@ -2734,13 +2596,14 @@ "signal-exit" ] }, - "rolldown@1.1.3": { - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "rolldown@1.2.11": { + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dependencies": [ "@oxc-project/types", "@rolldown/pluginutils" ], "optionalDependencies": [ + "@rolldown/binding-android-arm-eabi", "@rolldown/binding-android-arm64", "@rolldown/binding-darwin-arm64", "@rolldown/binding-darwin-x64", @@ -2753,7 +2616,6 @@ "@rolldown/binding-linux-x64-gnu", "@rolldown/binding-linux-x64-musl", "@rolldown/binding-openharmony-arm64", - "@rolldown/binding-wasm32-wasi", "@rolldown/binding-win32-arm64-msvc", "@rolldown/binding-win32-x64-msvc" ], @@ -2765,8 +2627,8 @@ "xmlchars" ] }, - "scheduler@0.27.0": { - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + "scheduler@0.28.0": { + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==" }, "semver@6.3.1": { "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", @@ -2811,8 +2673,8 @@ "strip-ansi@6.0.1" ] }, - "string-width@8.2.1": { - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "string-width@8.3.0": { + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dependencies": [ "get-east-asian-width", "strip-ansi@7.2.0" @@ -2834,7 +2696,7 @@ "strip-ansi@7.2.0": { "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dependencies": [ - "ansi-regex@6.2.2" + "ansi-regex@6.3.0" ] }, "strip-indent@3.0.0": { @@ -2874,8 +2736,8 @@ "picomatch@4.0.7" ] }, - "tinypool@2.1.0": { - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==" + "tinypool@2.2.0": { + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==" }, "tldts-core@7.4.15": { "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==" @@ -2917,17 +2779,14 @@ "typescript" ] }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, "type-check@0.4.0": { "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dependencies": [ "prelude-ls" ] }, - "typescript-eslint@8.62.0_eslint@10.6.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { - "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "typescript-eslint@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": { + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dependencies": [ "@typescript-eslint/eslint-plugin", "@typescript-eslint/parser", @@ -2941,8 +2800,8 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "bin": true }, - "undici-types@8.3.0": { - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + "undici-types@8.9.0": { + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==" }, "undici@8.11.2": { "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==" @@ -2992,8 +2851,8 @@ "unist-util-visit-parents" ] }, - "update-browserslist-db@1.2.3_browserslist@4.28.2": { - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "update-browserslist-db@1.3.3_browserslist@4.29.1": { + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dependencies": [ "browserslist", "escalade", @@ -3007,6 +2866,12 @@ "punycode" ] }, + "use-sync-external-store@1.7.0_react@19.3.0": { + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dependencies": [ + "react" + ] + }, "vfile-message@4.0.3": { "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "dependencies": [ @@ -3021,8 +2886,8 @@ "vfile-message" ] }, - "vite@8.1.0_@types+node@26.0.1_jiti@2.7.0": { - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": { + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dependencies": [ "@types/node", "jiti", @@ -3041,7 +2906,7 @@ ], "bin": true }, - "vitest@5.0.2_@types+node@26.0.1_jsdom@30.1.1_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": { + "vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_jiti@2.7.0": { "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", "dependencies": [ "@types/chai", @@ -3121,8 +2986,8 @@ "yocto-queue@0.1.0": { "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, - "yoctocolors@2.1.2": { - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==" + "yoctocolors@2.2.0": { + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==" }, "zod-validation-error@4.0.2_zod@4.4.3": { "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", @@ -3141,13 +3006,13 @@ "dependencies": [ "jsr:@db/sqlite@0.13", "jsr:@oak/oak@^17.2.0", - "jsr:@panva/jose@^6.2.3", + "jsr:@panva/jose@^6.2.12", "jsr:@std/assert@^1.0.19", "jsr:@std/testing@^1.0.21", "jsr:@tajpouria/cors@^1.2.1", "npm:@resvg/resvg-wasm@^2.6.2", - "npm:marked@^18.0.5", - "npm:nodemailer@^9.0.1" + "npm:marked@^18.0.14", + "npm:nodemailer@^10.0.10" ], "packageJson": { "dependencies": [ @@ -3157,38 +3022,38 @@ "npm:@fontsource-variable/saira@5.3.0", "npm:@fontsource-variable/space-grotesk@5.3.0", "npm:@fontsource/instrument-serif@5.3.0", - "npm:@lingui/cli@6.4.0", - "npm:@lingui/conf@6.4.0", - "npm:@lingui/core@6.4.0", - "npm:@lingui/format-po@6.4.0", - "npm:@lingui/react@6.4.0", - "npm:@lingui/swc-plugin@6.5.0", - "npm:@lingui/vite-plugin@6.4.0", + "npm:@lingui/cli@6.8.0", + "npm:@lingui/conf@6.8.0", + "npm:@lingui/core@6.8.0", + "npm:@lingui/format-po@6.8.0", + "npm:@lingui/react@6.8.0", + "npm:@lingui/swc-plugin@6.7.0", + "npm:@lingui/vite-plugin@6.8.0", "npm:@testing-library/dom@^10.4.2", "npm:@testing-library/jest-dom@^7.0.1", "npm:@testing-library/react@^16.3.3", "npm:@testing-library/user-event@^14.6.7", - "npm:@types/node@^26.0.1", - "npm:@types/react-dom@^19.2.3", - "npm:@types/react@^19.2.17", - "npm:@vitejs/plugin-react-swc@^4.3.1", + "npm:@types/node@^26.6.3", + "npm:@types/react-dom@^19.3.0", + "npm:@types/react@^19.3.0", + "npm:@vitejs/plugin-react-swc@^4.3.3", "npm:emojibase-data@17.0.0", "npm:eslint-plugin-react-hooks@^7.1.1", - "npm:eslint-plugin-react-refresh@~0.5.3", - "npm:eslint@^10.6.0", - "npm:frimousse@0.3", - "npm:globals@^17.7.0", + "npm:eslint-plugin-react-refresh@~0.5.7", + "npm:eslint@^10.11.0", + "npm:frimousse@0.4", + "npm:globals@^17.12.0", "npm:jiti@^2.7.0", "npm:jsdom@^30.1.1", - "npm:react-dom@^19.2.7", - "npm:react-hook-form@^7.80.0", + "npm:react-dom@^19.3.0", + "npm:react-hook-form@^7.89.0", "npm:react-markdown@^10.1.0", - "npm:react-router@^8.0.1", - "npm:react@^19.2.7", + "npm:react-router@^8.4.0", + "npm:react@^19.3.0", "npm:remark-gfm@^4.0.1", - "npm:typescript-eslint@^8.62.0", + "npm:typescript-eslint@^8.70.1", "npm:typescript@~6.0.3", - "npm:vite@^8.1.0", + "npm:vite@^8.3.1", "npm:vitest@^5.0.2" ] } diff --git a/package.json b/package.json index 9057c20e..e2d4437f 100644 --- a/package.json +++ b/package.json @@ -15,41 +15,41 @@ "@fontsource-variable/saira": "5.3.0", "@fontsource-variable/space-grotesk": "5.3.0", "@fontsource/instrument-serif": "5.3.0", - "@lingui/cli": "6.4.0", - "@lingui/conf": "6.4.0", - "@lingui/core": "6.4.0", - "@lingui/format-po": "6.4.0", - "@lingui/react": "6.4.0", - "@types/react": "^19.2.17", - "@lingui/swc-plugin": "6.5.0", - "@vitejs/plugin-react-swc": "^4.3.1", + "@lingui/cli": "6.8.0", + "@lingui/conf": "6.8.0", + "@lingui/core": "6.8.0", + "@lingui/format-po": "6.8.0", + "@lingui/react": "6.8.0", + "@types/react": "^19.3.0", + "@lingui/swc-plugin": "6.7.0", + "@vitejs/plugin-react-swc": "^4.3.3", "emojibase-data": "17.0.0", - "frimousse": "^0.3.0", + "frimousse": "^0.4.0", "jiti": "^2.7.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-hook-form": "^7.80.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-hook-form": "^7.89.0", "react-markdown": "^10.1.0", - "react-router": "^8.0.1", + "react-router": "^8.4.0", "remark-gfm": "^4.0.1" }, "devDependencies": { - "@lingui/vite-plugin": "6.4.0", + "@lingui/vite-plugin": "6.8.0", "@eslint/js": "^10.0.1", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", - "@types/node": "^26.0.1", - "@types/react-dom": "^19.2.3", - "eslint": "^10.6.0", + "@types/node": "^26.6.3", + "@types/react-dom": "^19.3.0", + "eslint": "^10.11.0", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", "jsdom": "^30.1.1", "typescript": "~6.0.3", - "typescript-eslint": "^8.62.0", - "vite": "^8.1.0", + "typescript-eslint": "^8.70.1", + "vite": "^8.3.1", "vitest": "^5.0.2" } } diff --git a/src/App.css b/src/App.css index 607ae470..8e9da460 100644 --- a/src/App.css +++ b/src/App.css @@ -432,7 +432,8 @@ font-weight: 400; font-style: normal; line-height: 1.5; - transition: border-color var(--dur-slow), box-shadow var(--dur-slow), background-color var(--dur-slow); + transition: border-color var(--dur-slow), box-shadow var(--dur-slow), + background-color var(--dur-slow); outline: none; } @@ -710,13 +711,25 @@ } @keyframes dump-panel-in { - from { opacity: 0; transform: translateX(12px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: none; + } } @keyframes dump-panel-in-back { - from { opacity: 0; transform: translateX(-12px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: translateX(-12px); + } + to { + opacity: 1; + transform: none; + } } @media (prefers-reduced-motion: reduce) { @@ -912,8 +925,12 @@ } @keyframes dump-upload-slide { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(250%); } + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } } @media (prefers-reduced-motion: reduce) { @@ -1593,7 +1610,8 @@ body.has-player { opacity: 0; transform: translateY(1rem) scale(0.9); pointer-events: none; - transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, background var(--dur-base) ease, + transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, + background var(--dur-base) ease, top 0.4s cubic-bezier(0.4, 0, 0.2, 1); } @@ -1670,7 +1688,8 @@ body.has-fab .page-content { opacity: 0; transform: translateY(1rem) scale(0.9); pointer-events: none; - transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, background var(--dur-base) ease, + transition: opacity var(--dur-slow) ease, transform var(--dur-slow) ease, + background var(--dur-base) ease, top 0.4s cubic-bezier(0.4, 0, 0.2, 1); } @@ -1889,7 +1908,8 @@ body.has-player .chat-fab { font-size: var(--text-sm); font-weight: 600; cursor: pointer; - transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base); + transition: border-color var(--dur-base), background var(--dur-base), color + var(--dur-base); white-space: nowrap; flex-shrink: 0; font-variant-numeric: tabular-nums; @@ -2371,7 +2391,8 @@ body.has-player .chat-fab { font-weight: 500; cursor: pointer; box-sizing: border-box; - transition: background var(--dur-base), border-color var(--dur-base), color var(--dur-base); + transition: background var(--dur-base), border-color var(--dur-base), color + var(--dur-base); } .profile-email-btn--save { @@ -2579,7 +2600,8 @@ body.has-player .chat-fab { font-size: var(--text-sm); font-family: inherit; cursor: pointer; - transition: border-color var(--dur-base), color var(--dur-base), background var(--dur-base); + transition: border-color var(--dur-base), color var(--dur-base), background + var(--dur-base); } .btn-border:hover { @@ -3598,7 +3620,8 @@ body.has-player .chat-fab { font-weight: 600; letter-spacing: var(--tab-tracking); text-transform: var(--tab-text-transform); - transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base); + transition: border-color var(--dur-base), background var(--dur-base), color + var(--dur-base); white-space: nowrap; flex-shrink: 0; } @@ -3837,10 +3860,10 @@ body.has-player .chat-fab { background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)), color-mix( - in srgb, - var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%), - var(--color-surface) - ); + in srgb, + var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%), + var(--color-surface) + ); } .thumb-placeholder-icon { @@ -3956,8 +3979,7 @@ body.has-player .chat-fab { opacity: 1; } -.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) - .rich-content-play-overlay, +.dump-card-preview .rich-content-thumbnail-btn:is(:hover, :focus-visible) .rich-content-play-overlay, .rich-content-compact:is(:hover, :focus-visible) .rich-content-open-overlay { transform: scale(1.2); background: rgba(0, 0, 0, 0.8); @@ -3965,8 +3987,7 @@ body.has-player .chat-fab { } /* The one playing keeps its badge lit, in the accent, showing ⏸. */ -.dump-card-preview .rich-content-thumbnail-btn.is-playing - .rich-content-play-overlay { +.dump-card-preview .rich-content-thumbnail-btn.is-playing .rich-content-play-overlay { background: var(--color-accent); color: var(--color-on-accent); } @@ -5100,7 +5121,8 @@ body.has-player .chat-fab { font-size: var(--text-sm); padding: 0.25rem 0.7rem; opacity: 0.7; - transition: opacity var(--dur-base), border-color var(--dur-base), color var(--dur-base); + transition: opacity var(--dur-base), border-color var(--dur-base), color + var(--dur-base); } .playlist-edit-btn:hover, @@ -5188,7 +5210,8 @@ body.has-player .chat-fab { font-size: var(--text-xs); padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); - transition: opacity var(--dur-base), border-color var(--dur-base), color var(--dur-base); + transition: opacity var(--dur-base), border-color var(--dur-base), color + var(--dur-base); white-space: nowrap; } @@ -5496,7 +5519,8 @@ body.has-player .chat-fab { color: var(--color-text-muted); cursor: pointer; font-family: inherit; - transition: color var(--dur-fast), border-color var(--dur-fast), background var(--dur-fast); + transition: color var(--dur-fast), border-color var(--dur-fast), background + var(--dur-fast); } .comment-action-btn:hover { @@ -5920,7 +5944,8 @@ body.has-player .chat-fab { overflow: hidden; cursor: pointer; min-width: 0; - transition: border-color var(--dur-base), transform var(--dur-base), box-shadow var(--dur-base); + transition: border-color var(--dur-base), transform var(--dur-base), + box-shadow var(--dur-base); } .journal-card:hover { @@ -6007,8 +6032,7 @@ body.has-player .chat-fab { background: rgba(0, 0, 0, 0.75); } -.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) - .rich-content-play-overlay { +.journal-card--image:has(.journal-card-title:hover, .journal-card-vote:hover) .rich-content-play-overlay { transform: none; background: rgba(0, 0, 0, 0.6); } @@ -6801,8 +6825,16 @@ body.has-player .chat-fab { } @keyframes tooltip-in { - from { opacity: 0; translate: 0 4px; scale: 0.96; } - to { opacity: 1; translate: 0 0; scale: 1; } + from { + opacity: 0; + translate: 0 4px; + scale: 0.96; + } + to { + opacity: 1; + translate: 0 0; + scale: 1; + } } @media (prefers-reduced-motion: reduce) { @@ -6810,7 +6842,9 @@ body.has-player .chat-fab { animation-name: tooltip-fade; } @keyframes tooltip-fade { - from { opacity: 0; } + from { + opacity: 0; + } } } @@ -7172,7 +7206,8 @@ body.has-player .chat-fab { font-family: inherit; line-height: 1.4; cursor: pointer; - transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base), transform var(--dur-fast); + transition: border-color var(--dur-base), background var(--dur-base), color + var(--dur-base), transform var(--dur-fast); } .category-chip:hover:not(:disabled) { @@ -7282,7 +7317,8 @@ body.has-player .chat-fab { font-size: var(--text-xs); line-height: 1; cursor: pointer; - transition: background var(--dur-base), color var(--dur-base), filter var(--dur-base); + transition: background var(--dur-base), color var(--dur-base), filter + var(--dur-base); } .category-editor-btn:hover:not(:disabled) { diff --git a/src/App.tsx b/src/App.tsx index 72358f01..ae4e9fa9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,7 +100,10 @@ function CategoryRedirect() { const { categorySlug } = useParams(); const { search } = useLocation(); return ( - + ); } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index c4a168c0..40e27615 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -108,7 +108,8 @@ export function AppHeader( <>
- {SITE_EMOJI} + {SITE_EMOJI} + {" "} {SITE_NAME} @@ -179,8 +180,10 @@ export function AppHeader(
- {/* Stays mounted so the row can slide open and shut; `inert` keeps the - collapsed input out of the tab order. */} + { + /* Stays mounted so the row can slide open and shut; `inert` keeps the + collapsed input out of the tab order. */ + }
({ mode: "onChange", - defaultValues: { currentPassword: "", newPassword: "", confirmPassword: "" }, + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, }); const onSubmit = form.submit(async ({ currentPassword, newPassword }) => { diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 2456b824..f494b0ba 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -410,7 +410,11 @@ export function DumpCreateModal( }) .catch(() => { if (signal.aborted) return; - setUrlFetchState({ status: "done", reached: false, richContent: null }); + setUrlFetchState({ + status: "done", + reached: false, + richContent: null, + }); }); // Signed in, so that a private dump of the poster's own counts as a @@ -845,8 +849,10 @@ export function DumpCreateModal( )} - {/* Hidden only while a link is being written: an empty - panel offers both ways in at once, no mode to pick. */} + { + /* Hidden only while a link is being written: an empty + panel offers both ways in at once, no mode to pick. */ + } {!(mode === "url" && url.trim()) && ( name="file" @@ -874,8 +880,10 @@ export function DumpCreateModal( ) : ( <> - {/* The link itself is a panel back, so name the thing - these choices are about. */} + { + /* The link itself is a panel back, so name the thing + these choices are about. */ + }

e.stopPropagation()} + > ); - const className = - `journal-card journal-card--${shape} journal-card--${mode}`; + const className = `journal-card journal-card--${shape} journal-card--${mode}`; if (mode === "image") { // feature shows its note; tall/wide/square keep the image as the subject. diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index f99b5f59..fb0a7b23 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -146,9 +146,7 @@ export function Markdown( > {children} diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 72b78d85..99c2902d 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -172,7 +172,8 @@ function SeekBar( step={0.01} value={current} // Pointer events, so a finger holds the thumb as a mouse does. - onPointerDown={() => onDragChange(true)} + onPointerDown={() => + onDragChange(true)} onPointerUp={() => onDragChange(false)} onPointerCancel={() => onDragChange(false)} onChange={(e) => onSeek(Number(e.target.value))} diff --git a/src/components/Thumbnail.tsx b/src/components/Thumbnail.tsx index 9ce0a9a6..ee971571 100644 --- a/src/components/Thumbnail.tsx +++ b/src/components/Thumbnail.tsx @@ -29,8 +29,14 @@ interface ThumbnailProps { * an empty framed box and no signal anywhere that a load had failed. */ export default function Thumbnail( - { src, alt = "", className, loading = "lazy", placeholder, placeholderClassName }: - ThumbnailProps, + { + src, + alt = "", + className, + loading = "lazy", + placeholder, + placeholderClassName, + }: ThumbnailProps, ) { const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">( "direct", diff --git a/src/components/ThumbnailPlaceholder.tsx b/src/components/ThumbnailPlaceholder.tsx index 7b0c41bb..06486819 100644 --- a/src/components/ThumbnailPlaceholder.tsx +++ b/src/components/ThumbnailPlaceholder.tsx @@ -40,8 +40,9 @@ export default function ThumbnailPlaceholder( setIconFailed(false); } - const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as - React.CSSProperties; + const style = { + "--thumb-tint": tintFor({ accentColor, url, seed }), + } as React.CSSProperties; return (
; + return ( + + ); } diff --git a/src/components/form/useApiForm.ts b/src/components/form/useApiForm.ts index a3acb279..e26ca195 100644 --- a/src/components/form/useApiForm.ts +++ b/src/components/form/useApiForm.ts @@ -10,7 +10,8 @@ import { import { friendlyFetchError } from "../../utils/apiError.ts"; import { parseAPIResponse } from "../../model.ts"; -export interface UseApiFormReturn extends UseFormReturn { +export interface UseApiFormReturn + extends UseFormReturn { /** * Wraps an async submit handler with the shared API-form lifecycle: clears the * previous root error, runs the handler while RHF keeps `formState.isSubmitting` diff --git a/src/contexts/CategoriesProvider.tsx b/src/contexts/CategoriesProvider.tsx index 484e4e85..5b0e9a53 100644 --- a/src/contexts/CategoriesProvider.tsx +++ b/src/contexts/CategoriesProvider.tsx @@ -1,4 +1,10 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { CategoriesContext, type CategoriesContextValue, diff --git a/src/contexts/FollowContext.ts b/src/contexts/FollowContext.ts index 60d2e675..72f02ef6 100644 --- a/src/contexts/FollowContext.ts +++ b/src/contexts/FollowContext.ts @@ -10,7 +10,8 @@ export interface FollowContextValue { isLoaded: boolean; } -export const FollowContext = createContext({ +/** What a consumer outside any provider gets. */ +export const defaultFollowValue: FollowContextValue = { followedUserIds: new Set(), followedPlaylistIds: new Set(), followUser: async () => {}, @@ -18,4 +19,8 @@ export const FollowContext = createContext({ followPlaylist: async () => {}, unfollowPlaylist: async () => {}, isLoaded: false, -}); +}; + +export const FollowContext = createContext( + defaultFollowValue, +); diff --git a/src/contexts/PlayerContext.ts b/src/contexts/PlayerContext.ts index 617b978d..40f27236 100644 --- a/src/contexts/PlayerContext.ts +++ b/src/contexts/PlayerContext.ts @@ -111,7 +111,8 @@ export interface PlayerContextValue { onError(): void; } -export const PlayerContext = createContext({ +/** What a consumer outside any provider gets. */ +export const defaultPlayerValue: PlayerContextValue = { current: null, playing: false, currentTime: 0, @@ -137,4 +138,8 @@ export const PlayerContext = createContext({ onTimeUpdate: () => {}, onEnded: () => {}, onError: () => {}, -}); +}; + +export const PlayerContext = createContext( + defaultPlayerValue, +); diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts index f239ed63..27744e80 100644 --- a/src/contexts/WSContext.ts +++ b/src/contexts/WSContext.ts @@ -129,7 +129,8 @@ export interface WSContextValue { setChatOpen: (open: boolean) => void; } -export const WSContext = createContext({ +/** What a consumer outside any provider gets. */ +export const defaultWSValue: WSContextValue = { wsStatus: "connecting", wsErrorMessage: null, onlineUsers: [], @@ -172,4 +173,6 @@ export const WSContext = createContext({ reconcileChat: () => {}, setChatTyping: () => {}, setChatOpen: () => {}, -}); +}; + +export const WSContext = createContext(defaultWSValue); diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 6d08b132..915d3666 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -822,79 +822,88 @@ export function WSProvider({ children }: WSProviderProps) { // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT }, [schedulePendingVote]); - const castCommentLike = useCallback((commentId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. - const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; - const prevLiked = myLikesRef.current.has(commentId); - if (prevLiked) return; // already liked + const castCommentLike = useCallback( + (commentId: string, shownCount?: number) => { + // Optimistic update. Until a live event arrives there's no count here, only + // the one the caller got over REST and is showing. + const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; + const prevLiked = myLikesRef.current.has(commentId); + if (prevLiked) return; // already liked - setMyLikes((prev) => { - const n = new Set(prev); - n.add(commentId); - return n; - }); - setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 })); - - // Schedule revert if no authoritative confirmation arrives. - schedulePendingLike(commentId, () => { - setMyLikes((prev) => { - const n = new Set(prev); - n.delete(commentId); - return n; - }); - setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount })); - }); - - if (socketRef.current?.readyState === WebSocket.OPEN) { - socketRef.current.send( - JSON.stringify( - { type: "comment_like_cast", commentId } satisfies OutgoingWSMessage, - ), - ); - } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT - }, [schedulePendingLike]); - - const removeCommentLike = useCallback((commentId: string, shownCount?: number) => { - // Optimistic update. Until a live event arrives there's no count here, only - // the one the caller got over REST and is showing. - const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; - const prevLiked = myLikesRef.current.has(commentId); - if (!prevLiked) return; // not liked - - setMyLikes((prev) => { - const n = new Set(prev); - n.delete(commentId); - return n; - }); - setLikeCounts((prev) => ({ - ...prev, - [commentId]: Math.max(0, prevCount - 1), - })); - - // Schedule revert if no authoritative confirmation arrives. - schedulePendingLike(commentId, () => { setMyLikes((prev) => { const n = new Set(prev); n.add(commentId); return n; }); - setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount })); - }); + setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount + 1 })); - if (socketRef.current?.readyState === WebSocket.OPEN) { - socketRef.current.send( - JSON.stringify( - { - type: "comment_like_remove", - commentId, - } satisfies OutgoingWSMessage, - ), - ); - } - // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT - }, [schedulePendingLike]); + // Schedule revert if no authoritative confirmation arrives. + schedulePendingLike(commentId, () => { + setMyLikes((prev) => { + const n = new Set(prev); + n.delete(commentId); + return n; + }); + setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount })); + }); + + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { + type: "comment_like_cast", + commentId, + } satisfies OutgoingWSMessage, + ), + ); + } + // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT + }, + [schedulePendingLike], + ); + + const removeCommentLike = useCallback( + (commentId: string, shownCount?: number) => { + // Optimistic update. Until a live event arrives there's no count here, only + // the one the caller got over REST and is showing. + const prevCount = likeCountsRef.current[commentId] ?? shownCount ?? 0; + const prevLiked = myLikesRef.current.has(commentId); + if (!prevLiked) return; // not liked + + setMyLikes((prev) => { + const n = new Set(prev); + n.delete(commentId); + return n; + }); + setLikeCounts((prev) => ({ + ...prev, + [commentId]: Math.max(0, prevCount - 1), + })); + + // Schedule revert if no authoritative confirmation arrives. + schedulePendingLike(commentId, () => { + setMyLikes((prev) => { + const n = new Set(prev); + n.add(commentId); + return n; + }); + setLikeCounts((prev) => ({ ...prev, [commentId]: prevCount })); + }); + + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { + type: "comment_like_remove", + commentId, + } satisfies OutgoingWSMessage, + ), + ); + } + // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT + }, + [schedulePendingLike], + ); const injectDump = useCallback((dump: Dump) => { setRecentDumps((prev) => { diff --git a/src/fonts.css b/src/fonts.css index 42ad5115..4b39fee5 100644 --- a/src/fonts.css +++ b/src/fonts.css @@ -10,7 +10,8 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -18,7 +19,8 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/saira/files/saira-latin-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/saira/files/saira-latin-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @font-face { @@ -26,7 +28,8 @@ font-style: italic; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/saira/files/saira-latin-ext-wght-italic.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -34,7 +37,8 @@ font-style: italic; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/saira/files/saira-latin-wght-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/saira/files/saira-latin-wght-italic.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @@ -44,7 +48,8 @@ font-style: normal; font-display: swap; font-weight: 300 700; - src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-ext-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-ext-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -52,7 +57,8 @@ font-style: normal; font-display: swap; font-weight: 300 700; - src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @@ -62,7 +68,8 @@ font-style: normal; font-display: swap; font-weight: 400; - src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-normal.woff2) format('woff2'); + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-normal.woff2) + format('woff2'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -70,7 +77,8 @@ font-style: normal; font-display: swap; font-weight: 400; - src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2) format('woff2'); + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-normal.woff2) + format('woff2'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @font-face { @@ -78,7 +86,8 @@ font-style: italic; font-display: swap; font-weight: 400; - src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-italic.woff2) format('woff2'); + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-ext-400-italic.woff2) + format('woff2'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -86,7 +95,8 @@ font-style: italic; font-display: swap; font-weight: 400; - src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2) format('woff2'); + src: url(@fontsource/instrument-serif/files/instrument-serif-latin-400-italic.woff2) + format('woff2'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @@ -96,7 +106,8 @@ font-style: normal; font-display: swap; font-weight: 100 800; - src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -104,7 +115,8 @@ font-style: normal; font-display: swap; font-weight: 100 800; - src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @font-face { @@ -112,7 +124,8 @@ font-style: italic; font-display: swap; font-weight: 100 800; - src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-ext-wght-italic.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -120,7 +133,8 @@ font-style: italic; font-display: swap; font-weight: 100 800; - src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-italic.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @@ -130,7 +144,8 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -138,7 +153,8 @@ font-style: normal; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2) format('woff2-variations'); + src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } @font-face { @@ -146,7 +162,8 @@ font-style: italic; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2) + format('woff2-variations'); unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } @font-face { @@ -154,6 +171,7 @@ font-style: italic; font-display: swap; font-weight: 100 900; - src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2) format('woff2-variations'); + src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2) + format('woff2-variations'); unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } diff --git a/src/hooks/useAnchoredFloat.ts b/src/hooks/useAnchoredFloat.ts index 59733472..f5d3d5ae 100644 --- a/src/hooks/useAnchoredFloat.ts +++ b/src/hooks/useAnchoredFloat.ts @@ -64,8 +64,11 @@ export function useAnchoredFloat( const w = Math.min(width === "anchor" ? r.width : width, vw - MARGIN * 2); const content = Math.max( minContentHeight, - Math.min(maxContentHeight, (placeAbove ? roomAbove : roomBelow) - - chromeHeight), + Math.min( + maxContentHeight, + (placeAbove ? roomAbove : roomBelow) - + chromeHeight, + ), ); // Anchoring by `bottom` when above means the panel's own height never @@ -101,7 +104,14 @@ export function useAnchoredFloat( globalThis.removeEventListener("resize", place); observer.disconnect(); }; - }, [open, anchorRef, width, chromeHeight, minContentHeight, maxContentHeight]); + }, [ + open, + anchorRef, + width, + chromeHeight, + minContentHeight, + maxContentHeight, + ]); // Gated on `open` rather than cleared on close: the stale value is never // painted, since the layout effect re-places the panel before the next paint. diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index c45065d2..14688894 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -116,8 +116,14 @@ export const useAuth = () => { }); // Mirrors what `fetch` throws on a network failure, so the shared // `friendlyFetchError` path renders it the same way. - xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch"))); - xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch"))); + xhr.addEventListener( + "error", + () => reject(new TypeError("Failed to fetch")), + ); + xhr.addEventListener( + "abort", + () => reject(new TypeError("Failed to fetch")), + ); xhr.send(body); }); diff --git a/src/hooks/useDefaultFeedTab.ts b/src/hooks/useDefaultFeedTab.ts index a6741e3d..9ea2f425 100644 --- a/src/hooks/useDefaultFeedTab.ts +++ b/src/hooks/useDefaultFeedTab.ts @@ -1,5 +1,9 @@ import { useCallback, useState } from "react"; -import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; +import { + DEFAULT_FEED_TAB, + FEED_TABS, + type FeedTab, +} from "../config/feedTabs.ts"; const STORAGE_KEY = "default-feed-tab"; diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts index d0365593..3bdbc7bf 100644 --- a/src/hooks/useDocumentTitle.ts +++ b/src/hooks/useDocumentTitle.ts @@ -31,9 +31,7 @@ let badge = 0; function applyTitle() { const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, ""); - document.title = badge > 0 - ? `(${badge > 99 ? "99+" : badge}) ${base}` - : base; + document.title = badge > 0 ? `(${badge > 99 ? "99+" : badge}) ${base}` : base; } /** diff --git a/src/hooks/useMentionAutocomplete.ts b/src/hooks/useMentionAutocomplete.ts index 5f0e2176..b8dedc9a 100644 --- a/src/hooks/useMentionAutocomplete.ts +++ b/src/hooks/useMentionAutocomplete.ts @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { API_URL } from "../config/api.ts"; import { insideCode } from "../utils/editorText.ts"; @@ -44,6 +50,18 @@ function getMentionQuery( return { query: match[1], start: match.index }; } +/** The parts of a keydown the mention list reads. */ +export type MentionKey = Pick< + KeyboardEvent, + | "key" + | "ctrlKey" + | "metaKey" + | "shiftKey" + | "altKey" + | "preventDefault" + | "stopPropagation" +>; + export function useMentionAutocomplete( /** Replaces `[start, end)` of the textarea's value, with a space after * when `word` is set; see TextEditor. */ @@ -145,7 +163,7 @@ export function useMentionAutocomplete( /** Returns whether it handled the key. */ const handleMentionKeyDown = useCallback( - (e: React.KeyboardEvent): boolean => { + (e: MentionKey): boolean => { const s = stateRef.current; if (!s.open || s.results.length === 0) return false; if (e.key === "ArrowDown" || e.key === "ArrowUp") { diff --git a/src/hooks/useUserDumpFeed.ts b/src/hooks/useUserDumpFeed.ts index 0dc5232d..8ffcb8a6 100644 --- a/src/hooks/useUserDumpFeed.ts +++ b/src/hooks/useUserDumpFeed.ts @@ -1,5 +1,5 @@ import { - type Ref, + type RefCallback, useCallback, useEffect, useLayoutEffect, @@ -41,7 +41,7 @@ interface UseUserDumpFeedResult { state: State; setState: React.Dispatch>; setItems: (fn: (prev: Dump[]) => Dump[]) => void; - sentinelRef: Ref; + sentinelRef: RefCallback; } /** diff --git a/src/hooks/useYouTubePlaylist.ts b/src/hooks/useYouTubePlaylist.ts index e901d8ff..aaec68bf 100644 --- a/src/hooks/useYouTubePlaylist.ts +++ b/src/hooks/useYouTubePlaylist.ts @@ -66,9 +66,12 @@ const PAUSED = 2; const RESUME_WINDOW_MS = 4000; const MAX_RESUMES = 3; -interface YTGlobal { - YT?: YTNamespace; - onYouTubeIframeAPIReady?: () => void; +// The globals YouTube's IFrame API script sets and calls. +declare global { + interface Window { + YT?: YTNamespace; + onYouTubeIframeAPIReady?: () => void; + } } const API_SRC = "https://www.youtube.com/iframe_api"; @@ -79,7 +82,7 @@ let apiPromise: Promise | null = null; function loadPlayerApi(): Promise { if (apiPromise) return apiPromise; apiPromise = new Promise((resolve, reject) => { - const w = globalThis as unknown as YTGlobal; + const w = window; if (w.YT?.Player) { resolve(w.YT); return; diff --git a/src/locales/en.po b/src/locales/en.po index d11c7c6b..5effc78d 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -19,7 +19,7 @@ msgstr "[deleted]" #. placeholder {0}: dump.commentCount #: src/components/DumpCard.tsx:109 -#: src/components/JournalCard.tsx:110 +#: src/components/JournalCard.tsx:114 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" @@ -51,7 +51,7 @@ msgstr "{0} more online: {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:126 +#: src/pages/UserRegister.tsx:130 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" @@ -67,15 +67,15 @@ msgstr "{unseenCount} new messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" -#: src/components/DumpCreateModal.tsx:942 -#: src/pages/PlaylistDetail.tsx:603 +#: src/components/DumpCreateModal.tsx:950 +#: src/pages/PlaylistDetail.tsx:607 #: src/pages/UserPublicProfile.tsx:819 msgid "← Back" msgstr "← Back" #: src/pages/Dump.tsx:290 #: src/pages/Dump.tsx:533 -#: src/pages/DumpEdit.tsx:185 +#: src/pages/DumpEdit.tsx:187 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -99,47 +99,47 @@ msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:198 +#: src/pages/Notifications.tsx:200 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} commented on <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:158 +#: src/pages/Notifications.tsx:160 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} followed your playlist <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:208 +#: src/pages/Notifications.tsx:210 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} liked your comment on <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:224 +#: src/pages/Notifications.tsx:226 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} mentioned you in <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:168 +#: src/pages/Notifications.tsx:170 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:149 +#: src/pages/Notifications.tsx:151 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:188 +#: src/pages/Notifications.tsx:190 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:178 +#: src/pages/Notifications.tsx:180 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" @@ -147,11 +147,11 @@ msgstr "<0>{0} was added to <1>{1}" msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:219 +#: src/pages/Notifications.tsx:221 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:224 msgid "a post" msgstr "a post" @@ -173,7 +173,7 @@ msgid "Add email…" msgstr "Add email…" #: src/components/AddToPlaylistModal.tsx:59 -#: src/components/DumpCreateModal.tsx:667 +#: src/components/DumpCreateModal.tsx:671 msgid "Add to playlist" msgstr "Add to playlist" @@ -192,7 +192,7 @@ msgstr "All" msgid "Already dumped by {0} {1}" msgstr "Already dumped by {0} {1}" -#: src/pages/UserRegister.tsx:156 +#: src/pages/UserRegister.tsx:160 msgid "Already have an account? <0>Log in" msgstr "Already have an account? <0>Log in" @@ -201,9 +201,9 @@ msgid "Appearance" msgstr "Appearance" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/ChangePasswordModal.tsx:85 +#: src/components/ChangePasswordModal.tsx:89 #: src/pages/ResetPassword.tsx:111 -#: src/pages/UserRegister.tsx:145 +#: src/pages/UserRegister.tsx:149 msgid "At least {0} characters" msgstr "At least {0} characters" @@ -221,26 +221,26 @@ msgid "Back to login" msgstr "Back to login" #: src/contexts/WSProvider.tsx:328 -#: src/contexts/WSProvider.tsx:710 +#: src/contexts/WSProvider.tsx:711 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." -#: src/components/CategoryManager.tsx:211 -#: src/components/CategoryManager.tsx:212 +#: src/components/CategoryManager.tsx:213 +#: src/components/CategoryManager.tsx:214 #: src/components/ChatModal.tsx:235 #: src/components/CommentThread.tsx:129 #: src/components/ConfirmModal.tsx:47 -#: src/components/DumpCreateModal.tsx:952 +#: src/components/DumpCreateModal.tsx:960 #: src/components/form/FormActions.tsx:32 #: src/pages/Dump.tsx:409 -#: src/pages/DumpEdit.tsx:506 -#: src/pages/PlaylistDetail.tsx:969 +#: src/pages/DumpEdit.tsx:502 +#: src/pages/PlaylistDetail.tsx:975 #: src/pages/UserPublicProfile.tsx:1826 #: src/pages/UserPublicProfile.tsx:1901 msgid "Cancel" msgstr "Cancel" -#: src/pages/PlaylistDetail.tsx:782 +#: src/pages/PlaylistDetail.tsx:786 msgid "Cancel removal" msgstr "Cancel removal" @@ -249,7 +249,7 @@ msgstr "Cancel removal" msgid "Cancel reply" msgstr "Cancel reply" -#: src/components/AppHeader.tsx:127 +#: src/components/AppHeader.tsx:128 msgid "Cancel search" msgstr "Cancel search" @@ -266,8 +266,8 @@ msgstr "Category" msgid "Change avatar" msgstr "Change avatar" -#: src/components/ChangePasswordModal.tsx:50 -#: src/components/ChangePasswordModal.tsx:104 +#: src/components/ChangePasswordModal.tsx:54 +#: src/components/ChangePasswordModal.tsx:108 msgid "Change password" msgstr "Change password" @@ -291,16 +291,16 @@ msgstr "Chat" msgid "Chat ({unreadChatCount} unread)" msgstr "Chat ({unreadChatCount} unread)" -#: src/pages/UserRegister.tsx:85 +#: src/pages/UserRegister.tsx:89 msgid "Checking invite…" msgstr "Checking invite…" -#: src/components/ChangePasswordModal.tsx:59 +#: src/components/ChangePasswordModal.tsx:63 #: src/components/Modal.tsx:60 msgid "Close" msgstr "Close" -#: src/components/DumpCreateModal.tsx:698 +#: src/components/DumpCreateModal.tsx:702 msgid "Close anyway" msgstr "Close anyway" @@ -312,7 +312,7 @@ msgstr "Close emoji picker" msgid "Close player" msgstr "Close player" -#: src/components/DumpCreateModal.tsx:688 +#: src/components/DumpCreateModal.tsx:692 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Close without posting? The attached file won't be kept — everything you typed will." @@ -324,12 +324,12 @@ msgstr "Collapse player" msgid "Color scheme" msgstr "Color scheme" -#: src/components/ChangePasswordModal.tsx:92 +#: src/components/ChangePasswordModal.tsx:96 #: src/pages/ResetPassword.tsx:118 msgid "Confirm new password" msgstr "Confirm new password" -#: src/contexts/WSProvider.tsx:703 +#: src/contexts/WSProvider.tsx:704 msgid "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent" @@ -341,11 +341,11 @@ msgstr "Copied!" msgid "Copy" msgstr "Copy" -#: src/components/ChangePasswordModal.tsx:101 +#: src/components/ChangePasswordModal.tsx:105 msgid "Could not change password" msgstr "Could not change password" -#: src/pages/DumpEdit.tsx:494 +#: src/pages/DumpEdit.tsx:490 #: src/pages/UserPlaylists.tsx:400 msgid "Could not delete" msgstr "Could not delete" @@ -354,11 +354,11 @@ msgstr "Could not delete" msgid "Could not load." msgstr "Could not load." -#: src/pages/DumpEdit.tsx:454 +#: src/pages/DumpEdit.tsx:450 msgid "Could not save" msgstr "Could not save" -#: src/components/DumpCreateModal.tsx:810 +#: src/components/DumpCreateModal.tsx:814 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." @@ -384,7 +384,7 @@ msgstr "Created ({0}{1})" msgid "Creating…" msgstr "Creating…" -#: src/components/ChangePasswordModal.tsx:70 +#: src/components/ChangePasswordModal.tsx:74 msgid "Current password" msgstr "Current password" @@ -396,39 +396,39 @@ msgstr "Dark" msgid "Default tab" msgstr "Default tab" -#: src/components/CategoryManager.tsx:222 -#: src/components/CategoryManager.tsx:223 +#: src/components/CategoryManager.tsx:224 +#: src/components/CategoryManager.tsx:225 #: src/components/ChatModal.tsx:347 #: src/components/ChatModal.tsx:348 #: src/components/ChatModal.tsx:446 #: src/components/CommentThread.tsx:399 #: src/components/CommentThread.tsx:405 #: src/components/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:965 +#: src/pages/PlaylistDetail.tsx:971 msgid "Delete" msgstr "Delete" -#: src/components/CategoryManager.tsx:237 +#: src/components/CategoryManager.tsx:241 msgid "Delete category" msgstr "Delete category" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:236 +#: src/components/CategoryManager.tsx:240 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone." -#: src/pages/DumpEdit.tsx:265 -#: src/pages/DumpEdit.tsx:502 +#: src/pages/DumpEdit.tsx:267 +#: src/pages/DumpEdit.tsx:498 msgid "Delete dump" msgstr "Delete dump" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:148 +#: src/pages/DumpEdit.tsx:149 msgid "Delete failed ({0})" msgstr "Delete failed ({0})" #: src/components/PlaylistCard.tsx:108 -#: src/pages/PlaylistDetail.tsx:795 +#: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Delete playlist" @@ -437,7 +437,7 @@ msgstr "Delete playlist" msgid "Delete this comment?" msgstr "Delete this comment?" -#: src/pages/DumpEdit.tsx:264 +#: src/pages/DumpEdit.tsx:266 msgid "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone." @@ -445,7 +445,7 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/pages/PlaylistDetail.tsx:794 +#: src/pages/PlaylistDetail.tsx:798 #: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -455,7 +455,7 @@ msgid "deleted message" msgstr "deleted message" #: src/components/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:926 +#: src/pages/PlaylistDetail.tsx:932 msgid "Description (optional)" msgstr "Description (optional)" @@ -472,7 +472,7 @@ msgstr "Disconnected — reconnecting…" msgid "Dismiss" msgstr "Dismiss" -#: src/components/DumpCreateModal.tsx:749 +#: src/components/DumpCreateModal.tsx:753 msgid "Done" msgstr "Done" @@ -485,23 +485,23 @@ msgstr "Download {0}" msgid "Drop a file here" msgstr "Drop a file here" -#: src/components/DumpCreateModal.tsx:855 +#: src/components/DumpCreateModal.tsx:861 msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" -#: src/pages/DumpEdit.tsx:446 +#: src/pages/DumpEdit.tsx:442 msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/AppHeader.tsx:167 +#: src/components/AppHeader.tsx:168 msgid "Dump" msgstr "Dump" -#: src/components/DumpCreateModal.tsx:965 +#: src/components/DumpCreateModal.tsx:973 msgid "Dump it" msgstr "Dump it" -#: src/components/DumpCreateModal.tsx:726 +#: src/components/DumpCreateModal.tsx:730 msgid "Dumped!" msgstr "Dumped!" @@ -517,7 +517,7 @@ msgstr "Dumps" msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" -#: src/pages/Notifications.tsx:422 +#: src/pages/Notifications.tsx:436 msgid "Earlier" msgstr "Earlier" @@ -526,7 +526,7 @@ msgstr "Earlier" #: src/components/ChatModal.tsx:526 #: src/components/CommentThread.tsx:390 #: src/pages/Dump.tsx:529 -#: src/pages/PlaylistDetail.tsx:658 +#: src/pages/PlaylistDetail.tsx:662 msgid "Edit" msgstr "Edit" @@ -558,7 +558,7 @@ msgstr "edited" #: src/components/ChatModal.tsx:361 #: src/components/CommentThread.tsx:339 #: src/pages/Dump.tsx:468 -#: src/pages/PlaylistDetail.tsx:697 +#: src/pages/PlaylistDetail.tsx:701 msgid "edited {0}" msgstr "edited {0}" @@ -568,11 +568,11 @@ msgstr "edited {0}" #: src/components/ChatModal.tsx:357 #: src/components/CommentThread.tsx:337 #: src/pages/Dump.tsx:466 -#: src/pages/PlaylistDetail.tsx:694 +#: src/pages/PlaylistDetail.tsx:698 msgid "Edited {0}" msgstr "Edited {0}" -#: src/pages/DumpEdit.tsx:210 +#: src/pages/DumpEdit.tsx:212 msgid "Editing" msgstr "Editing" @@ -581,7 +581,7 @@ msgstr "Editing" msgid "Editing {0}" msgstr "Editing {0}" -#: src/pages/UserRegister.tsx:133 +#: src/pages/UserRegister.tsx:137 msgid "Email address" msgstr "Email address" @@ -593,7 +593,7 @@ msgstr "Enter a query to search." msgid "Expand player" msgstr "Expand player" -#: src/components/CategoryManager.tsx:231 +#: src/components/CategoryManager.tsx:234 msgid "Failed to create category" msgstr "Failed to create category" @@ -601,7 +601,7 @@ msgstr "Failed to create category" msgid "Failed to create playlist" msgstr "Failed to create playlist" -#: src/components/CategoryManager.tsx:155 +#: src/components/CategoryManager.tsx:157 msgid "Failed to delete" msgstr "Failed to delete" @@ -615,14 +615,14 @@ msgstr "Failed to generate invite" #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:396 +#: src/pages/Notifications.tsx:410 #: src/pages/UserPublicProfile.tsx:1114 #: src/pages/UserPublicProfile.tsx:1160 #: src/pages/UserPublicProfile.tsx:1205 msgid "Failed to load" msgstr "Failed to load" -#: src/components/DumpCreateModal.tsx:758 +#: src/components/DumpCreateModal.tsx:762 msgid "Failed to post" msgstr "Failed to post" @@ -634,13 +634,13 @@ msgstr "Failed to post comment" msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/PlaylistDetail.tsx:963 #: src/pages/UserPublicProfile.tsx:1829 #: src/pages/UserPublicProfile.tsx:1903 msgid "Failed to save" msgstr "Failed to save" -#: src/components/CategoryManager.tsx:231 +#: src/components/CategoryManager.tsx:235 msgid "Failed to save category" msgstr "Failed to save category" @@ -660,11 +660,11 @@ msgstr "Failed to update role" msgid "Feeds" msgstr "Feeds" -#: src/components/DumpCreateModal.tsx:797 +#: src/components/DumpCreateModal.tsx:801 msgid "Fetching preview…" msgstr "Fetching preview…" -#: src/components/DumpCreateModal.tsx:584 +#: src/components/DumpCreateModal.tsx:588 msgid "File too large (max 50 MB)." msgstr "File too large (max 50 MB)." @@ -689,7 +689,7 @@ msgstr "Follow some public playlists to see their dumps here." msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/components/FeedTabBar.tsx:35 +#: src/components/FeedTabBar.tsx:40 #: src/pages/UserPublicProfile.tsx:994 #: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" @@ -723,7 +723,7 @@ msgstr "From people" msgid "From playlists" msgstr "From playlists" -#: src/components/MediaPlayer.tsx:526 +#: src/components/MediaPlayer.tsx:527 msgid "Fullscreen" msgstr "Fullscreen" @@ -735,7 +735,7 @@ msgstr "Go home" msgid "Go to login" msgstr "Go to login" -#: src/components/FeedTabBar.tsx:31 +#: src/components/FeedTabBar.tsx:36 #: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Hot" @@ -757,7 +757,7 @@ msgstr "In collections" msgid "Insert emoji" msgstr "Insert emoji" -#: src/pages/UserRegister.tsx:96 +#: src/pages/UserRegister.tsx:100 msgid "Invalid invite" msgstr "Invalid invite" @@ -779,12 +779,12 @@ msgstr "invited by" msgid "Invitees" msgstr "Invitees" -#: src/components/FeedTabBar.tsx:33 +#: src/components/FeedTabBar.tsx:38 #: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" -#: src/components/DumpCreateModal.tsx:695 +#: src/components/DumpCreateModal.tsx:699 msgid "Keep editing" msgstr "Keep editing" @@ -796,7 +796,7 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/components/DumpCreateModal.tsx:772 +#: src/components/DumpCreateModal.tsx:776 msgid "Link" msgstr "Link" @@ -804,16 +804,16 @@ msgstr "Link" msgid "Link or file" msgstr "Link or file" -#: src/contexts/WSProvider.tsx:709 +#: src/contexts/WSProvider.tsx:710 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/AppHeader.tsx:200 +#: src/components/AppHeader.tsx:203 msgid "Live updates unavailable." msgstr "Live updates unavailable." #: src/components/UserListPopover.tsx:237 -#: src/pages/Notifications.tsx:469 +#: src/pages/Notifications.tsx:483 #: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Load more" @@ -823,7 +823,7 @@ msgid "Load older messages" msgstr "Load older messages" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:161 +#: src/pages/DumpEdit.tsx:163 msgid "Loading dump…" msgstr "Loading dump…" @@ -833,7 +833,7 @@ msgstr "Loading messages…" #: src/pages/index/FollowedFeed.tsx:121 #: src/pages/index/HotFeed.tsx:64 -#: src/pages/index/JournalFeed.tsx:73 +#: src/pages/index/JournalFeed.tsx:75 #: src/pages/index/NewFeed.tsx:64 #: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 @@ -843,7 +843,7 @@ msgstr "Loading messages…" msgid "Loading more…" msgstr "Loading more…" -#: src/pages/PlaylistDetail.tsx:587 +#: src/pages/PlaylistDetail.tsx:591 msgid "Loading playlist…" msgstr "Loading playlist…" @@ -861,8 +861,8 @@ msgstr "Loading profile…" #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:392 -#: src/pages/Notifications.tsx:468 +#: src/pages/Notifications.tsx:406 +#: src/pages/Notifications.tsx:482 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:356 #: src/pages/UserPublicProfile.tsx:1108 @@ -873,7 +873,7 @@ msgstr "Loading profile…" msgid "Loading…" msgstr "Loading…" -#: src/components/AppHeader.tsx:175 +#: src/components/AppHeader.tsx:176 #: src/pages/UserLogin.tsx:35 #: src/pages/UserLogin.tsx:61 #: src/pages/UserLogin.tsx:80 @@ -913,26 +913,26 @@ msgstr "Max 50 MB" msgid "Moderator" msgstr "Moderator" -#: src/components/MediaPlayer.tsx:499 +#: src/components/MediaPlayer.tsx:500 msgid "Mute" msgstr "Mute" -#: src/components/CategoryManager.tsx:178 -#: src/components/CategoryManager.tsx:179 +#: src/components/CategoryManager.tsx:180 +#: src/components/CategoryManager.tsx:181 msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:385 +#: src/pages/Notifications.tsx:399 msgid "new" msgstr "new" #: src/components/ChatModal.tsx:1121 -#: src/components/FeedTabBar.tsx:32 +#: src/components/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "New" -#: src/components/DumpCreateModal.tsx:667 +#: src/components/DumpCreateModal.tsx:671 #: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 @@ -940,7 +940,7 @@ msgstr "New" msgid "New dump" msgstr "New dump" -#: src/components/ChangePasswordModal.tsx:78 +#: src/components/ChangePasswordModal.tsx:82 #: src/pages/ResetPassword.tsx:103 msgid "New password" msgstr "New password" @@ -950,7 +950,7 @@ msgstr "New password" msgid "New playlist" msgstr "New playlist" -#: src/components/DumpCreateModal.tsx:964 +#: src/components/DumpCreateModal.tsx:972 msgid "Next →" msgstr "Next →" @@ -958,7 +958,7 @@ msgstr "Next →" msgid "Next track" msgstr "Next track" -#: src/pages/PlaylistDetail.tsx:717 +#: src/pages/PlaylistDetail.tsx:721 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." @@ -1003,7 +1003,7 @@ msgstr "No playlists match \"{q}\"." msgid "No playlists yet." msgstr "No playlists yet." -#: src/contexts/WSProvider.tsx:934 +#: src/contexts/WSProvider.tsx:944 msgid "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent" @@ -1015,7 +1015,7 @@ msgstr "No users match \"{q}\"." msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/pages/Notifications.tsx:403 +#: src/pages/Notifications.tsx:417 #: src/pages/UserDumps.tsx:99 #: src/pages/UserPublicProfile.tsx:1480 #: src/pages/UserPublicProfile.tsx:1608 @@ -1024,8 +1024,8 @@ msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:267 -#: src/pages/Notifications.tsx:381 +#: src/pages/Notifications.tsx:269 +#: src/pages/Notifications.tsx:395 #: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" @@ -1069,11 +1069,11 @@ msgid "Password" msgstr "Password" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:139 +#: src/pages/UserRegister.tsx:143 msgid "Password (min. {0} characters)" msgstr "Password (min. {0} characters)" -#: src/components/ChangePasswordModal.tsx:55 +#: src/components/ChangePasswordModal.tsx:59 msgid "Password changed successfully." msgstr "Password changed successfully." @@ -1081,23 +1081,23 @@ msgstr "Password changed successfully." msgid "Password updated" msgstr "Password updated" -#: src/components/ChangePasswordModal.tsx:98 +#: src/components/ChangePasswordModal.tsx:102 #: src/pages/ResetPassword.tsx:123 msgid "Passwords do not match" msgstr "Passwords do not match" -#: src/components/DumpCreateModal.tsx:790 +#: src/components/DumpCreateModal.tsx:794 msgid "Paste a link…" msgstr "Paste a link…" #: src/components/FilePreview.tsx:108 #: src/components/FilePreview.tsx:269 -#: src/components/MediaPlayer.tsx:483 +#: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 msgid "Pause" msgstr "Pause" -#: src/components/DumpCreateModal.tsx:707 +#: src/components/DumpCreateModal.tsx:711 msgid "Picked up where you left off." msgstr "Picked up where you left off." @@ -1105,17 +1105,17 @@ msgstr "Picked up where you left off." #: src/components/FilePreview.tsx:213 #: src/components/FilePreview.tsx:234 #: src/components/FilePreview.tsx:269 -#: src/components/MediaPlayer.tsx:483 +#: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 #: src/components/RichContentCard.tsx:129 msgid "Play" msgstr "Play" -#: src/pages/PlaylistDetail.tsx:910 +#: src/pages/PlaylistDetail.tsx:916 msgid "Playlist title" msgstr "Playlist title" -#: src/components/AppHeader.tsx:148 +#: src/components/AppHeader.tsx:149 #: src/components/DumpCreateModal.tsx:68 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:191 @@ -1140,7 +1140,7 @@ msgstr "Post reply" #: src/components/CommentThread.tsx:424 #: src/components/CommentThread.tsx:502 -#: src/components/DumpCreateModal.tsx:958 +#: src/components/DumpCreateModal.tsx:966 msgid "Posting…" msgstr "Posting…" @@ -1149,30 +1149,30 @@ msgid "Previous track" msgstr "Previous track" #: src/components/DumpCard.tsx:118 -#: src/components/JournalCard.tsx:119 +#: src/components/JournalCard.tsx:123 #: src/components/PlaylistCard.tsx:72 #: src/components/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 -#: src/pages/PlaylistDetail.tsx:677 +#: src/pages/PlaylistDetail.tsx:681 msgid "private" msgstr "private" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:951 +#: src/pages/PlaylistDetail.tsx:957 msgid "Private" msgstr "Private" #: src/components/PlaylistCard.tsx:71 -#: src/pages/PlaylistDetail.tsx:676 +#: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:944 +#: src/pages/PlaylistDetail.tsx:950 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:243 +#: src/pages/DumpEdit.tsx:245 msgid "Refresh metadata" msgstr "Refresh metadata" @@ -1180,21 +1180,21 @@ msgstr "Refresh metadata" msgid "Refreshing stream…" msgstr "Refreshing stream…" -#: src/pages/DumpEdit.tsx:242 +#: src/pages/DumpEdit.tsx:244 msgid "Refreshing…" msgstr "Refreshing…" #: src/pages/UserRegister.tsx:37 -#: src/pages/UserRegister.tsx:108 -#: src/pages/UserRegister.tsx:150 +#: src/pages/UserRegister.tsx:112 +#: src/pages/UserRegister.tsx:154 msgid "Register" msgstr "Register" -#: src/pages/UserRegister.tsx:149 +#: src/pages/UserRegister.tsx:153 msgid "Registering…" msgstr "Registering…" -#: src/pages/UserRegister.tsx:113 +#: src/pages/UserRegister.tsx:117 msgid "Registration failed" msgstr "Registration failed" @@ -1206,7 +1206,7 @@ msgstr "Related" msgid "Remove file" msgstr "Remove file" -#: src/pages/PlaylistDetail.tsx:772 +#: src/pages/PlaylistDetail.tsx:776 msgid "Remove from playlist" msgstr "Remove from playlist" @@ -1218,7 +1218,7 @@ msgstr "Remove like" msgid "Remove vote" msgstr "Remove vote" -#: src/pages/DumpEdit.tsx:438 +#: src/pages/DumpEdit.tsx:434 msgid "Replace file" msgstr "Replace file" @@ -1244,8 +1244,8 @@ msgstr "Reset failed" msgid "Reset password" msgstr "Reset password" -#: src/pages/DumpEdit.tsx:414 -#: src/pages/DumpEdit.tsx:468 +#: src/pages/DumpEdit.tsx:410 +#: src/pages/DumpEdit.tsx:464 msgid "Reset to default" msgstr "Reset to default" @@ -1256,7 +1256,7 @@ msgstr "Reset to the suggested title" #: src/components/ChatModal.tsx:519 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:178 +#: src/pages/DumpEdit.tsx:180 msgid "Retry" msgstr "Retry" @@ -1264,28 +1264,28 @@ msgstr "Retry" msgid "Role" msgstr "Role" -#: src/components/CategoryManager.tsx:202 +#: src/components/CategoryManager.tsx:204 #: src/components/ChatModal.tsx:232 #: src/components/CommentThread.tsx:350 #: src/pages/Dump.tsx:401 -#: src/pages/DumpEdit.tsx:509 -#: src/pages/PlaylistDetail.tsx:976 +#: src/pages/DumpEdit.tsx:505 +#: src/pages/PlaylistDetail.tsx:982 #: src/pages/UserPublicProfile.tsx:1818 #: src/pages/UserPublicProfile.tsx:1893 msgid "Save" msgstr "Save" -#: src/components/ChangePasswordModal.tsx:103 +#: src/components/ChangePasswordModal.tsx:107 #: src/components/CommentThread.tsx:351 #: src/pages/Dump.tsx:400 -#: src/pages/PlaylistDetail.tsx:972 +#: src/pages/PlaylistDetail.tsx:978 #: src/pages/ResetPassword.tsx:126 #: src/pages/UserPublicProfile.tsx:1815 #: src/pages/UserPublicProfile.tsx:1890 msgid "Saving…" msgstr "Saving…" -#: src/components/AppHeader.tsx:127 +#: src/components/AppHeader.tsx:128 #: src/components/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" @@ -1311,7 +1311,7 @@ msgstr "Search failed" msgid "Searching…" msgstr "Searching…" -#: src/components/MediaPlayer.tsx:179 +#: src/components/MediaPlayer.tsx:180 msgid "Seek" msgstr "Seek" @@ -1328,7 +1328,7 @@ msgstr "Send reset link" msgid "Sending…" msgstr "Sending…" -#: src/components/AppHeader.tsx:164 +#: src/components/AppHeader.tsx:165 msgid "Server unreachable" msgstr "Server unreachable" @@ -1349,7 +1349,7 @@ msgstr "Several people are typing…" msgid "Single use, valid for 7 days." msgstr "Single use, valid for 7 days." -#: src/components/CategoryManager.tsx:191 +#: src/components/CategoryManager.tsx:193 msgid "slug" msgstr "slug" @@ -1357,10 +1357,10 @@ msgstr "slug" msgid "Some images couldn't be uploaded." msgstr "Some images couldn't be uploaded." -#: src/components/form/FormError.tsx:15 +#: src/components/form/FormError.tsx:16 #: src/components/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:663 -#: src/pages/PlaylistDetail.tsx:711 +#: src/contexts/WSProvider.tsx:664 +#: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" msgstr "Something went wrong" @@ -1368,7 +1368,7 @@ msgstr "Something went wrong" msgid "Sounds" msgstr "Sounds" -#: src/components/DumpCreateModal.tsx:714 +#: src/components/DumpCreateModal.tsx:718 msgid "Start fresh" msgstr "Start fresh" @@ -1380,7 +1380,7 @@ msgstr "Style" msgid "Submit search" msgstr "Submit search" -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:223 msgid "the chat" msgstr "the chat" @@ -1396,7 +1396,7 @@ msgstr "The image couldn't be uploaded." msgid "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again." -#: src/pages/UserRegister.tsx:97 +#: src/pages/UserRegister.tsx:101 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." @@ -1412,22 +1412,22 @@ msgstr "This page does not exist." msgid "This reset link is missing or malformed." msgstr "This reset link is missing or malformed." -#: src/pages/DumpEdit.tsx:398 +#: src/pages/DumpEdit.tsx:394 msgid "Thumbnail" msgstr "Thumbnail" #: src/components/DumpCreateModal.tsx:174 #: src/components/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:458 +#: src/pages/DumpEdit.tsx:454 msgid "Title" msgstr "Title" -#: src/components/DumpCreateModal.tsx:582 +#: src/components/DumpCreateModal.tsx:586 msgid "Title is required." msgstr "Title is required." #: src/components/ChatModal.tsx:147 -#: src/pages/Notifications.tsx:419 +#: src/pages/Notifications.tsx:433 msgid "Today" msgstr "Today" @@ -1444,7 +1444,7 @@ msgstr "Try again" msgid "Type a message…" msgstr "Type a message…" -#: src/pages/PlaylistDetail.tsx:784 +#: src/pages/PlaylistDetail.tsx:788 msgid "Undo" msgstr "Undo" @@ -1456,7 +1456,7 @@ msgstr "Unfollow {targetUsername}" msgid "Unfollow playlist" msgstr "Unfollow playlist" -#: src/components/MediaPlayer.tsx:499 +#: src/components/MediaPlayer.tsx:500 msgid "Unmute" msgstr "Unmute" @@ -1465,7 +1465,7 @@ msgid "Upload failed" msgstr "Upload failed" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/DumpCreateModal.tsx:650 +#: src/components/DumpCreateModal.tsx:654 msgid "Uploading {0}%" msgstr "Uploading {0}%" @@ -1473,7 +1473,7 @@ msgstr "Uploading {0}%" msgid "Uploading image…" msgstr "Uploading image…" -#: src/components/DumpCreateModal.tsx:961 +#: src/components/DumpCreateModal.tsx:969 msgid "Uploading…" msgstr "Uploading…" @@ -1491,15 +1491,15 @@ msgstr "Upvoted" msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" -#: src/pages/DumpEdit.tsx:430 +#: src/pages/DumpEdit.tsx:426 msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:566 +#: src/components/DumpCreateModal.tsx:570 msgid "URL is required." msgstr "URL is required." -#: src/components/CategoryManager.tsx:192 +#: src/components/CategoryManager.tsx:194 msgid "URL slug" msgstr "URL slug" @@ -1516,7 +1516,7 @@ msgid "User menu" msgstr "User menu" #: src/pages/UserLogin.tsx:69 -#: src/pages/UserRegister.tsx:116 +#: src/pages/UserRegister.tsx:120 msgid "Username" msgstr "Username" @@ -1531,16 +1531,16 @@ msgstr "Users" msgid "View all →" msgstr "View all →" -#: src/components/DumpCreateModal.tsx:728 +#: src/components/DumpCreateModal.tsx:732 msgid "View dump →" msgstr "View dump →" -#: src/components/MediaPlayer.tsx:516 +#: src/components/MediaPlayer.tsx:517 msgid "Volume" msgstr "Volume" -#: src/components/DumpCreateModal.tsx:912 -#: src/pages/DumpEdit.tsx:479 +#: src/components/DumpCreateModal.tsx:920 +#: src/pages/DumpEdit.tsx:475 msgid "What makes it worth it?" msgstr "What makes it worth it?" @@ -1549,7 +1549,7 @@ msgstr "What makes it worth it?" msgid "Who am I?" msgstr "Who am I?" -#: src/components/DumpCreateModal.tsx:927 +#: src/components/DumpCreateModal.tsx:935 msgid "Who can see it" msgstr "Who can see it" @@ -1557,8 +1557,8 @@ msgstr "Who can see it" msgid "Why & where" msgstr "Why & where" -#: src/components/DumpCreateModal.tsx:911 -#: src/pages/DumpEdit.tsx:478 +#: src/components/DumpCreateModal.tsx:919 +#: src/pages/DumpEdit.tsx:474 msgid "Why?" msgstr "Why?" @@ -1567,7 +1567,7 @@ msgid "Write a reply…" msgstr "Write a reply…" #: src/components/ChatModal.tsx:150 -#: src/pages/Notifications.tsx:421 +#: src/pages/Notifications.tsx:435 msgid "Yesterday" msgstr "Yesterday" @@ -1575,17 +1575,17 @@ msgstr "Yesterday" msgid "You can post it anyway." msgstr "You can post it anyway." -#: src/pages/Notifications.tsx:406 +#: src/pages/Notifications.tsx:420 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." -#: src/contexts/WSProvider.tsx:915 +#: src/contexts/WSProvider.tsx:925 msgid "You're offline — not sent" msgstr "You're offline — not sent" #: src/pages/index/FollowedFeed.tsx:126 #: src/pages/index/HotFeed.tsx:69 -#: src/pages/index/JournalFeed.tsx:78 +#: src/pages/index/JournalFeed.tsx:80 #: src/pages/index/NewFeed.tsx:69 #: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 diff --git a/src/locales/fr.po b/src/locales/fr.po index cb47ef6e..07e5f6c2 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -19,7 +19,7 @@ msgstr "[supprimé]" #. placeholder {0}: dump.commentCount #: src/components/DumpCard.tsx:109 -#: src/components/JournalCard.tsx:110 +#: src/components/JournalCard.tsx:114 msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" @@ -51,7 +51,7 @@ msgstr "{0} de plus en ligne : {restNames}" #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX -#: src/pages/UserRegister.tsx:126 +#: src/pages/UserRegister.tsx:130 msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" @@ -67,15 +67,15 @@ msgstr "{unseenCount} nouveaux messages" msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" -#: src/components/DumpCreateModal.tsx:942 -#: src/pages/PlaylistDetail.tsx:603 +#: src/components/DumpCreateModal.tsx:950 +#: src/pages/PlaylistDetail.tsx:607 #: src/pages/UserPublicProfile.tsx:819 msgid "← Back" msgstr "← Retour" #: src/pages/Dump.tsx:290 #: src/pages/Dump.tsx:533 -#: src/pages/DumpEdit.tsx:185 +#: src/pages/DumpEdit.tsx:187 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -99,47 +99,47 @@ msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:198 +#: src/pages/Notifications.tsx:200 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} a commenté sur <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:158 +#: src/pages/Notifications.tsx:160 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} a suivi votre collection <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:208 +#: src/pages/Notifications.tsx:210 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} a aimé votre commentaire sur <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:224 +#: src/pages/Notifications.tsx:226 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} vous a mentionné dans <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:168 +#: src/pages/Notifications.tsx:170 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:149 +#: src/pages/Notifications.tsx:151 msgid "<0>{0} started following you" msgstr "<0>{0} a commencé à vous suivre" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:188 +#: src/pages/Notifications.tsx:190 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} a voté pour <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:178 +#: src/pages/Notifications.tsx:180 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" @@ -147,11 +147,11 @@ msgstr "<0>{0} a été ajouté à <1>{1}" msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:219 +#: src/pages/Notifications.tsx:221 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:222 +#: src/pages/Notifications.tsx:224 msgid "a post" msgstr "une publication" @@ -173,7 +173,7 @@ msgid "Add email…" msgstr "Ajouter un e-mail…" #: src/components/AddToPlaylistModal.tsx:59 -#: src/components/DumpCreateModal.tsx:667 +#: src/components/DumpCreateModal.tsx:671 msgid "Add to playlist" msgstr "Ajouter à la collection" @@ -192,7 +192,7 @@ msgstr "Tout" msgid "Already dumped by {0} {1}" msgstr "Déjà recommandé par {0} {1}" -#: src/pages/UserRegister.tsx:156 +#: src/pages/UserRegister.tsx:160 msgid "Already have an account? <0>Log in" msgstr "Vous avez déjà un compte ? <0>Se connecter" @@ -201,9 +201,9 @@ msgid "Appearance" msgstr "Apparence" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/components/ChangePasswordModal.tsx:85 +#: src/components/ChangePasswordModal.tsx:89 #: src/pages/ResetPassword.tsx:111 -#: src/pages/UserRegister.tsx:145 +#: src/pages/UserRegister.tsx:149 msgid "At least {0} characters" msgstr "Au moins {0} caractères" @@ -221,26 +221,26 @@ msgid "Back to login" msgstr "Retour à la connexion" #: src/contexts/WSProvider.tsx:328 -#: src/contexts/WSProvider.tsx:710 +#: src/contexts/WSProvider.tsx:711 msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." -#: src/components/CategoryManager.tsx:211 -#: src/components/CategoryManager.tsx:212 +#: src/components/CategoryManager.tsx:213 +#: src/components/CategoryManager.tsx:214 #: src/components/ChatModal.tsx:235 #: src/components/CommentThread.tsx:129 #: src/components/ConfirmModal.tsx:47 -#: src/components/DumpCreateModal.tsx:952 +#: src/components/DumpCreateModal.tsx:960 #: src/components/form/FormActions.tsx:32 #: src/pages/Dump.tsx:409 -#: src/pages/DumpEdit.tsx:506 -#: src/pages/PlaylistDetail.tsx:969 +#: src/pages/DumpEdit.tsx:502 +#: src/pages/PlaylistDetail.tsx:975 #: src/pages/UserPublicProfile.tsx:1826 #: src/pages/UserPublicProfile.tsx:1901 msgid "Cancel" msgstr "Annuler" -#: src/pages/PlaylistDetail.tsx:782 +#: src/pages/PlaylistDetail.tsx:786 msgid "Cancel removal" msgstr "Annuler la suppression" @@ -249,7 +249,7 @@ msgstr "Annuler la suppression" msgid "Cancel reply" msgstr "Annuler la réponse" -#: src/components/AppHeader.tsx:127 +#: src/components/AppHeader.tsx:128 msgid "Cancel search" msgstr "Annuler la recherche" @@ -266,8 +266,8 @@ msgstr "Catégorie" msgid "Change avatar" msgstr "Changer l'avatar" -#: src/components/ChangePasswordModal.tsx:50 -#: src/components/ChangePasswordModal.tsx:104 +#: src/components/ChangePasswordModal.tsx:54 +#: src/components/ChangePasswordModal.tsx:108 msgid "Change password" msgstr "Changer le mot de passe" @@ -291,16 +291,16 @@ msgstr "Tribune" msgid "Chat ({unreadChatCount} unread)" msgstr "{unreadChatCount} messages non lus" -#: src/pages/UserRegister.tsx:85 +#: src/pages/UserRegister.tsx:89 msgid "Checking invite…" msgstr "Vérification de l'invitation…" -#: src/components/ChangePasswordModal.tsx:59 +#: src/components/ChangePasswordModal.tsx:63 #: src/components/Modal.tsx:60 msgid "Close" msgstr "Fermer" -#: src/components/DumpCreateModal.tsx:698 +#: src/components/DumpCreateModal.tsx:702 msgid "Close anyway" msgstr "Fermer quand même" @@ -312,7 +312,7 @@ msgstr "Fermer le sélecteur d'emoji" msgid "Close player" msgstr "Fermer le lecteur" -#: src/components/DumpCreateModal.tsx:688 +#: src/components/DumpCreateModal.tsx:692 msgid "Close without posting? The attached file won't be kept — everything you typed will." msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé." @@ -324,12 +324,12 @@ msgstr "Réduire le lecteur" msgid "Color scheme" msgstr "Thème de couleur" -#: src/components/ChangePasswordModal.tsx:92 +#: src/components/ChangePasswordModal.tsx:96 #: src/pages/ResetPassword.tsx:118 msgid "Confirm new password" msgstr "Confirmer le nouveau mot de passe" -#: src/contexts/WSProvider.tsx:703 +#: src/contexts/WSProvider.tsx:704 msgid "Connection lost — this message may not have been sent" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" @@ -341,11 +341,11 @@ msgstr "Copié !" msgid "Copy" msgstr "Copier" -#: src/components/ChangePasswordModal.tsx:101 +#: src/components/ChangePasswordModal.tsx:105 msgid "Could not change password" msgstr "Impossible de changer le mot de passe" -#: src/pages/DumpEdit.tsx:494 +#: src/pages/DumpEdit.tsx:490 #: src/pages/UserPlaylists.tsx:400 msgid "Could not delete" msgstr "Suppression impossible" @@ -354,11 +354,11 @@ msgstr "Suppression impossible" msgid "Could not load." msgstr "Impossible de charger." -#: src/pages/DumpEdit.tsx:454 +#: src/pages/DumpEdit.tsx:450 msgid "Could not save" msgstr "Sauvegarde impossible" -#: src/components/DumpCreateModal.tsx:810 +#: src/components/DumpCreateModal.tsx:814 msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later." msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard." @@ -384,7 +384,7 @@ msgstr "Créées ({0}{1})" msgid "Creating…" msgstr "Création…" -#: src/components/ChangePasswordModal.tsx:70 +#: src/components/ChangePasswordModal.tsx:74 msgid "Current password" msgstr "Mot de passe actuel" @@ -396,39 +396,39 @@ msgstr "Sombre" msgid "Default tab" msgstr "Onglet par défaut" -#: src/components/CategoryManager.tsx:222 -#: src/components/CategoryManager.tsx:223 +#: src/components/CategoryManager.tsx:224 +#: src/components/CategoryManager.tsx:225 #: src/components/ChatModal.tsx:347 #: src/components/ChatModal.tsx:348 #: src/components/ChatModal.tsx:446 #: src/components/CommentThread.tsx:399 #: src/components/CommentThread.tsx:405 #: src/components/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:965 +#: src/pages/PlaylistDetail.tsx:971 msgid "Delete" msgstr "Supprimer" -#: src/components/CategoryManager.tsx:237 +#: src/components/CategoryManager.tsx:241 msgid "Delete category" msgstr "Supprimer la catégorie" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:236 +#: src/components/CategoryManager.tsx:240 msgid "Delete category \"{0}\"? This cannot be undone." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." -#: src/pages/DumpEdit.tsx:265 -#: src/pages/DumpEdit.tsx:502 +#: src/pages/DumpEdit.tsx:267 +#: src/pages/DumpEdit.tsx:498 msgid "Delete dump" msgstr "Supprimer la reco" #. placeholder {0}: res.status -#: src/pages/DumpEdit.tsx:148 +#: src/pages/DumpEdit.tsx:149 msgid "Delete failed ({0})" msgstr "Échec de la suppression ({0})" #: src/components/PlaylistCard.tsx:108 -#: src/pages/PlaylistDetail.tsx:795 +#: src/pages/PlaylistDetail.tsx:799 #: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Supprimer la collection" @@ -437,7 +437,7 @@ msgstr "Supprimer la collection" msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" -#: src/pages/DumpEdit.tsx:264 +#: src/pages/DumpEdit.tsx:266 msgid "Delete this dump? This cannot be undone." msgstr "Supprimer cette reco ? Cette action est irréversible." @@ -445,7 +445,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/pages/PlaylistDetail.tsx:794 +#: src/pages/PlaylistDetail.tsx:798 #: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -455,7 +455,7 @@ msgid "deleted message" msgstr "message supprimé" #: src/components/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:926 +#: src/pages/PlaylistDetail.tsx:932 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -472,7 +472,7 @@ msgstr "Déconnecté — reconnexion…" msgid "Dismiss" msgstr "Fermer" -#: src/components/DumpCreateModal.tsx:749 +#: src/components/DumpCreateModal.tsx:753 msgid "Done" msgstr "Terminé" @@ -485,23 +485,23 @@ msgstr "Télécharger {0}" msgid "Drop a file here" msgstr "Déposez un fichier ici" -#: src/components/DumpCreateModal.tsx:855 +#: src/components/DumpCreateModal.tsx:861 msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" -#: src/pages/DumpEdit.tsx:446 +#: src/pages/DumpEdit.tsx:442 msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/AppHeader.tsx:167 +#: src/components/AppHeader.tsx:168 msgid "Dump" msgstr "Reco" -#: src/components/DumpCreateModal.tsx:965 +#: src/components/DumpCreateModal.tsx:973 msgid "Dump it" msgstr "Recommander" -#: src/components/DumpCreateModal.tsx:726 +#: src/components/DumpCreateModal.tsx:730 msgid "Dumped!" msgstr "Recommandé !" @@ -517,7 +517,7 @@ msgstr "Recos" msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" -#: src/pages/Notifications.tsx:422 +#: src/pages/Notifications.tsx:436 msgid "Earlier" msgstr "Plus tôt" @@ -526,7 +526,7 @@ msgstr "Plus tôt" #: src/components/ChatModal.tsx:526 #: src/components/CommentThread.tsx:390 #: src/pages/Dump.tsx:529 -#: src/pages/PlaylistDetail.tsx:658 +#: src/pages/PlaylistDetail.tsx:662 msgid "Edit" msgstr "Modifier" @@ -558,7 +558,7 @@ msgstr "modifié" #: src/components/ChatModal.tsx:361 #: src/components/CommentThread.tsx:339 #: src/pages/Dump.tsx:468 -#: src/pages/PlaylistDetail.tsx:697 +#: src/pages/PlaylistDetail.tsx:701 msgid "edited {0}" msgstr "modifié {0}" @@ -568,11 +568,11 @@ msgstr "modifié {0}" #: src/components/ChatModal.tsx:357 #: src/components/CommentThread.tsx:337 #: src/pages/Dump.tsx:466 -#: src/pages/PlaylistDetail.tsx:694 +#: src/pages/PlaylistDetail.tsx:698 msgid "Edited {0}" msgstr "Modifié le {0}" -#: src/pages/DumpEdit.tsx:210 +#: src/pages/DumpEdit.tsx:212 msgid "Editing" msgstr "Modification" @@ -581,7 +581,7 @@ msgstr "Modification" msgid "Editing {0}" msgstr "Édition de {0}" -#: src/pages/UserRegister.tsx:133 +#: src/pages/UserRegister.tsx:137 msgid "Email address" msgstr "Adresse e-mail" @@ -593,7 +593,7 @@ msgstr "Saisissez une recherche." msgid "Expand player" msgstr "Agrandir le lecteur" -#: src/components/CategoryManager.tsx:231 +#: src/components/CategoryManager.tsx:234 msgid "Failed to create category" msgstr "Échec de la création de la catégorie" @@ -601,7 +601,7 @@ msgstr "Échec de la création de la catégorie" msgid "Failed to create playlist" msgstr "Impossible de créer la collection" -#: src/components/CategoryManager.tsx:155 +#: src/components/CategoryManager.tsx:157 msgid "Failed to delete" msgstr "Suppression échouée" @@ -615,14 +615,14 @@ msgstr "Impossible de générer une invitation" #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:396 +#: src/pages/Notifications.tsx:410 #: src/pages/UserPublicProfile.tsx:1114 #: src/pages/UserPublicProfile.tsx:1160 #: src/pages/UserPublicProfile.tsx:1205 msgid "Failed to load" msgstr "Chargement échoué" -#: src/components/DumpCreateModal.tsx:758 +#: src/components/DumpCreateModal.tsx:762 msgid "Failed to post" msgstr "Publication échouée" @@ -634,13 +634,13 @@ msgstr "Impossible de publier le commentaire" msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/PlaylistDetail.tsx:963 #: src/pages/UserPublicProfile.tsx:1829 #: src/pages/UserPublicProfile.tsx:1903 msgid "Failed to save" msgstr "Enregistrement échoué" -#: src/components/CategoryManager.tsx:231 +#: src/components/CategoryManager.tsx:235 msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" @@ -660,11 +660,11 @@ msgstr "Erreur lors de la mise à jour du rôle" msgid "Feeds" msgstr "Flux" -#: src/components/DumpCreateModal.tsx:797 +#: src/components/DumpCreateModal.tsx:801 msgid "Fetching preview…" msgstr "Récupération de l'aperçu…" -#: src/components/DumpCreateModal.tsx:584 +#: src/components/DumpCreateModal.tsx:588 msgid "File too large (max 50 MB)." msgstr "Fichier trop volumineux (max 50 Mo)." @@ -689,7 +689,7 @@ msgstr "Suivez des collections publiques pour voir leurs recos ici." msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/components/FeedTabBar.tsx:35 +#: src/components/FeedTabBar.tsx:40 #: src/pages/UserPublicProfile.tsx:994 #: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" @@ -723,7 +723,7 @@ msgstr "De personnes" msgid "From playlists" msgstr "De collections" -#: src/components/MediaPlayer.tsx:526 +#: src/components/MediaPlayer.tsx:527 msgid "Fullscreen" msgstr "Plein écran" @@ -735,7 +735,7 @@ msgstr "Accueil" msgid "Go to login" msgstr "Aller à la connexion" -#: src/components/FeedTabBar.tsx:31 +#: src/components/FeedTabBar.tsx:36 #: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Tendances" @@ -757,7 +757,7 @@ msgstr "Dans les collections" msgid "Insert emoji" msgstr "Insérer un emoji" -#: src/pages/UserRegister.tsx:96 +#: src/pages/UserRegister.tsx:100 msgid "Invalid invite" msgstr "Invitation invalide" @@ -779,12 +779,12 @@ msgstr "invité par" msgid "Invitees" msgstr "Invités" -#: src/components/FeedTabBar.tsx:33 +#: src/components/FeedTabBar.tsx:38 #: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" -#: src/components/DumpCreateModal.tsx:695 +#: src/components/DumpCreateModal.tsx:699 msgid "Keep editing" msgstr "Continuer l'édition" @@ -796,7 +796,7 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/components/DumpCreateModal.tsx:772 +#: src/components/DumpCreateModal.tsx:776 msgid "Link" msgstr "Lien" @@ -804,16 +804,16 @@ msgstr "Lien" msgid "Link or file" msgstr "Lien ou fichier" -#: src/contexts/WSProvider.tsx:709 +#: src/contexts/WSProvider.tsx:710 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/AppHeader.tsx:200 +#: src/components/AppHeader.tsx:203 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." #: src/components/UserListPopover.tsx:237 -#: src/pages/Notifications.tsx:469 +#: src/pages/Notifications.tsx:483 #: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Charger plus" @@ -823,7 +823,7 @@ msgid "Load older messages" msgstr "Charger les messages plus anciens" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:161 +#: src/pages/DumpEdit.tsx:163 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -833,7 +833,7 @@ msgstr "Chargement des messages…" #: src/pages/index/FollowedFeed.tsx:121 #: src/pages/index/HotFeed.tsx:64 -#: src/pages/index/JournalFeed.tsx:73 +#: src/pages/index/JournalFeed.tsx:75 #: src/pages/index/NewFeed.tsx:64 #: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 @@ -843,7 +843,7 @@ msgstr "Chargement des messages…" msgid "Loading more…" msgstr "Chargement…" -#: src/pages/PlaylistDetail.tsx:587 +#: src/pages/PlaylistDetail.tsx:591 msgid "Loading playlist…" msgstr "Chargement de la collection…" @@ -861,8 +861,8 @@ msgstr "Chargement du profil…" #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:392 -#: src/pages/Notifications.tsx:468 +#: src/pages/Notifications.tsx:406 +#: src/pages/Notifications.tsx:482 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:356 #: src/pages/UserPublicProfile.tsx:1108 @@ -873,7 +873,7 @@ msgstr "Chargement du profil…" msgid "Loading…" msgstr "Chargement…" -#: src/components/AppHeader.tsx:175 +#: src/components/AppHeader.tsx:176 #: src/pages/UserLogin.tsx:35 #: src/pages/UserLogin.tsx:61 #: src/pages/UserLogin.tsx:80 @@ -913,26 +913,26 @@ msgstr "Max 50 Mo" msgid "Moderator" msgstr "Modérateur" -#: src/components/MediaPlayer.tsx:499 +#: src/components/MediaPlayer.tsx:500 msgid "Mute" msgstr "Couper le son" -#: src/components/CategoryManager.tsx:178 -#: src/components/CategoryManager.tsx:179 +#: src/components/CategoryManager.tsx:180 +#: src/components/CategoryManager.tsx:181 msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:385 +#: src/pages/Notifications.tsx:399 msgid "new" msgstr "nouveau" #: src/components/ChatModal.tsx:1121 -#: src/components/FeedTabBar.tsx:32 +#: src/components/FeedTabBar.tsx:37 #: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "Nouveau" -#: src/components/DumpCreateModal.tsx:667 +#: src/components/DumpCreateModal.tsx:671 #: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 @@ -940,7 +940,7 @@ msgstr "Nouveau" msgid "New dump" msgstr "Nouvelle reco" -#: src/components/ChangePasswordModal.tsx:78 +#: src/components/ChangePasswordModal.tsx:82 #: src/pages/ResetPassword.tsx:103 msgid "New password" msgstr "Nouveau mot de passe" @@ -950,7 +950,7 @@ msgstr "Nouveau mot de passe" msgid "New playlist" msgstr "Nouvelle collection" -#: src/components/DumpCreateModal.tsx:964 +#: src/components/DumpCreateModal.tsx:972 msgid "Next →" msgstr "Suivant →" @@ -958,7 +958,7 @@ msgstr "Suivant →" msgid "Next track" msgstr "Piste suivante" -#: src/pages/PlaylistDetail.tsx:717 +#: src/pages/PlaylistDetail.tsx:721 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." @@ -1003,7 +1003,7 @@ msgstr "Aucune collection ne correspond à « {q} »." msgid "No playlists yet." msgstr "Pas encore de collections." -#: src/contexts/WSProvider.tsx:934 +#: src/contexts/WSProvider.tsx:944 msgid "No reply from the server — this message may not have been sent" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" @@ -1015,7 +1015,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »." msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/pages/Notifications.tsx:403 +#: src/pages/Notifications.tsx:417 #: src/pages/UserDumps.tsx:99 #: src/pages/UserPublicProfile.tsx:1480 #: src/pages/UserPublicProfile.tsx:1608 @@ -1024,8 +1024,8 @@ msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:267 -#: src/pages/Notifications.tsx:381 +#: src/pages/Notifications.tsx:269 +#: src/pages/Notifications.tsx:395 #: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" @@ -1069,11 +1069,11 @@ msgid "Password" msgstr "Mot de passe" #. placeholder {0}: VALIDATION.PASSWORD_MIN -#: src/pages/UserRegister.tsx:139 +#: src/pages/UserRegister.tsx:143 msgid "Password (min. {0} characters)" msgstr "Mot de passe (min. {0} caractères)" -#: src/components/ChangePasswordModal.tsx:55 +#: src/components/ChangePasswordModal.tsx:59 msgid "Password changed successfully." msgstr "Mot de passe modifié avec succès." @@ -1081,23 +1081,23 @@ msgstr "Mot de passe modifié avec succès." msgid "Password updated" msgstr "Mot de passe mis à jour" -#: src/components/ChangePasswordModal.tsx:98 +#: src/components/ChangePasswordModal.tsx:102 #: src/pages/ResetPassword.tsx:123 msgid "Passwords do not match" msgstr "Les mots de passe ne correspondent pas" -#: src/components/DumpCreateModal.tsx:790 +#: src/components/DumpCreateModal.tsx:794 msgid "Paste a link…" msgstr "Collez un lien…" #: src/components/FilePreview.tsx:108 #: src/components/FilePreview.tsx:269 -#: src/components/MediaPlayer.tsx:483 +#: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 msgid "Pause" msgstr "Pause" -#: src/components/DumpCreateModal.tsx:707 +#: src/components/DumpCreateModal.tsx:711 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." @@ -1105,17 +1105,17 @@ msgstr "Reprise de votre brouillon." #: src/components/FilePreview.tsx:213 #: src/components/FilePreview.tsx:234 #: src/components/FilePreview.tsx:269 -#: src/components/MediaPlayer.tsx:483 +#: src/components/MediaPlayer.tsx:484 #: src/components/RichContentCard.tsx:83 #: src/components/RichContentCard.tsx:129 msgid "Play" msgstr "Lecture" -#: src/pages/PlaylistDetail.tsx:910 +#: src/pages/PlaylistDetail.tsx:916 msgid "Playlist title" msgstr "Titre de la collection" -#: src/components/AppHeader.tsx:148 +#: src/components/AppHeader.tsx:149 #: src/components/DumpCreateModal.tsx:68 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:191 @@ -1140,7 +1140,7 @@ msgstr "Publier la réponse" #: src/components/CommentThread.tsx:424 #: src/components/CommentThread.tsx:502 -#: src/components/DumpCreateModal.tsx:958 +#: src/components/DumpCreateModal.tsx:966 msgid "Posting…" msgstr "Publication…" @@ -1149,30 +1149,30 @@ msgid "Previous track" msgstr "Piste précédente" #: src/components/DumpCard.tsx:118 -#: src/components/JournalCard.tsx:119 +#: src/components/JournalCard.tsx:123 #: src/components/PlaylistCard.tsx:72 #: src/components/PlaylistMembershipPanel.tsx:64 #: src/pages/Dump.tsx:474 -#: src/pages/PlaylistDetail.tsx:677 +#: src/pages/PlaylistDetail.tsx:681 msgid "private" msgstr "privé" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:951 +#: src/pages/PlaylistDetail.tsx:957 msgid "Private" msgstr "Privé" #: src/components/PlaylistCard.tsx:71 -#: src/pages/PlaylistDetail.tsx:676 +#: src/pages/PlaylistDetail.tsx:680 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:944 +#: src/pages/PlaylistDetail.tsx:950 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:243 +#: src/pages/DumpEdit.tsx:245 msgid "Refresh metadata" msgstr "Actualiser les métadonnées" @@ -1180,21 +1180,21 @@ msgstr "Actualiser les métadonnées" msgid "Refreshing stream…" msgstr "Actualisation du flux…" -#: src/pages/DumpEdit.tsx:242 +#: src/pages/DumpEdit.tsx:244 msgid "Refreshing…" msgstr "Actualisation…" #: src/pages/UserRegister.tsx:37 -#: src/pages/UserRegister.tsx:108 -#: src/pages/UserRegister.tsx:150 +#: src/pages/UserRegister.tsx:112 +#: src/pages/UserRegister.tsx:154 msgid "Register" msgstr "S'inscrire" -#: src/pages/UserRegister.tsx:149 +#: src/pages/UserRegister.tsx:153 msgid "Registering…" msgstr "Inscription…" -#: src/pages/UserRegister.tsx:113 +#: src/pages/UserRegister.tsx:117 msgid "Registration failed" msgstr "Inscription échouée" @@ -1206,7 +1206,7 @@ msgstr "Connexe" msgid "Remove file" msgstr "Supprimer le fichier" -#: src/pages/PlaylistDetail.tsx:772 +#: src/pages/PlaylistDetail.tsx:776 msgid "Remove from playlist" msgstr "Retirer de la collection" @@ -1218,7 +1218,7 @@ msgstr "Retirer le j'aime" msgid "Remove vote" msgstr "Retirer le vote" -#: src/pages/DumpEdit.tsx:438 +#: src/pages/DumpEdit.tsx:434 msgid "Replace file" msgstr "Remplacer le fichier" @@ -1244,8 +1244,8 @@ msgstr "Échec de la réinitialisation" msgid "Reset password" msgstr "Réinitialiser le mot de passe" -#: src/pages/DumpEdit.tsx:414 -#: src/pages/DumpEdit.tsx:468 +#: src/pages/DumpEdit.tsx:410 +#: src/pages/DumpEdit.tsx:464 msgid "Reset to default" msgstr "Réinitialiser par défaut" @@ -1256,7 +1256,7 @@ msgstr "Rétablir le titre suggéré" #: src/components/ChatModal.tsx:519 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:178 +#: src/pages/DumpEdit.tsx:180 msgid "Retry" msgstr "Réessayer" @@ -1264,28 +1264,28 @@ msgstr "Réessayer" msgid "Role" msgstr "Rôle" -#: src/components/CategoryManager.tsx:202 +#: src/components/CategoryManager.tsx:204 #: src/components/ChatModal.tsx:232 #: src/components/CommentThread.tsx:350 #: src/pages/Dump.tsx:401 -#: src/pages/DumpEdit.tsx:509 -#: src/pages/PlaylistDetail.tsx:976 +#: src/pages/DumpEdit.tsx:505 +#: src/pages/PlaylistDetail.tsx:982 #: src/pages/UserPublicProfile.tsx:1818 #: src/pages/UserPublicProfile.tsx:1893 msgid "Save" msgstr "Enregistrer" -#: src/components/ChangePasswordModal.tsx:103 +#: src/components/ChangePasswordModal.tsx:107 #: src/components/CommentThread.tsx:351 #: src/pages/Dump.tsx:400 -#: src/pages/PlaylistDetail.tsx:972 +#: src/pages/PlaylistDetail.tsx:978 #: src/pages/ResetPassword.tsx:126 #: src/pages/UserPublicProfile.tsx:1815 #: src/pages/UserPublicProfile.tsx:1890 msgid "Saving…" msgstr "Enregistrement…" -#: src/components/AppHeader.tsx:127 +#: src/components/AppHeader.tsx:128 #: src/components/SearchBar.tsx:73 #: src/pages/Search.tsx:51 msgid "Search" @@ -1311,7 +1311,7 @@ msgstr "Recherche échouée" msgid "Searching…" msgstr "Recherche…" -#: src/components/MediaPlayer.tsx:179 +#: src/components/MediaPlayer.tsx:180 msgid "Seek" msgstr "Position" @@ -1328,7 +1328,7 @@ msgstr "Envoyer le lien de réinitialisation" msgid "Sending…" msgstr "Envoi…" -#: src/components/AppHeader.tsx:164 +#: src/components/AppHeader.tsx:165 msgid "Server unreachable" msgstr "Serveur inaccessible" @@ -1349,7 +1349,7 @@ msgstr "Plusieurs personnes écrivent…" msgid "Single use, valid for 7 days." msgstr "Usage unique, valable 7 jours." -#: src/components/CategoryManager.tsx:191 +#: src/components/CategoryManager.tsx:193 msgid "slug" msgstr "identifiant" @@ -1357,10 +1357,10 @@ msgstr "identifiant" msgid "Some images couldn't be uploaded." msgstr "Certaines images n'ont pas pu être envoyées." -#: src/components/form/FormError.tsx:15 +#: src/components/form/FormError.tsx:16 #: src/components/PageError.tsx:13 -#: src/contexts/WSProvider.tsx:663 -#: src/pages/PlaylistDetail.tsx:711 +#: src/contexts/WSProvider.tsx:664 +#: src/pages/PlaylistDetail.tsx:715 msgid "Something went wrong" msgstr "Une erreur est survenue" @@ -1368,7 +1368,7 @@ msgstr "Une erreur est survenue" msgid "Sounds" msgstr "Sons" -#: src/components/DumpCreateModal.tsx:714 +#: src/components/DumpCreateModal.tsx:718 msgid "Start fresh" msgstr "Repartir de zéro" @@ -1380,7 +1380,7 @@ msgstr "Style" msgid "Submit search" msgstr "Lancer la recherche" -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:223 msgid "the chat" msgstr "la tribune" @@ -1396,7 +1396,7 @@ msgstr "L'image n'a pas pu être envoyée." msgid "This comment couldn't be deleted. Try again." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." -#: src/pages/UserRegister.tsx:97 +#: src/pages/UserRegister.tsx:101 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." @@ -1412,22 +1412,22 @@ msgstr "Rien à voir, circulez." msgid "This reset link is missing or malformed." msgstr "Ce lien de réinitialisation est absent ou malformé." -#: src/pages/DumpEdit.tsx:398 +#: src/pages/DumpEdit.tsx:394 msgid "Thumbnail" msgstr "Miniature" #: src/components/DumpCreateModal.tsx:174 #: src/components/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:458 +#: src/pages/DumpEdit.tsx:454 msgid "Title" msgstr "Titre" -#: src/components/DumpCreateModal.tsx:582 +#: src/components/DumpCreateModal.tsx:586 msgid "Title is required." msgstr "Un titre est requis." #: src/components/ChatModal.tsx:147 -#: src/pages/Notifications.tsx:419 +#: src/pages/Notifications.tsx:433 msgid "Today" msgstr "Aujourd'hui" @@ -1444,7 +1444,7 @@ msgstr "Réessayer" msgid "Type a message…" msgstr "Écrivez un message…" -#: src/pages/PlaylistDetail.tsx:784 +#: src/pages/PlaylistDetail.tsx:788 msgid "Undo" msgstr "Annuler" @@ -1456,7 +1456,7 @@ msgstr "Ne plus suivre {targetUsername}" msgid "Unfollow playlist" msgstr "Ne plus suivre la collection" -#: src/components/MediaPlayer.tsx:499 +#: src/components/MediaPlayer.tsx:500 msgid "Unmute" msgstr "Rétablir le son" @@ -1465,7 +1465,7 @@ msgid "Upload failed" msgstr "Envoi échoué" #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100) -#: src/components/DumpCreateModal.tsx:650 +#: src/components/DumpCreateModal.tsx:654 msgid "Uploading {0}%" msgstr "Envoi {0} %" @@ -1473,7 +1473,7 @@ msgstr "Envoi {0} %" msgid "Uploading image…" msgstr "Envoi de l'image…" -#: src/components/DumpCreateModal.tsx:961 +#: src/components/DumpCreateModal.tsx:969 msgid "Uploading…" msgstr "Envoi…" @@ -1491,15 +1491,15 @@ msgstr "Voté" msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" -#: src/pages/DumpEdit.tsx:430 +#: src/pages/DumpEdit.tsx:426 msgid "URL" msgstr "URL" -#: src/components/DumpCreateModal.tsx:566 +#: src/components/DumpCreateModal.tsx:570 msgid "URL is required." msgstr "L'URL est obligatoire." -#: src/components/CategoryManager.tsx:192 +#: src/components/CategoryManager.tsx:194 msgid "URL slug" msgstr "Identifiant d’URL" @@ -1516,7 +1516,7 @@ msgid "User menu" msgstr "Menu utilisateur" #: src/pages/UserLogin.tsx:69 -#: src/pages/UserRegister.tsx:116 +#: src/pages/UserRegister.tsx:120 msgid "Username" msgstr "Nom d'utilisateur" @@ -1531,16 +1531,16 @@ msgstr "Utilisateurs" msgid "View all →" msgstr "Tout voir →" -#: src/components/DumpCreateModal.tsx:728 +#: src/components/DumpCreateModal.tsx:732 msgid "View dump →" msgstr "Voir la reco →" -#: src/components/MediaPlayer.tsx:516 +#: src/components/MediaPlayer.tsx:517 msgid "Volume" msgstr "Volume" -#: src/components/DumpCreateModal.tsx:912 -#: src/pages/DumpEdit.tsx:479 +#: src/components/DumpCreateModal.tsx:920 +#: src/pages/DumpEdit.tsx:475 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" @@ -1549,7 +1549,7 @@ msgstr "Pourquoi on en voudrait ?" msgid "Who am I?" msgstr "Qui suis-je ?" -#: src/components/DumpCreateModal.tsx:927 +#: src/components/DumpCreateModal.tsx:935 msgid "Who can see it" msgstr "Qui peut la voir" @@ -1557,8 +1557,8 @@ msgstr "Qui peut la voir" msgid "Why & where" msgstr "Pourquoi & où" -#: src/components/DumpCreateModal.tsx:911 -#: src/pages/DumpEdit.tsx:478 +#: src/components/DumpCreateModal.tsx:919 +#: src/pages/DumpEdit.tsx:474 msgid "Why?" msgstr "Pourquoi ?" @@ -1567,7 +1567,7 @@ msgid "Write a reply…" msgstr "Écrire une réponse…" #: src/components/ChatModal.tsx:150 -#: src/pages/Notifications.tsx:421 +#: src/pages/Notifications.tsx:435 msgid "Yesterday" msgstr "Hier" @@ -1575,17 +1575,17 @@ msgstr "Hier" msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." -#: src/pages/Notifications.tsx:406 +#: src/pages/Notifications.tsx:420 msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." -#: src/contexts/WSProvider.tsx:915 +#: src/contexts/WSProvider.tsx:925 msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" #: src/pages/index/FollowedFeed.tsx:126 #: src/pages/index/HotFeed.tsx:69 -#: src/pages/index/JournalFeed.tsx:78 +#: src/pages/index/JournalFeed.tsx:80 #: src/pages/index/NewFeed.tsx:69 #: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 141b3ae1..5cf01ccb 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -145,7 +145,9 @@ export function DumpEdit() { }); if (!res.ok) { const body = await res.json().catch(() => null); - throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`); + throw new Error( + body?.error?.message ?? t`Delete failed (${res.status})`, + ); } } catch (err) { setDeleteError(friendlyFetchError(err)); diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 28ff6916..4ce5d144 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -11,7 +11,11 @@ import { useLocation, useParams } from "react-router"; import { AppHeader } from "../components/AppHeader.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx"; import { FeedTabBar } from "../components/FeedTabBar.tsx"; -import { DEFAULT_FEED_TAB, FEED_TABS, type FeedTab } from "../config/feedTabs.ts"; +import { + DEFAULT_FEED_TAB, + FEED_TABS, + type FeedTab, +} from "../config/feedTabs.ts"; import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; @@ -93,10 +97,11 @@ export function Index({ categorySlug }: { categorySlug?: string }) { : preferredTab; // "followed" needs a session: a guest there (a shared link, or signed out // on it) gets the default feed rather than an empty page. - const tab: FeedTab = (FEED_TABS as readonly string[]).includes(rawTab ?? "") && + const tab: FeedTab = + (FEED_TABS as readonly string[]).includes(rawTab ?? "") && (rawTab !== "followed" || !!user) - ? (rawTab as FeedTab) - : defaultTab; + ? (rawTab as FeedTab) + : defaultTab; // ── Main feed state ── @@ -287,7 +292,9 @@ export function Index({ categorySlug }: { categorySlug?: string }) { const categoryId = category?.id; const inScope = useCallback( (d: Dump) => - categorySlug ? !!categoryId && !!d.categoryIds?.includes(categoryId) : true, + categorySlug + ? !!categoryId && !!d.categoryIds?.includes(categoryId) + : true, [categorySlug, categoryId], ); @@ -322,7 +329,10 @@ export function Index({ categorySlug }: { categorySlug?: string }) { - +
} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index eb6d417c..5eccad96 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -555,7 +555,11 @@ export function PlaylistDetail() { prev.status === "loaded" ? { ...prev, - playlist: { ...prev.playlist, ...updated, dumps: prev.playlist.dumps }, + playlist: { + ...prev.playlist, + ...updated, + dumps: prev.playlist.dumps, + }, } : prev ); @@ -873,8 +877,10 @@ function PlaylistEditForm( return ( - {/* `display: contents` keeps the image-beside-content flex layout while - still giving us a real
for submit + Enter handling. */} + { + /* `display: contents` keeps the image-beside-content flex layout while + still giving us a real for submit + Enter handling. */ + } - {/* Each sub-block is its own grid, with a themable
between them. + { + /* Each sub-block is its own grid, with a themable
between them. A break sits between two grids rather than inside one because a grid item would be stretched to the 9rem row track — out here its height - is its own, down to nothing for a theme that wants no separation. */} + is its own, down to nothing for a theme that wants no separation. */ + }
{blocks.map((block, index) => ( diff --git a/src/test/components/ChatModal.test.tsx b/src/test/components/ChatModal.test.tsx index 94f2a908..b03757f6 100644 --- a/src/test/components/ChatModal.test.tsx +++ b/src/test/components/ChatModal.test.tsx @@ -35,14 +35,34 @@ describe("ChatModal: history", () => { test("shows the latest messages under a day separator, grouping runs", async () => { const t0 = Date.now() - 60_000; await setup([ - makeChatMessage({ id: "1", userId: "ann", authorUsername: "ann", body: "one", createdAt: new Date(t0) }), - makeChatMessage({ id: "2", userId: "ann", authorUsername: "ann", body: "two", createdAt: new Date(t0 + 1000) }), - makeChatMessage({ id: "3", userId: "bob", authorUsername: "bob", body: "three", createdAt: new Date(t0 + 2000) }), + makeChatMessage({ + id: "1", + userId: "ann", + authorUsername: "ann", + body: "one", + createdAt: new Date(t0), + }), + makeChatMessage({ + id: "2", + userId: "ann", + authorUsername: "ann", + body: "two", + createdAt: new Date(t0 + 1000), + }), + makeChatMessage({ + id: "3", + userId: "bob", + authorUsername: "bob", + body: "three", + createdAt: new Date(t0 + 2000), + }), ]); expect(screen.getByText("Today")).toBeInTheDocument(); // "two" continues ann's run: no second author link. expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1); - expect(screen.getByText("two").closest(".chat-message")).toHaveClass("chat-message--grouped"); + expect(screen.getByText("two").closest(".chat-message")).toHaveClass( + "chat-message--grouped", + ); expect(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50"); }); @@ -57,27 +77,55 @@ describe("ChatModal: history", () => { }); test("a full page offers older messages", async () => { - const page = Array.from({ length: 50 }, (_, i) => - makeChatMessage({ id: `m${i + 10}`, body: `msg ${i + 10}`, createdAt: new Date(Date.now() - (50 - i) * 1000) }) + const page = Array.from( + { length: 50 }, + (_, i) => + makeChatMessage({ + id: `m${i + 10}`, + body: `msg ${i + 10}`, + createdAt: new Date(Date.now() - (50 - i) * 1000), + }), + ); + route( + "GET /api/chat", + (req) => + req.url.searchParams.has("before") + ? wire([ + makeChatMessage({ + id: "m0", + body: "the oldest", + createdAt: new Date(Date.now() - 999_000), + }), + ]) + : wire(page), ); - route("GET /api/chat", (req) => - req.url.searchParams.has("before") - ? wire([makeChatMessage({ id: "m0", body: "the oldest", createdAt: new Date(Date.now() - 999_000) })]) - : wire(page)); const user = userEvent.setup(); renderApp( {}} />, { session: me }); - await user.click(await screen.findByRole("button", { name: "Load older messages" })); + await user.click( + await screen.findByRole("button", { name: "Load older messages" }), + ); await screen.findByText("the oldest"); - const req = calls("GET /api/chat").find((r) => r.url.searchParams.has("before"))!; + const req = calls("GET /api/chat").find((r) => + r.url.searchParams.has("before") + )!; expect(req.url.searchParams.get("beforeId")).toBe("m10"); - expect(screen.queryByRole("button", { name: "Load older messages" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Load older messages" })).not + .toBeInTheDocument(); }); test("the 'new' divider sits before the first unread message from someone else", async () => { const readAt = new Date(Date.now() - 30_000); await setup([ - makeChatMessage({ id: "old", body: "seen", createdAt: new Date(Date.now() - 60_000) }), - makeChatMessage({ id: "new1", body: "unseen", createdAt: new Date(Date.now() - 10_000) }), + makeChatMessage({ + id: "old", + body: "seen", + createdAt: new Date(Date.now() - 60_000), + }), + makeChatMessage({ + id: "new1", + body: "unseen", + createdAt: new Date(Date.now() - 10_000), + }), ], { welcome: { chatReadAt: readAt.toISOString() } }); const divider = document.querySelector(".chat-new-sep")!; expect(divider).toHaveTextContent("New"); @@ -91,7 +139,10 @@ describe("ChatModal: history", () => { test("tells the server the chat is open, and closed", async () => { const { socket } = await setup(); - expect(sent(socket, "chat_focus")).toContainEqual({ type: "chat_focus", open: true }); + expect(sent(socket, "chat_focus")).toContainEqual({ + type: "chat_focus", + open: true, + }); }); }); @@ -106,7 +157,15 @@ describe("ChatModal: sending", () => { socket.receive({ type: "chat_message", nonce: send.nonce, - message: wire(makeChatMessage({ id: "echo", userId: "me", authorUsername: "me", body: "hello there", createdAt: new Date() })), + message: wire( + makeChatMessage({ + id: "echo", + userId: "me", + authorUsername: "me", + body: "hello there", + createdAt: new Date(), + }), + ), }); expect(screen.queryByText("Sending…")).not.toBeInTheDocument(); expect(screen.getByText("hello there")).toBeInTheDocument(); @@ -130,13 +189,25 @@ describe("ChatModal: sending", () => { const { user, socket, composer } = await setup(); await user.type(composer, "oops{Enter}"); const [send] = sent(socket, "chat_send"); - socket.receive({ type: "error", scope: "chat", nonce: send.nonce, message: "Slow down" }); - const failed = screen.getByText("Slow down").closest(".chat-message")! as HTMLElement; + socket.receive({ + type: "error", + scope: "chat", + nonce: send.nonce, + message: "Slow down", + }); + const failed = screen.getByText("Slow down").closest( + ".chat-message", + )! as HTMLElement; await user.click(within(failed).getByRole("button", { name: "Retry" })); expect(sent(socket, "chat_send")).toHaveLength(2); const [, retry] = sent(socket, "chat_send"); - socket.receive({ type: "error", scope: "chat", nonce: retry.nonce, message: "Still no" }); + socket.receive({ + type: "error", + scope: "chat", + nonce: retry.nonce, + message: "Still no", + }); await user.type(composer, "draft"); await user.click(screen.getByRole("button", { name: "Edit" })); expect(composer).toHaveValue("draft\n\noops"); @@ -144,21 +215,32 @@ describe("ChatModal: sending", () => { }); test("typing is announced, and its end too", async () => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"], shouldAdvanceTime: true }); + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "Date"], + shouldAdvanceTime: true, + }); try { const { socket, composer } = await setup(); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); await user.type(composer, "abc"); - expect(sent(socket, "chat_typing")).toEqual([{ type: "chat_typing", typing: true }]); + expect(sent(socket, "chat_typing")).toEqual([{ + type: "chat_typing", + typing: true, + }]); act(() => vi.advanceTimersByTime(3_600)); - expect(sent(socket, "chat_typing").at(-1)).toEqual({ type: "chat_typing", typing: false }); + expect(sent(socket, "chat_typing").at(-1)).toEqual({ + type: "chat_typing", + typing: false, + }); } finally { vi.useRealTimers(); } }); test("the draft and reply outlive the chat", async () => { - const first = await setup([makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" })]); + const first = await setup([ + makeChatMessage({ id: "a", authorUsername: "ann", body: "question?" }), + ]); await first.user.type(first.composer, "half a thought"); const stored = JSON.parse(sessionStorage.getItem("chat-draft:me")!); expect(stored.draft).toBe("half a thought"); @@ -167,38 +249,68 @@ describe("ChatModal: sending", () => { describe("ChatModal: replies, edits, deletes", () => { const theirs = () => - makeChatMessage({ id: "t1", userId: "ann", authorUsername: "ann", body: "ask me **anything**", createdAt: new Date(Date.now() - 20_000) }); + makeChatMessage({ + id: "t1", + userId: "ann", + authorUsername: "ann", + body: "ask me **anything**", + createdAt: new Date(Date.now() - 20_000), + }); const mine = () => - makeChatMessage({ id: "m1", userId: "me", authorUsername: "me", body: "my words", createdAt: new Date(Date.now() - 10_000) }); + makeChatMessage({ + id: "m1", + userId: "me", + authorUsername: "me", + body: "my words", + createdAt: new Date(Date.now() - 10_000), + }); test("replying: the bar, the reference, and Escape cancelling just the reply", async () => { const { user, socket, composer, onClose } = await setup([theirs()]); - const msg = screen.getByText("anything").closest(".chat-message")! as HTMLElement; + const msg = screen.getByText("anything").closest( + ".chat-message", + )! as HTMLElement; await user.click(within(msg).getByRole("button", { name: "Reply" })); expect(screen.getByText("Replying to")).toBeInTheDocument(); - expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent("ask me anything"); + expect(document.querySelector(".chat-reply-bar-snippet")).toHaveTextContent( + "ask me anything", + ); await user.keyboard("{Escape}"); expect(screen.queryByText("Replying to")).not.toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); await user.click(within(msg).getByRole("button", { name: "Reply" })); await user.type(composer, "sure{Enter}"); - expect(sent(socket, "chat_send")[0]).toMatchObject({ body: "sure", replyToId: "t1" }); + expect(sent(socket, "chat_send")[0]).toMatchObject({ + body: "sure", + replyToId: "t1", + }); }); test("only your own messages can be edited and deleted", async () => { await setup([theirs(), mine()]); - const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement; - const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; - expect(within(t).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + const t = screen.getByText("anything").closest( + ".chat-message", + )! as HTMLElement; + const m = screen.getByText("my words").closest( + ".chat-message", + )! as HTMLElement; + expect(within(t).queryByRole("button", { name: "Edit" })).not + .toBeInTheDocument(); expect(within(m).getByRole("button", { name: "Edit" })).toBeInTheDocument(); - expect(within(m).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(within(m).getByRole("button", { name: "Delete" })) + .toBeInTheDocument(); }); test("moderators can delete anyone's", async () => { - await setup([theirs()], { session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse }); - const t = screen.getByText("anything").closest(".chat-message")! as HTMLElement; - expect(within(t).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + await setup([theirs()], { + session: makeSession({ id: "mod", role: "moderator" }) as AuthResponse, + }); + const t = screen.getByText("anything").closest( + ".chat-message", + )! as HTMLElement; + expect(within(t).getByRole("button", { name: "Delete" })) + .toBeInTheDocument(); }); test("Up in an empty composer edits your last message; Enter saves", async () => { @@ -208,33 +320,51 @@ describe("ChatModal: replies, edits, deletes", () => { const box = await screen.findByDisplayValue("my words"); await user.clear(box); await user.type(box, "better words{Enter}"); - expect(sent(socket, "chat_edit")).toEqual([{ type: "chat_edit", id: "m1", body: "better words" }]); + expect(sent(socket, "chat_edit")).toEqual([{ + type: "chat_edit", + id: "m1", + body: "better words", + }]); expect(screen.queryByDisplayValue("better words")).not.toBeInTheDocument(); }); test("an unchanged edit sends nothing; Escape leaves the edit, not the chat", async () => { const { user, socket, onClose } = await setup([mine()]); - const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; + const m = screen.getByText("my words").closest( + ".chat-message", + )! as HTMLElement; await user.click(within(m).getByRole("button", { name: "Edit" })); await user.click(screen.getByRole("button", { name: "Save" })); expect(sent(socket, "chat_edit")).toHaveLength(0); await user.click(within(m).getByRole("button", { name: "Edit" })); await user.keyboard("{Escape}"); - expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save" })).not + .toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); }); test("deleting asks first; Escape on the question keeps the chat open", async () => { const { user, socket, onClose } = await setup([mine()]); - const m = screen.getByText("my words").closest(".chat-message")! as HTMLElement; + const m = screen.getByText("my words").closest( + ".chat-message", + )! as HTMLElement; await user.click(within(m).getByRole("button", { name: "Delete" })); - expect(screen.getByRole("alertdialog")).toHaveTextContent("Delete this message?"); + expect(screen.getByRole("alertdialog")).toHaveTextContent( + "Delete this message?", + ); await user.keyboard("{Escape}"); expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); await user.click(within(m).getByRole("button", { name: "Delete" })); - await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" })); - expect(sent(socket, "chat_delete")).toEqual([{ type: "chat_delete", id: "m1" }]); + await user.click( + within(screen.getByRole("alertdialog")).getByRole("button", { + name: "Delete", + }), + ); + expect(sent(socket, "chat_delete")).toEqual([{ + type: "chat_delete", + id: "m1", + }]); socket.receive({ type: "chat_message_deleted", id: "m1" }); expect(screen.queryByText("my words")).not.toBeInTheDocument(); }); @@ -242,10 +372,17 @@ describe("ChatModal: replies, edits, deletes", () => { test("a reply to a deleted message says so", async () => { const { socket } = await setup([ theirs(), - makeChatMessage({ id: "r", body: "a reply", replyToId: "t1", replyToAuthor: "ann", replyToBody: "ask me" }), + makeChatMessage({ + id: "r", + body: "a reply", + replyToId: "t1", + replyToAuthor: "ann", + replyToBody: "ask me", + }), ]); socket.receive({ type: "chat_message_deleted", id: "t1" }); - expect(screen.getByText("deleted message").closest("button")).toBeDisabled(); + expect(screen.getByText("deleted message").closest("button")) + .toBeDisabled(); }); }); @@ -254,19 +391,31 @@ describe("ChatModal: live", () => { const { socket } = await setup(); socket.receive({ type: "chat_message", - message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })), + message: wire( + makeChatMessage({ + id: "x", + body: "hey @me look", + createdAt: new Date(), + }), + ), }); - expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass("chat-message--mention"); + expect(screen.getByText(/look/).closest(".chat-message")).toHaveClass( + "chat-message--mention", + ); }); test("an emoji-only message is shown large", async () => { await setup([makeChatMessage({ body: "🎉🎉" })]); - expect(document.querySelector(".chat-message-text--jumbo")).toHaveTextContent("🎉🎉"); + expect(document.querySelector(".chat-message-text--jumbo")) + .toHaveTextContent("🎉🎉"); }); test("who's typing", async () => { const { socket } = await setup(); - socket.receive({ type: "chat_typing_update", users: [{ userId: "ann", username: "ann", hasAvatar: false }] }); + socket.receive({ + type: "chat_typing_update", + users: [{ userId: "ann", username: "ann", hasAvatar: false }], + }); expect(screen.getByText("ann is typing…")).toBeInTheDocument(); socket.receive({ type: "chat_typing_update", @@ -276,7 +425,10 @@ describe("ChatModal: live", () => { ], }); expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument(); - socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] }); + socket.receive({ + type: "chat_typing_update", + users: [{ userId: "me", username: "me", hasAvatar: false }], + }); expect(screen.queryByText(/typing…/)).not.toBeInTheDocument(); }); @@ -289,16 +441,27 @@ describe("ChatModal: live", () => { }); test("disconnected, it says so; a reconnect resyncs from the oldest loaded message", async () => { - const old = makeChatMessage({ id: "o", body: "kept", createdAt: new Date(Date.now() - 50_000) }); - const edited = makeChatMessage({ id: "e", body: "before", createdAt: new Date(Date.now() - 40_000) }); + const old = makeChatMessage({ + id: "o", + body: "kept", + createdAt: new Date(Date.now() - 50_000), + }); + const edited = makeChatMessage({ + id: "e", + body: "before", + createdAt: new Date(Date.now() - 40_000), + }); const { socket } = await setup([old, edited]); route("GET /api/chat", () => wire([old, { ...edited, body: "after" }])); socket.drop(); - expect(screen.getByText("Disconnected — reconnecting…")).toBeInTheDocument(); + expect(screen.getByText("Disconnected — reconnecting…")) + .toBeInTheDocument(); await act(() => new Promise((r) => setTimeout(r, 600))); FakeWebSocket.last.welcome(); await screen.findByText("after"); const resync = calls("GET /api/chat").at(-1)!; - expect(resync.url.searchParams.get("after")).toBe(old.createdAt.toISOString()); + expect(resync.url.searchParams.get("after")).toBe( + old.createdAt.toISOString(), + ); }); }); diff --git a/src/test/components/CommentThread.test.tsx b/src/test/components/CommentThread.test.tsx index cb170409..1fafd072 100644 --- a/src/test/components/CommentThread.test.tsx +++ b/src/test/components/CommentThread.test.tsx @@ -23,7 +23,9 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) { token={session?.token ?? null} onCommentCreated={(c) => setComments((prev) => [...prev, c])} onCommentDeleted={(id) => - setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true } : c))} + setComments((prev) => + prev.map((c) => c.id === id ? { ...c, deleted: true } : c) + )} onCommentUpdated={(c) => setComments((prev) => prev.map((x) => x.id === c.id ? c : x))} /> @@ -33,9 +35,16 @@ function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) { return user; } -const created = (over: Partial) => - (req: { body: unknown }) => - wire(makeComment({ dumpId: "d1", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, ...over })); +const created = (over: Partial) => (req: { body: unknown }) => + wire( + makeComment({ + dumpId: "d1", + userId: "me", + authorUsername: "me", + body: (req.body as { body: string }).body, + ...over, + }), + ); describe("CommentThread", () => { test("counts visible comments and nests replies", () => { @@ -44,7 +53,8 @@ describe("CommentThread", () => { makeComment({ id: "b", parentId: "a", body: "reply" }), makeComment({ id: "c", deleted: true, body: "" }), ]); - expect(screen.getByRole("heading", { name: "2 comments" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "2 comments" })) + .toBeInTheDocument(); const top = screen.getByText("top").closest("li")!; expect(within(top).getByText("reply")).toBeInTheDocument(); expect(screen.getByText("[deleted]")).toBeInTheDocument(); @@ -52,8 +62,10 @@ describe("CommentThread", () => { test("guests read but can't write, reply or like", () => { setup([makeComment({ body: "hello" })], null); - expect(screen.queryByPlaceholderText("Add a comment…")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Reply" })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Add a comment…")).not + .toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reply" })).not + .toBeInTheDocument(); expect(screen.getByRole("button", { name: "Like" })).toBeDisabled(); }); @@ -66,8 +78,11 @@ describe("CommentThread", () => { await user.click(screen.getByRole("button", { name: "Post comment" })); await screen.findByText("First!", { selector: ".comment-body *" }); expect(box).toHaveValue(""); - expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "First!" }); - expect(screen.getByRole("heading", { name: "1 comment" })).toBeInTheDocument(); + expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ + body: "First!", + }); + expect(screen.getByRole("heading", { name: "1 comment" })) + .toBeInTheDocument(); }); test("Ctrl+Enter posts too; blank text doesn't", async () => { @@ -79,7 +94,9 @@ describe("CommentThread", () => { expect(calls("POST /api/dumps/:id/comments")).toHaveLength(0); await user.type(box, "ok"); await user.keyboard("{Control>}{Enter}{/Control}"); - await waitFor(() => expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1)); + await waitFor(() => + expect(calls("POST /api/dumps/:id/comments")).toHaveLength(1) + ); }); test("a failed post keeps the text and says why", async () => { @@ -96,7 +113,8 @@ describe("CommentThread", () => { test("the top-level Cancel only shows with text, and clears it", async () => { const user = setup([]); - expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel" })).not + .toBeInTheDocument(); await user.type(screen.getByPlaceholderText("Add a comment…"), "draft"); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(screen.getByPlaceholderText("Add a comment…")).toHaveValue(""); @@ -110,8 +128,14 @@ describe("CommentThread", () => { await waitFor(() => expect(box).toHaveFocus()); await user.type(box, "child"); await user.click(screen.getByRole("button", { name: "Post reply" })); - await waitFor(() => expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument()); - expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ body: "child", parentId: "a" }); + await waitFor(() => + expect(screen.queryByPlaceholderText("Write a reply…")).not + .toBeInTheDocument() + ); + expect(calls("POST /api/dumps/:id/comments")[0].body).toEqual({ + body: "child", + parentId: "a", + }); const parent = screen.getByText("parent").closest("li")!; expect(within(parent).getByText("child")).toBeInTheDocument(); }); @@ -119,14 +143,31 @@ describe("CommentThread", () => { test("Escape backs out of a reply", async () => { const user = setup([makeComment({ id: "a" })]); await user.click(screen.getByRole("button", { name: "Reply" })); - await user.type(screen.getByPlaceholderText("Write a reply…"), "nah{Escape}"); - expect(screen.queryByPlaceholderText("Write a reply…")).not.toBeInTheDocument(); + await user.type( + screen.getByPlaceholderText("Write a reply…"), + "nah{Escape}", + ); + expect(screen.queryByPlaceholderText("Write a reply…")).not + .toBeInTheDocument(); }); test("editing your own comment", async () => { - route("PATCH /api/comments/:id", (req) => - wire(makeComment({ id: "a", userId: "me", authorUsername: "me", body: (req.body as { body: string }).body, updatedAt: new Date() }))); - const user = setup([makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" })]); + route( + "PATCH /api/comments/:id", + (req) => + wire( + makeComment({ + id: "a", + userId: "me", + authorUsername: "me", + body: (req.body as { body: string }).body, + updatedAt: new Date(), + }), + ), + ); + const user = setup([ + makeComment({ id: "a", userId: "me", authorUsername: "me", body: "old" }), + ]); await user.click(screen.getByRole("button", { name: "Edit" })); const box = screen.getByDisplayValue("old"); await user.clear(box); @@ -140,7 +181,10 @@ describe("CommentThread", () => { const user = setup([makeComment({ id: "a", userId: "me", body: "same" })]); await user.click(screen.getByRole("button", { name: "Edit" })); await user.click(screen.getByRole("button", { name: "Save" })); - await waitFor(() => expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument()); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Save" })).not + .toBeInTheDocument() + ); expect(calls("PATCH /api/comments/:id")).toHaveLength(0); }); @@ -151,10 +195,13 @@ describe("CommentThread", () => { ]; setup(comments); const theirs = screen.getByText("theirs").closest("li")!; - expect(within(theirs).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); - expect(within(theirs).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(within(theirs).queryByRole("button", { name: "Edit" })).not + .toBeInTheDocument(); + expect(within(theirs).queryByRole("button", { name: "Delete" })).not + .toBeInTheDocument(); const mine = screen.getByText("mine").closest("li")!; - expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(within(mine).getByRole("button", { name: "Delete" })) + .toBeInTheDocument(); }); test("a moderator can delete anyone's", () => { @@ -163,7 +210,10 @@ describe("CommentThread", () => { }); test("deleting asks first", async () => { - route("DELETE /api/comments/:id", () => new Response(null, { status: 204 })); + route( + "DELETE /api/comments/:id", + () => new Response(null, { status: 204 }), + ); const user = setup([makeComment({ id: "a", userId: "me", body: "bye" })]); await user.click(screen.getByRole("button", { name: "Delete" })); const dialog = screen.getByRole("alertdialog"); @@ -176,8 +226,14 @@ describe("CommentThread", () => { route("DELETE /api/comments/:id", () => fail("Forbidden", 403)); const user = setup([makeComment({ id: "a", userId: "me", body: "stays" })]); await user.click(screen.getByRole("button", { name: "Delete" })); - await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete" })); - expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't be deleted/i); + await user.click( + within(screen.getByRole("alertdialog")).getByRole("button", { + name: "Delete", + }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + /couldn't be deleted/i, + ); expect(screen.getByText("stays")).toBeInTheDocument(); }); @@ -194,7 +250,9 @@ describe("CommentThread", () => { test("the timestamp links to the comment", () => { setup([makeComment({ id: "a" })]); - expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute("href", "/dumps/d1#comment-a"); + expect(screen.getByRole("link", { name: /ago/ })).toHaveAttribute( + "href", + "/dumps/d1#comment-a", + ); }); }); - diff --git a/src/test/components/DumpCreateModal.test.tsx b/src/test/components/DumpCreateModal.test.tsx index 13ea84b7..33c85214 100644 --- a/src/test/components/DumpCreateModal.test.tsx +++ b/src/test/components/DumpCreateModal.test.tsx @@ -1,15 +1,29 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DumpCreateModal } from "../../components/DumpCreateModal.tsx"; import { calls, fail, route } from "../fetch.ts"; -import { makeCategory, makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts"; +import { + makeCategory, + makeDump, + makePlaylist, + makeSession, + wire, +} from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; const session = makeSession({ username: "me" }); const categories = [makeCategory({ id: "c1", name: "Music" })]; -function setup(props: Partial> = {}) { +function setup( + props: Partial> = {}, +) { const onClose = vi.fn(); const injectDump = vi.fn(); const user = userEvent.setup(); @@ -21,11 +35,18 @@ function setup(props: Partial> = {} return { user, onClose, injectDump }; } -function previewRoutes({ title = "Page title", reached = true, dupes = [] as unknown[] } = {}) { +function previewRoutes( + { title = "Page title", reached = true, dupes = [] as unknown[] } = {}, +) { route("GET /api/preview", (req) => ({ reached, richContent: reached - ? { type: "link", url: req.url.searchParams.get("url"), title, siteName: "Site" } + ? { + type: "link", + url: req.url.searchParams.get("url"), + title, + siteName: "Site", + } : { type: "link", url: req.url.searchParams.get("url") }, })); route("GET /api/dumps/by-url", () => dupes); @@ -37,7 +58,8 @@ const linkBox = () => screen.getByPlaceholderText("Paste a link…"); describe("DumpCreateModal: a link", () => { test("Next stays disabled until there is a link or a file", async () => { const { user } = setup(); - expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "New dump" })) + .toBeInTheDocument(); expect(nextBtn()).toBeDisabled(); await user.type(linkBox(), "not a url at all"); expect(nextBtn()).toBeDisabled(); @@ -61,21 +83,39 @@ describe("DumpCreateModal: a link", () => { previewRoutes({ reached: false }); const { user } = setup(); await user.type(linkBox(), "https://down.test/"); - expect(await screen.findByText(/Couldn't load a preview/)).toBeInTheDocument(); + expect(await screen.findByText(/Couldn't load a preview/)) + .toBeInTheDocument(); expect(nextBtn()).toBeEnabled(); }); test("warns about an earlier dump of the same link", async () => { previewRoutes({ dupes: [ - { id: "old", slug: "old-one", title: "Old one", username: "ann", createdAt: new Date(Date.now() - 3600_000).toISOString(), voteCount: 1, commentCount: 0 }, - { id: "older", title: "Older", username: "bob", createdAt: new Date(0).toISOString(), voteCount: 0, commentCount: 0 }, + { + id: "old", + slug: "old-one", + title: "Old one", + username: "ann", + createdAt: new Date(Date.now() - 3600_000).toISOString(), + voteCount: 1, + commentCount: 0, + }, + { + id: "older", + title: "Older", + username: "bob", + createdAt: new Date(0).toISOString(), + voteCount: 0, + commentCount: 0, + }, ], }); const { user, onClose } = setup(); await user.type(linkBox(), "https://x.test/"); const notice = await screen.findByText(/Already dumped by ann/); - expect(notice.closest("[role=status]")).toHaveTextContent("and 1 earlier dump"); + expect(notice.closest("[role=status]")).toHaveTextContent( + "and 1 earlier dump", + ); await user.click(screen.getByRole("link", { name: "Old one" })); expect(currentPath()).toBe("/dumps/old-one"); expect(onClose).toHaveBeenCalled(); @@ -83,10 +123,18 @@ describe("DumpCreateModal: a link", () => { test("the whole way through: post, then add to a playlist", async () => { previewRoutes(); - const created = makeDump({ id: "new", slug: "page-title", title: "Page title", userId: session.user.id }); + const created = makeDump({ + id: "new", + slug: "page-title", + title: "Page title", + userId: session.user.id, + }); route("POST /api/dumps", () => wire(created)); const mix = makePlaylist({ id: "p1", title: "Mix" }); - route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(mix), hasDump: false }]); + route( + "GET /api/playlists/by-dump/:id/memberships", + () => [{ playlist: wire(mix), hasDump: false }], + ); route("POST /api/playlists/:pid/dumps/:did", () => ({})); const { user, injectDump, onClose } = setup(); @@ -109,7 +157,9 @@ describe("DumpCreateModal: a link", () => { isPrivate: true, categoryIds: ["c1"], }); - expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" })); + expect(injectDump).toHaveBeenCalledWith( + expect.objectContaining({ id: "new" }), + ); expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); const row = await screen.findByRole("checkbox", { name: /Mix/ }); @@ -117,7 +167,9 @@ describe("DumpCreateModal: a link", () => { row.focus(); await user.keyboard(" "); await waitFor(() => expect(row).toHaveAttribute("aria-checked", "true")); - expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/p1/dumps/new"); + expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe( + "/api/playlists/p1/dumps/new", + ); await user.click(screen.getByRole("button", { name: "Done" })); expect(onClose).toHaveBeenCalled(); @@ -126,7 +178,13 @@ describe("DumpCreateModal: a link", () => { test("a refused playlist toggle leaves the box as it was", async () => { previewRoutes(); route("POST /api/dumps", () => wire(makeDump({ id: "new" }))); - route("GET /api/playlists/by-dump/:id/memberships", () => [{ playlist: wire(makePlaylist({ title: "Mix" })), hasDump: false }]); + route( + "GET /api/playlists/by-dump/:id/memberships", + () => [{ + playlist: wire(makePlaylist({ title: "Mix" })), + hasDump: false, + }], + ); route("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403)); const { user } = setup(); await user.type(linkBox(), "https://a.test/{Enter}"); @@ -143,14 +201,18 @@ describe("DumpCreateModal: a link", () => { route("GET /api/playlists/by-dump/:id/memberships", () => []); const { user } = setup(); await user.type(linkBox(), "https://a.test/"); - await waitFor(() => expect(screen.getByLabelText("Title")).toHaveValue("Page title")); + await waitFor(() => + expect(screen.getByLabelText("Title")).toHaveValue("Page title") + ); await user.click(screen.getByRole("button", { name: "Edit title" })); const title = screen.getByLabelText("Title"); await waitFor(() => expect(title).toHaveFocus()); await user.clear(title); await user.type(title, "My title"); // The suggestion can be had back. - await user.click(screen.getByRole("button", { name: "Reset to the suggested title" })); + await user.click( + screen.getByRole("button", { name: "Reset to the suggested title" }), + ); expect(title).toHaveValue("Page title"); await user.click(screen.getByRole("button", { name: "Edit title" })); await user.clear(title); @@ -158,7 +220,9 @@ describe("DumpCreateModal: a link", () => { await user.click(nextBtn()); await user.click(screen.getByRole("button", { name: "Dump it" })); await waitFor(() => expect(calls("POST /api/dumps")).toHaveLength(1)); - expect(calls("POST /api/dumps")[0].body).toMatchObject({ title: "Mine after all" }); + expect(calls("POST /api/dumps")[0].body).toMatchObject({ + title: "Mine after all", + }); }); test("Enter in the link field goes to the next step rather than posting", async () => { @@ -180,7 +244,8 @@ describe("DumpCreateModal: a link", () => { const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent("Failed to post"); expect(alert).toHaveTextContent("That URL isn't allowed"); - expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument(); + expect(screen.getByRole("dialog", { name: "New dump" })) + .toBeInTheDocument(); }); test("starts from a shared URL", async () => { @@ -206,21 +271,28 @@ describe("DumpCreateModal: a link", () => { describe("DumpCreateModal: drafts", () => { test("a dismissed draft comes back, and can be dropped", async () => { - localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ - url: "https://draft.test/", - title: "", - titleFromUser: false, - comment: "half-written", - isPublic: false, - categoryIds: ["c1"], - })); + localStorage.setItem( + "gerbeur.dumpDraft", + JSON.stringify({ + url: "https://draft.test/", + title: "", + titleFromUser: false, + comment: "half-written", + isPublic: false, + categoryIds: ["c1"], + }), + ); previewRoutes(); const { user } = setup(); - expect(screen.getByText("Picked up where you left off.")).toBeInTheDocument(); + expect(screen.getByText("Picked up where you left off.")) + .toBeInTheDocument(); expect(linkBox()).toHaveValue("https://draft.test/"); await user.click(nextBtn()); expect(screen.getByLabelText("Why?")).toHaveValue("half-written"); - expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute( + "aria-pressed", + "true", + ); await user.click(screen.getByRole("button", { name: "← Back" })); await user.click(screen.getByRole("button", { name: "Start fresh" })); expect(linkBox()).toHaveValue(""); @@ -231,14 +303,20 @@ describe("DumpCreateModal: drafts", () => { previewRoutes(); const { user } = setup(); await user.type(linkBox(), "https://keep.test/"); - expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe("https://keep.test/"); + expect(JSON.parse(localStorage.getItem("gerbeur.dumpDraft")!).url).toBe( + "https://keep.test/", + ); }); test("a shared URL starts clean rather than merging an old draft", () => { - localStorage.setItem("gerbeur.dumpDraft", JSON.stringify({ url: "https://old.test/", comment: "old why" })); + localStorage.setItem( + "gerbeur.dumpDraft", + JSON.stringify({ url: "https://old.test/", comment: "old why" }), + ); previewRoutes(); setup({ initialUrl: "https://new.test/" }); - expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument(); + expect(screen.queryByText("Picked up where you left off.")).not + .toBeInTheDocument(); expect(linkBox()).toHaveValue("https://new.test/"); }); }); @@ -252,7 +330,10 @@ describe("DumpCreateModal: a file", () => { body: FormData | null = null; private l: Record void>> = {}; private u: Record void>> = {}; - upload = { addEventListener: (t: string, f: (e: unknown) => void) => (this.u[t] ??= []).push(f) }; + upload = { + addEventListener: (t: string, f: (e: unknown) => void) => + (this.u[t] ??= []).push(f), + }; constructor() { FakeXHR.last = this; } @@ -265,7 +346,9 @@ describe("DumpCreateModal: a file", () => { this.body = body; } progress(f: number) { - this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true })); + this.u.progress?.forEach((fn) => + fn({ loaded: f * 100, total: 100, lengthComputable: true }) + ); } respond(status: number, data: unknown) { this.status = status; @@ -275,7 +358,8 @@ describe("DumpCreateModal: a file", () => { } afterEach(() => vi.unstubAllGlobals()); - const song = () => new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" }); + const song = () => + new File([new Uint8Array(10)], "my_song-final.mp3", { type: "audio/mpeg" }); test("a dropped file names the dump and uploads with progress", async () => { vi.stubGlobal("XMLHttpRequest", FakeXHR); @@ -286,19 +370,29 @@ describe("DumpCreateModal: a file", () => { fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { dataTransfer: { files: [song()] }, }); - expect(screen.queryByPlaceholderText("Paste a link…")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Paste a link…")).not + .toBeInTheDocument(); expect(screen.getByText("my_song-final.mp3")).toBeInTheDocument(); expect(screen.getByLabelText("Title")).toHaveValue("my song-final"); await user.click(nextBtn()); await user.click(screen.getByRole("button", { name: "Dump it" })); act(() => FakeXHR.last.progress(0.42)); - expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42"); - expect(screen.getByRole("button", { name: "Uploading 42%" })).toBeDisabled(); + expect(screen.getByRole("progressbar")).toHaveAttribute( + "aria-valuenow", + "42", + ); + expect(screen.getByRole("button", { name: "Uploading 42%" })) + .toBeDisabled(); const fd = FakeXHR.last.body!; expect(fd.get("title")).toBe("my song-final"); expect(fd.get("isPrivate")).toBe("false"); expect(fd.get("categoryIds")).toBe("[]"); - await act(async () => FakeXHR.last.respond(201, { success: true, data: wire(makeDump({ id: "f" })) })); + await act(async () => + FakeXHR.last.respond(201, { + success: true, + data: wire(makeDump({ id: "f" })), + }) + ); await screen.findByRole("dialog", { name: "Add to playlist" }); }); @@ -310,9 +404,16 @@ describe("DumpCreateModal: a file", () => { }); await user.keyboard("{Escape}"); expect(onClose).not.toHaveBeenCalled(); - const confirm = screen.getByText(/The attached file won't be kept/).closest("[role=alert]")!; - await user.click(within(confirm as HTMLElement).getByRole("button", { name: "Keep editing" })); - expect(screen.queryByText(/The attached file won't be kept/)).not.toBeInTheDocument(); + const confirm = screen.getByText(/The attached file won't be kept/).closest( + "[role=alert]", + )!; + await user.click( + within(confirm as HTMLElement).getByRole("button", { + name: "Keep editing", + }), + ); + expect(screen.queryByText(/The attached file won't be kept/)).not + .toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Cancel" })); await user.click(screen.getByRole("button", { name: "Close anyway" })); expect(onClose).toHaveBeenCalled(); diff --git a/src/test/components/Modal.test.tsx b/src/test/components/Modal.test.tsx index f83c89d1..6aad8d4c 100644 --- a/src/test/components/Modal.test.tsx +++ b/src/test/components/Modal.test.tsx @@ -22,7 +22,11 @@ describe("Modal", () => { test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => { const onClose = vi.fn(); const user = userEvent.setup(); - wrap(); + wrap( + + + , + ); await user.click(screen.getByText("inside")); expect(onClose).not.toHaveBeenCalled(); await user.keyboard("{Escape}"); @@ -34,7 +38,9 @@ describe("Modal", () => { test("onBeforeClose can refuse", async () => { const onClose = vi.fn(); const user = userEvent.setup(); - wrap( false}>x); + wrap( + false}>x, + ); await user.keyboard("{Escape}"); await user.click(screen.getByRole("button", { name: "Close" })); expect(onClose).not.toHaveBeenCalled(); @@ -60,7 +66,11 @@ describe("ConfirmModal", () => { const onCancel = vi.fn(); const user = userEvent.setup(); wrap( - , + , ); const dialog = screen.getByRole("alertdialog"); expect(dialog).toHaveAccessibleDescription("Really?"); @@ -74,7 +84,12 @@ describe("ConfirmModal", () => { test("takes a custom label", () => { wrap( - {}} onCancel={() => {}} />, + {}} + onCancel={() => {}} + />, ); expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument(); }); diff --git a/src/test/components/TextEditor.test.tsx b/src/test/components/TextEditor.test.tsx index 07f51048..f46d0a5e 100644 --- a/src/test/components/TextEditor.test.tsx +++ b/src/test/components/TextEditor.test.tsx @@ -1,8 +1,17 @@ import { describe, expect, test, vi } from "vitest"; -import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRef, useState } from "react"; -import { TextEditor, type TextEditorHandle } from "../../components/TextEditor.tsx"; +import { + TextEditor, + type TextEditorHandle, +} from "../../components/TextEditor.tsx"; import { calls, deferred, fail, route } from "../fetch.ts"; import { makeSession } from "../fixtures.ts"; import { renderWithContexts } from "../render.tsx"; @@ -54,7 +63,9 @@ describe("TextEditor basics", () => { await user.keyboard("{Meta>}{Enter}{/Meta}"); expect(onSubmit).toHaveBeenCalledTimes(2); await user.keyboard("{Enter}"); - expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + expect(onKeyDown).toHaveBeenCalledWith( + expect.objectContaining({ key: "Enter" }), + ); }); test("an Enter confirming an IME candidate does nothing else", () => { @@ -81,7 +92,9 @@ describe("TextEditor basics", () => { await user.type(box, "8"); expect(screen.getByText("8 / 10")).toBeInTheDocument(); await user.type(box, "901"); - expect(screen.getByText("11 / 10")).toHaveClass("text-editor-count--danger"); + expect(screen.getByText("11 / 10")).toHaveClass( + "text-editor-count--danger", + ); expect(box).toHaveAttribute("aria-invalid", "true"); }); @@ -92,7 +105,9 @@ describe("TextEditor basics", () => { test("the handle focuses at the end", () => { const ref = createRef(); - renderWithContexts( {}} />); + renderWithContexts( + {}} />, + ); act(() => ref.current!.focus({ atEnd: true })); const box = screen.getByRole("textbox") as HTMLTextAreaElement; expect(box).toHaveFocus(); @@ -152,7 +167,9 @@ describe("mentions", () => { await user.type(box, "@al"); await screen.findByRole("listbox"); await user.keyboard("{Enter}"); - expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + expect(onKeyDown).not.toHaveBeenCalledWith( + expect.objectContaining({ key: "Enter" }), + ); expect(box).toHaveValue("@alice "); }); @@ -172,9 +189,13 @@ describe("image upload", () => { const { user, box } = setup({}, "look: "); await user.click(box); box.setSelectionRange(6, 6); - fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); + fireEvent.paste(box, { + clipboardData: { types: ["Files"], files: [imageFile()] }, + }); await waitFor(() => - expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)") + expect(box).toHaveValue( + "look: ![](http://localhost:8000/api/attachments/att1)", + ) ); const [req] = calls("POST /api/attachments"); expect(req.body).toBeInstanceOf(FormData); @@ -195,14 +216,20 @@ describe("image upload", () => { route("POST /api/attachments", () => pending.promise); const onKeyDown = vi.fn(); const { box } = setup({ onKeyDown }); - fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); - expect(await screen.findByRole("status")).toHaveTextContent("Uploading image…"); + fireEvent.paste(box, { + clipboardData: { types: ["Files"], files: [imageFile()] }, + }); + expect(await screen.findByRole("status")).toHaveTextContent( + "Uploading image…", + ); expect(box).toHaveAttribute("readonly"); expect(box).toHaveAttribute("aria-busy", "true"); fireEvent.keyDown(box, { key: "Enter" }); expect(onKeyDown).not.toHaveBeenCalled(); await act(async () => - pending.resolve(new Response(JSON.stringify({ success: true, data: { id: "z" } }))) + pending.resolve( + new Response(JSON.stringify({ success: true, data: { id: "z" } })), + ) ); await waitFor(() => expect(box).not.toHaveAttribute("readonly")); }); @@ -210,29 +237,47 @@ describe("image upload", () => { test("a failed upload is reported", async () => { route("POST /api/attachments", () => fail("Too big", 413)); const { box } = setup(); - fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); - expect(await screen.findByRole("alert")).toHaveTextContent("The image couldn't be uploaded."); + fireEvent.paste(box, { + clipboardData: { types: ["Files"], files: [imageFile()] }, + }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "The image couldn't be uploaded.", + ); expect(box).toHaveValue(""); expect(box).toHaveAttribute("aria-describedby"); }); test("dropping a non-image is caught and explained", () => { const { box } = setup(); - const dataTransfer = { types: ["Files"], files: [imageFile("a.pdf", "application/pdf")], dropEffect: "" }; + const dataTransfer = { + types: ["Files"], + files: [imageFile("a.pdf", "application/pdf")], + dropEffect: "", + }; const over = fireEvent.dragOver(box, { dataTransfer }); expect(over).toBe(false); // prevented: the browser mustn't open the file fireEvent.drop(box, { dataTransfer }); - expect(screen.getByRole("alert")).toHaveTextContent("Only images can be dropped here."); + expect(screen.getByRole("alert")).toHaveTextContent( + "Only images can be dropped here.", + ); expect(calls("POST /api/attachments")).toHaveLength(0); }); test("dropped images upload; dragged text is left to the browser", async () => { - route("POST /api/attachments", (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` })); + route( + "POST /api/attachments", + (req) => ({ id: `n${calls("POST /api/attachments").indexOf(req)}` }), + ); const { box } = setup(); - const textDrag = fireEvent.dragOver(box, { dataTransfer: { types: ["text/plain"], files: [] } }); + const textDrag = fireEvent.dragOver(box, { + dataTransfer: { types: ["text/plain"], files: [] }, + }); expect(textDrag).toBe(true); fireEvent.drop(box, { - dataTransfer: { types: ["Files"], files: [imageFile("a.png"), imageFile("b.png")] }, + dataTransfer: { + types: ["Files"], + files: [imageFile("a.png"), imageFile("b.png")], + }, }); await waitFor(() => expect(box.value).toContain("attachments/n1")); expect(box.value.split("\n")).toHaveLength(2); @@ -245,11 +290,14 @@ describe("emoji", () => { const onCancel = vi.fn(); const { user, box } = setup({ onCancel }); await user.type(box, "hi :astro"); - const search = await screen.findByRole("searchbox", { name: "Search emoji" }); + const search = await screen.findByRole("searchbox", { + name: "Search emoji", + }); expect(search).toHaveValue("astro"); expect(box).toHaveFocus(); await user.keyboard("{Escape}"); - expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not + .toBeInTheDocument(); expect(onCancel).not.toHaveBeenCalled(); expect(box).toHaveValue("hi :astro"); }); @@ -267,7 +315,8 @@ describe("emoji", () => { serveEmojibase(); const { user, box } = setup(); await user.type(box, "ok :D at 12:30"); - expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not + .toBeInTheDocument(); }); test("the button browses and inserts at the caret", async () => { @@ -276,11 +325,16 @@ describe("emoji", () => { await user.click(box); box.setSelectionRange(1, 1); await user.click(screen.getByRole("button", { name: "Insert emoji" })); - expect(screen.getByRole("button", { name: "Insert emoji" })).toHaveAttribute("aria-expanded", "true"); - const search = await screen.findByRole("searchbox", { name: "Search emoji" }); + expect(screen.getByRole("button", { name: "Insert emoji" })) + .toHaveAttribute("aria-expanded", "true"); + const search = await screen.findByRole("searchbox", { + name: "Search emoji", + }); await waitFor(() => expect(search).toHaveFocus()); await user.type(search, "astronaut"); - await user.click(await screen.findByRole("gridcell", { name: "Astronaut" })); + await user.click( + await screen.findByRole("gridcell", { name: "Astronaut" }), + ); expect(box).toHaveValue("a🧑‍🚀b"); }); @@ -290,6 +344,7 @@ describe("emoji", () => { await user.click(screen.getByRole("button", { name: "Insert emoji" })); await screen.findByRole("searchbox", { name: "Search emoji" }); await user.click(document.body); - expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not + .toBeInTheDocument(); }); }); diff --git a/src/test/components/buttons.test.tsx b/src/test/components/buttons.test.tsx index 362f0c3f..8861257a 100644 --- a/src/test/components/buttons.test.tsx +++ b/src/test/components/buttons.test.tsx @@ -5,7 +5,10 @@ import { useRef } from "react"; import { Avatar } from "../../components/Avatar.tsx"; import { VoteButton } from "../../components/VoteButton.tsx"; import { LikeButton } from "../../components/LikeButton.tsx"; -import { FollowPlaylistButton, FollowUserButton } from "../../components/FollowButton.tsx"; +import { + FollowPlaylistButton, + FollowUserButton, +} from "../../components/FollowButton.tsx"; import { UserListPopover } from "../../components/UserListPopover.tsx"; import { NotificationBell } from "../../components/NotificationBell.tsx"; import { ChatButton } from "../../components/ChatButton.tsx"; @@ -13,12 +16,14 @@ import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; import { ChatContext } from "../../contexts/ChatContext.ts"; import { calls, fail, route } from "../fetch.ts"; import { makeNotification, makeSession, makeUser, page } from "../fixtures.ts"; -import { currentPath, defaultOf, renderWithContexts } from "../render.tsx"; -import { WSContext } from "../../contexts/WSContext.ts"; +import { currentPath, renderWithContexts } from "../render.tsx"; +import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts"; describe("Avatar", () => { test("an image when the user has one, versioned", () => { - renderWithContexts(); + renderWithContexts( + , + ); expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( "src", "http://localhost:8000/api/avatars/u1?v=7", @@ -34,18 +39,25 @@ describe("Avatar", () => { test("a live override wins over stale props, and retries a failed image", () => { const { rerender } = renderWithContexts( - + , ); expect(screen.queryByRole("img")).not.toBeInTheDocument(); rerender( - + , ); // rerender replaces the tree under the same router wrapper - expect(screen.getByRole("img")).toHaveAttribute("src", expect.stringContaining("?v=2")); + expect(screen.getByRole("img")).toHaveAttribute( + "src", + expect.stringContaining("?v=2"), + ); }); }); @@ -55,18 +67,39 @@ describe("VoteButton / LikeButton", () => { const onRemove = vi.fn(); const user = userEvent.setup(); const { rerender } = renderWithContexts( - , + , ); await user.click(screen.getByRole("button", { name: "Upvote" })); expect(onCast).toHaveBeenCalledWith("d", 0); - rerender(); + rerender( + , + ); await user.click(screen.getByRole("button", { name: "Remove vote" })); expect(onRemove).toHaveBeenCalledWith("d", 3); }); test("disabled for guests, with a hint", () => { renderWithContexts( - {}} onRemove={() => {}} />, + {}} + onRemove={() => {}} + />, ); const btn = screen.getByRole("button", { name: "Upvote" }); expect(btn).toBeDisabled(); @@ -75,17 +108,27 @@ describe("VoteButton / LikeButton", () => { test("the count opens who voted, without voting", async () => { const onCast = vi.fn(); - route("GET /api/dumps/:id/voters", () => page([makeUser({ username: "fan" })])); + route( + "GET /api/dumps/:id/voters", + () => page([makeUser({ username: "fan" })]), + ); const user = userEvent.setup(); renderWithContexts( - {}} />, + {}} + />, ); await user.click(screen.getByText("1")); expect(onCast).not.toHaveBeenCalled(); - expect(await screen.findByRole("menuitem", { name: /fan/ })).toHaveAttribute( - "href", - "/users/fan", - ); + expect(await screen.findByRole("menuitem", { name: /fan/ })) + .toHaveAttribute( + "href", + "/users/fan", + ); }); test("likes work the same way", async () => { @@ -94,13 +137,27 @@ describe("VoteButton / LikeButton", () => { route("GET /api/comments/:id/likers", () => page([])); const user = userEvent.setup(); const { rerender } = renderWithContexts( - , + , ); await user.click(screen.getByRole("button", { name: "Like" })); expect(onLike).toHaveBeenCalledWith("c", 2); await user.click(screen.getByText("2")); expect(await screen.findByText("No one yet.")).toBeInTheDocument(); - rerender(); + rerender( + , + ); await user.click(screen.getByRole("button", { name: "Remove like" })); expect(onUnlike).toHaveBeenCalledWith("c", 2); }); @@ -113,28 +170,40 @@ describe("UserListPopover", () => { <> anchor - + ); } test("pages through the list", async () => { - route("GET /api/x/voters", (req) => - req.url.searchParams.get("page") === "1" - ? page([makeUser({ username: "a" })], true) - : page([makeUser({ username: "b" })], false)); + route( + "GET /api/x/voters", + (req) => + req.url.searchParams.get("page") === "1" + ? page([makeUser({ username: "a" })], true) + : page([makeUser({ username: "b" })], false), + ); const user = userEvent.setup(); renderWithContexts(, { session: makeSession() }); expect(screen.getByText("Loading…")).toBeInTheDocument(); await screen.findByText("a"); await user.click(screen.getByRole("button", { name: "Load more" })); await screen.findByText("b"); - expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); - expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch(/^Bearer /); + expect(screen.queryByRole("button", { name: "Load more" })).not + .toBeInTheDocument(); + expect(calls("GET /api/x/voters")[0].headers.get("Authorization")).toMatch( + /^Bearer /, + ); }); test("shows the server's error, and a network one", async () => { - route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true }); + route("GET /api/x/voters", () => fail("Dump not found", 404), { + once: true, + }); const first = renderWithContexts(); await screen.findByText("Dump not found"); first.unmount(); @@ -144,9 +213,16 @@ describe("UserListPopover", () => { }); test("a failed next page lets you try again", async () => { - route("GET /api/x/voters", () => page([makeUser({ username: "a" })], true), { once: true }); + route( + "GET /api/x/voters", + () => page([makeUser({ username: "a" })], true), + { once: true }, + ); route("GET /api/x/voters", () => new TypeError("offline"), { once: true }); - route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false)); + route( + "GET /api/x/voters", + () => page([makeUser({ username: "b" })], false), + ); const user = userEvent.setup(); renderWithContexts(); await screen.findByText("a"); @@ -184,7 +260,10 @@ describe("FollowButton", () => { ); expect(screen.queryByRole("button")).not.toBeInTheDocument(); unmount(); - renderWithContexts(, { session: me }); + renderWithContexts( + , + { session: me }, + ); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -206,10 +285,13 @@ describe("FollowButton", () => { test("shows Following, and unfollows", async () => { const unfollowUser = vi.fn(); const user = userEvent.setup(); - renderWithContexts(, { - session: me, - follows: { followedUserIds: new Set(["u"]), unfollowUser }, - }); + renderWithContexts( + , + { + session: me, + follows: { followedUserIds: new Set(["u"]), unfollowUser }, + }, + ); const btn = screen.getByRole("button", { name: "Unfollow ann" }); expect(btn).toHaveTextContent("Following"); await user.click(btn); @@ -217,10 +299,13 @@ describe("FollowButton", () => { }); test("disabled until follows have loaded", () => { - renderWithContexts(, { - session: me, - follows: { isLoaded: false }, - }); + renderWithContexts( + , + { + session: me, + follows: { isLoaded: false }, + }, + ); expect(screen.getByRole("button")).toBeDisabled(); }); @@ -236,7 +321,11 @@ describe("FollowButton", () => { priv.unmount(); renderWithContexts(, { session: me, - follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) }, + follows: { + followPlaylist, + unfollowPlaylist, + followedPlaylistIds: new Set(["p"]), + }, }); await user.click(screen.getByRole("button", { name: "Unfollow playlist" })); expect(unfollowPlaylist).toHaveBeenCalledWith("p"); @@ -246,8 +335,12 @@ describe("FollowButton", () => { describe("NotificationBell / ChatButton", () => { test("the bell shows the unread count and goes to notifications", async () => { const user = userEvent.setup(); - renderWithContexts(, { ws: { unreadNotificationCount: 120 } }); - const bell = screen.getByRole("button", { name: "Notifications (120 unread)" }); + renderWithContexts(, { + ws: { unreadNotificationCount: 120 }, + }); + const bell = screen.getByRole("button", { + name: "Notifications (120 unread)", + }); expect(bell).toHaveTextContent("99+"); await user.click(bell); expect(currentPath()).toBe("/notifications"); @@ -258,7 +351,9 @@ describe("NotificationBell / ChatButton", () => { try { const n1 = makeNotification({ id: "n1" }); const withLast = (n: ReturnType) => ( - + ); @@ -280,7 +375,9 @@ describe("NotificationBell / ChatButton", () => { const openChat = vi.fn(); const user = userEvent.setup(); renderWithContexts( - {} }}> + {} }} + > , { ws: { unreadChatCount: 4 } }, diff --git a/src/test/components/content.test.tsx b/src/test/components/content.test.tsx index d44b801f..1bf136fa 100644 --- a/src/test/components/content.test.tsx +++ b/src/test/components/content.test.tsx @@ -19,7 +19,9 @@ describe("Markdown", () => { md("**bold** ~~gone~~\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done"); expect(screen.getByText("bold").tagName).toBe("STRONG"); expect(screen.getByText("gone").tagName).toBe("DEL"); - expect(screen.getByRole("table").parentElement).toHaveClass("md-table-wrap"); + expect(screen.getByRole("table").parentElement).toHaveClass( + "md-table-wrap", + ); expect(screen.getByRole("checkbox")).toBeChecked(); }); @@ -51,11 +53,25 @@ describe("Markdown", () => { md( `[ext](https://elsewhere.test) [in](/dumps/x) [same](${location.origin}/playlists/p?x=1#y) [file](/api/files/1)`, ); - expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("target", "_blank"); - expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute("rel", "noopener noreferrer"); - expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute("target"); - expect(screen.getByRole("link", { name: "same" })).toHaveAttribute("href", "/playlists/p?x=1#y"); - expect(screen.getByRole("link", { name: "file" })).toHaveAttribute("target", "_blank"); + expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( + "target", + "_blank", + ); + expect(screen.getByRole("link", { name: "ext" })).toHaveAttribute( + "rel", + "noopener noreferrer", + ); + expect(screen.getByRole("link", { name: "in" })).not.toHaveAttribute( + "target", + ); + expect(screen.getByRole("link", { name: "same" })).toHaveAttribute( + "href", + "/playlists/p?x=1#y", + ); + expect(screen.getByRole("link", { name: "file" })).toHaveAttribute( + "target", + "_blank", + ); }); test("a single newline is a line break", () => { @@ -71,7 +87,10 @@ describe("Markdown", () => { test("images load lazily; inline teasers drop them", () => { const full = md("![cat](https://x.test/cat.png)"); - expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute("loading", "lazy"); + expect(screen.getByRole("img", { name: "cat" })).toHaveAttribute( + "loading", + "lazy", + ); full.unmount(); md("![cat](https://x.test/cat.png) text", true); expect(screen.queryByRole("img")).not.toBeInTheDocument(); @@ -96,15 +115,20 @@ describe("Thumbnail", () => { test("direct, then proxied, then the placeholder", () => { renderWithContexts( - , + , ); const img = screen.getByRole("img", { name: "art" }); expect(img).toHaveAttribute("src", "https://img.test/a.png"); expect(img).toHaveAttribute("referrerpolicy", "no-referrer"); fireEvent.error(img); - expect(screen.getByRole("img", { name: "art" }).getAttribute("src")).toContain( - "/api/proxy-image?url=", - ); + expect(screen.getByRole("img", { name: "art" }).getAttribute("src")) + .toContain( + "/api/proxy-image?url=", + ); fireEvent.error(screen.getByRole("img", { name: "art" })); expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument(); expect(screen.getByText("S")).toBeInTheDocument(); @@ -112,16 +136,28 @@ describe("Thumbnail", () => { test("our own images skip the proxy retry", () => { renderWithContexts( - , + , ); fireEvent.error(screen.getByRole("img", { name: "t" })); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); }); test("no src: straight to the placeholder; a new src tries again", () => { - const { rerender } = renderWithContexts(); + const { rerender } = renderWithContexts( + , + ); expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); - rerender(); + rerender( + , + ); expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); }); }); @@ -129,17 +165,31 @@ describe("Thumbnail", () => { describe("ThumbnailPlaceholder", () => { test("tints by the page colour, or by hostname", () => { const { container, rerender } = renderWithContexts( - , + , ); - const box = () => container.querySelector(".thumb-placeholder")!; + const box = () => + container.querySelector(".thumb-placeholder")!; expect(box().style.getPropertyValue("--thumb-tint")).toBe("#123456"); - rerender(); - expect(box().style.getPropertyValue("--thumb-tint")).toBe(hueFromString("site.test")); + rerender( + , + ); + expect(box().style.getPropertyValue("--thumb-tint")).toBe( + hueFromString("site.test"), + ); }); test("favicon, then the initial when it fails; a glyph wins over the initial", () => { const { container, rerender } = renderWithContexts( - , + , ); const icon = container.querySelector("img")!; fireEvent.error(icon); @@ -163,10 +213,16 @@ describe("RichContentCard", () => { test("full card: play button, source link and details", async () => { const play = vi.fn(); - renderWithContexts(, { - player: { play }, - }); - expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute("href", yt.url); + renderWithContexts( + , + { + player: { play }, + }, + ); + expect(screen.getByRole("link", { name: /A video/ })).toHaveAttribute( + "href", + yt.url, + ); expect(screen.getByText("About it")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Play" })); expect(play).toHaveBeenCalledWith(expect.objectContaining({ @@ -183,12 +239,21 @@ describe("RichContentCard", () => { current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" }, }, }); - expect(screen.getByRole("button", { name: "Pause" })).toHaveClass("is-playing"); + expect(screen.getByRole("button", { name: "Pause" })).toHaveClass( + "is-playing", + ); }); test("compact and unplayable: a link to the source", () => { renderWithContexts( - , + , ); expect(screen.getByRole("link", { name: "Open on Blog" })).toHaveAttribute( "href", @@ -198,7 +263,10 @@ describe("RichContentCard", () => { test("a dump thumbnail overrides the provider's", () => { renderWithContexts( - , + , ); expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( "src", @@ -209,7 +277,11 @@ describe("RichContentCard", () => { test("a native stream that is the active item toggles instead of restarting", async () => { const togglePlay = vi.fn(); const play = vi.fn(); - const bc = { type: "bandcamp", url: "https://a.bandcamp.com/album/x", embedUrl: "https://bandcamp.com/EmbeddedPlayer/1" }; + const bc = { + type: "bandcamp", + url: "https://a.bandcamp.com/album/x", + embedUrl: "https://bandcamp.com/EmbeddedPlayer/1", + }; renderWithContexts(, { player: { togglePlay, @@ -249,32 +321,57 @@ describe("DumpCard", () => { commentCount: 2, categoryIds: ["c1", "missing"], }); - renderWithContexts(
, { - categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })], - }); - expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute("href", "/dumps/nice"); + renderWithContexts( +
    + +
, + { + categories: [makeCategory({ id: "c1", slug: "music", name: "Music" })], + }, + ); + expect(screen.getByRole("link", { name: /Nice find/ })).toHaveAttribute( + "href", + "/dumps/nice", + ); expect(screen.getByText("here").tagName).toBe("STRONG"); expect(screen.getByText("2 comments")).toBeInTheDocument(); expect(screen.getByText("1 minute ago")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music"); + expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute( + "href", + "/music", + ); }); test("an unread dot until visited, never on your own", () => { const dump = makeDump({ id: "d2" }); - const first = renderWithContexts(
); + const first = renderWithContexts( +
    + +
, + ); expect(first.container.querySelector(".unread-dot")).not.toBeNull(); first.unmount(); - const own = renderWithContexts(
); + const own = renderWithContexts( +
    + +
, + ); expect(own.container.querySelector(".unread-dot")).toBeNull(); own.unmount(); markDumpVisited("d2"); - const seen = renderWithContexts(
); + const seen = renderWithContexts( +
    + +
, + ); expect(seen.container.querySelector(".unread-dot")).toBeNull(); }); test("the private badge shows to the owner", () => { renderWithContexts( -
, +
    + +
, ); expect(screen.getByText("private")).toBeInTheDocument(); }); @@ -283,7 +380,9 @@ describe("DumpCard", () => { const castVote = vi.fn(); const dump = makeDump({ id: "d3" }); renderWithContexts( -
, +
    + +
, { session: makeSession() }, ); await userEvent.click(screen.getByRole("button", { name: "Upvote" })); @@ -296,11 +395,21 @@ describe("DumpCard", () => { test("file dumps get a file preview; bare URLs a placeholder", () => { const { container } = renderWithContexts(
    - +
, ); - expect(container.querySelectorAll(".thumb-placeholder").length).toBeGreaterThan(0); + expect(container.querySelectorAll(".thumb-placeholder").length) + .toBeGreaterThan(0); const cards = container.querySelectorAll(".dump-card"); expect(within(cards[1] as HTMLElement).getByText("P")).toBeInTheDocument(); }); diff --git a/src/test/components/header.test.tsx b/src/test/components/header.test.tsx index 6b89f891..efba6d11 100644 --- a/src/test/components/header.test.tsx +++ b/src/test/components/header.test.tsx @@ -20,10 +20,10 @@ import { CountedInput } from "../../components/CountedInput.tsx"; import { PageError } from "../../components/PageError.tsx"; import { AuthContext } from "../../contexts/AuthContext.ts"; import { ChatContext } from "../../contexts/ChatContext.ts"; -import { WSContext } from "../../contexts/WSContext.ts"; +import { defaultWSValue, WSContext } from "../../contexts/WSContext.ts"; import type { AuthResponse, Category } from "../../model.ts"; import { makeCategory, makeSession, makeUser } from "../fixtures.ts"; -import { currentPath, defaultOf, renderWithContexts } from "../render.tsx"; +import { currentPath, renderWithContexts } from "../render.tsx"; const categories: Category[] = [ makeCategory({ id: "c1", slug: "music", name: "Music" }), @@ -67,7 +67,12 @@ describe("SearchBar", () => { }); test("takes focus when it opens, not when it mounts open", () => { - const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} }; + const props = { + value: "", + onValueChange: () => {}, + onSubmit: () => {}, + onCancel: () => {}, + }; const { rerender } = renderWithContexts(); expect(screen.getByRole("searchbox")).not.toHaveFocus(); rerender(); @@ -91,7 +96,10 @@ describe("UserMenu", () => { const trigger = screen.getByRole("button", { name: "User menu" }); await user.click(trigger); expect(trigger).toHaveAttribute("aria-expanded", "true"); - expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute("href", "/users/ann"); + expect(screen.getByRole("menuitem", { name: "@ann" })).toHaveAttribute( + "href", + "/users/ann", + ); expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute( "href", "/users/ann/playlists", @@ -136,10 +144,13 @@ describe("PresenceRow", () => { }); test("gives up seats to the tabs beside it, keeping at least an avatar and the counter", () => { - const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(100); + const width = vi.spyOn(HTMLElement.prototype, "clientWidth", "get") + .mockReturnValue(100); renderWithContexts(
-
a
+
+ a +
, { ws: { onlineUsers: online(9) } }, @@ -155,7 +166,10 @@ describe("PresenceRow", () => { describe("CategorySwitcher", () => { test("hidden with no categories", () => { - renderWithContexts(, { route: "/~/hot", path: "/:x/:feedTab" }); + renderWithContexts(, { + route: "/~/hot", + path: "/:x/:feedTab", + }); expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); @@ -168,7 +182,10 @@ describe("CategorySwitcher", () => { }); const trigger = screen.getByRole("button", { name: "Music" }); await user.click(trigger); - expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("option", { name: "Music" })).toHaveAttribute( + "aria-selected", + "true", + ); await user.click(screen.getByRole("option", { name: "Cinema" })); expect(currentPath()).toBe("/cinema/new"); expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); @@ -188,7 +205,11 @@ describe("CategorySwitcher", () => { test("Escape closes the menu", async () => { const user = userEvent.setup(); - renderWithContexts(, { categories, route: "/~/hot", path: "/~/:feedTab" }); + renderWithContexts(, { + categories, + route: "/~/hot", + path: "/~/:feedTab", + }); await user.click(screen.getByRole("button", { name: "All" })); await user.keyboard("{Escape}"); expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); @@ -199,17 +220,28 @@ describe("CategorySelect", () => { test("toggle chips reflect the selection", async () => { const onToggle = vi.fn(); const user = userEvent.setup(); - renderWithContexts(, { - categories, - }); - expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute("aria-pressed", "false"); + renderWithContexts( + , + { + categories, + }, + ); + expect(screen.getByRole("button", { name: "Cinema" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "Music" })).toHaveAttribute( + "aria-pressed", + "false", + ); await user.click(screen.getByRole("button", { name: "Music" })); expect(onToggle).toHaveBeenCalledWith("c1"); }); test("nothing without categories", () => { - renderWithContexts( {}} />); + renderWithContexts( + {}} />, + ); expect(screen.queryByText("Categories")).not.toBeInTheDocument(); }); }); @@ -232,8 +264,13 @@ describe("TabBar / FeedTabBar", () => { test("guests don't get Followed; tabs navigate within the scope", async () => { const user = userEvent.setup(); - renderWithContexts(, { route: "/music/hot", path: "/:categorySlug/:feedTab", categories }); - expect(screen.queryByRole("button", { name: "Followed" })).not.toBeInTheDocument(); + renderWithContexts(, { + route: "/music/hot", + path: "/:categorySlug/:feedTab", + categories, + }); + expect(screen.queryByRole("button", { name: "Followed" })).not + .toBeInTheDocument(); expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active"); await user.click(screen.getByRole("button", { name: "Journal" })); expect(currentPath()).toBe("/music/journal"); @@ -245,7 +282,9 @@ describe("TabBar / FeedTabBar", () => { route: "/~/followed", path: "/~/:feedTab", }); - expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active"); + expect(screen.getByRole("button", { name: "Followed" })).toHaveClass( + "active", + ); }); }); @@ -258,10 +297,16 @@ describe("AppHeader", () => { }); test("members get their links, chat, bell and the new-dump button", () => { - renderWithContexts(, { session: makeSession({ username: "ann" }) }); - expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute("href", "/users/ann"); + renderWithContexts(, { + session: makeSession({ username: "ann" }), + }); + expect(screen.getByRole("link", { name: "ann" })).toHaveAttribute( + "href", + "/users/ann", + ); expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Notifications" })) + .toBeInTheDocument(); const dump = screen.getByRole("button", { name: /Dump/ }); expect(dump).toBeDisabled(); expect(dump).toHaveAttribute("title", "Server unreachable"); @@ -281,10 +326,11 @@ describe("AppHeader", () => { renderWithContexts(); const toggle = screen.getByRole("button", { name: "Search" }); await user.click(toggle); - expect(screen.getByRole("button", { name: "Cancel search" })).toHaveAttribute( - "aria-expanded", - "true", - ); + expect(screen.getByRole("button", { name: "Cancel search" })) + .toHaveAttribute( + "aria-expanded", + "true", + ); const [input] = screen.getAllByRole("searchbox"); await user.type(input, "x"); await user.keyboard("{Escape}"); @@ -303,7 +349,7 @@ describe("AppHeader", () => { return ( - + } /> @@ -357,15 +403,25 @@ describe("AppHeader", () => { describe("floating buttons", () => { function withChat(openChat = vi.fn()) { return (ui: React.ReactNode) => ( - {} }}> + {} }} + > {ui} ); } test("guests get neither", () => { - renderWithContexts(withChat()(<>)); - expect(screen.queryByRole("button", { hidden: true })).not.toBeInTheDocument(); + renderWithContexts( + withChat()( + <> + + + , + ), + ); + expect(screen.queryByRole("button", { hidden: true })).not + .toBeInTheDocument(); }); test("they show once the header scrolls away", () => { @@ -400,14 +456,20 @@ describe("floating buttons", () => { expect(chat).toHaveClass("chat-fab--visible"); act(() => chat.click()); expect(openChat).toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "New dump" })).toHaveClass("dump-fab--visible"); + expect(screen.getByRole("button", { name: "New dump" })).toHaveClass( + "dump-fab--visible", + ); }); }); describe("small pieces", () => { test("Tooltip shows on hover", async () => { const user = userEvent.setup(); - renderWithContexts(hover me); + renderWithContexts( + + hover me + , + ); await user.hover(screen.getByText("hover me")); expect(screen.getByText("Explained")).toBeInTheDocument(); await user.unhover(screen.getByText("hover me")); @@ -415,7 +477,13 @@ describe("small pieces", () => { }); test("CountedInput counts toward its limit", () => { - renderWithContexts( {}} />); + renderWithContexts( + {}} + />, + ); const count = screen.getByText("90 / 100"); expect(count).toHaveClass("text-editor-count--warn"); expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100"); diff --git a/src/test/components/media.test.tsx b/src/test/components/media.test.tsx index 772888ea..95276f11 100644 --- a/src/test/components/media.test.tsx +++ b/src/test/components/media.test.tsx @@ -10,7 +10,10 @@ function media(container: HTMLElement) { return container.querySelector("audio, video")!; } function setTime(el: HTMLMediaElement, currentTime: number, duration: number) { - Object.defineProperty(el, "duration", { configurable: true, value: duration }); + Object.defineProperty(el, "duration", { + configurable: true, + value: duration, + }); el.currentTime = currentTime; fireEvent(el, new Event("durationchange")); fireEvent(el, new Event("timeupdate")); @@ -22,7 +25,12 @@ describe("MediaPlayer", () => { const play = vi.spyOn(HTMLMediaElement.prototype, "play"); const pause = vi.spyOn(HTMLMediaElement.prototype, "pause"); renderWithContexts( - , + , ); await userEvent.click(screen.getByRole("button", { name: "Play" })); expect(play).toHaveBeenCalled(); @@ -35,15 +43,30 @@ describe("MediaPlayer", () => { test("autoplays when asked", async () => { const onPlayStateChange = vi.fn(); renderWithContexts( - , + , ); await waitFor(() => expect(onPlayStateChange).toHaveBeenCalledWith(true)); expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); }); test("a play refused by the browser stays paused", async () => { - vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(new Error("NotAllowedError")); - renderWithContexts(); + vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue( + new Error("NotAllowedError"), + ); + renderWithContexts( + , + ); await act(async () => {}); expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); }); @@ -51,7 +74,12 @@ describe("MediaPlayer", () => { test("a pause from outside the page (a media key) is reflected", async () => { const onPlayStateChange = vi.fn(); const { container } = renderWithContexts( - , + , ); await userEvent.click(screen.getByRole("button", { name: "Play" })); fireEvent(media(container), new Event("pause")); @@ -79,7 +107,9 @@ describe("MediaPlayer", () => { expect(screen.getByText("1:05")).toBeInTheDocument(); expect(screen.getByText("3:20")).toBeInTheDocument(); expect(onTimeUpdate).toHaveBeenLastCalledWith(65, 200); - fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { target: { value: "120" } }); + fireEvent.change(screen.getByRole("slider", { name: "Seek" }), { + target: { value: "120" }, + }); expect(el.currentTime).toBe(120); fireEvent(el, new Event("ended")); expect(onEnded).toHaveBeenCalled(); @@ -104,7 +134,12 @@ describe("MediaPlayer", () => { test("an error is reported for re-resolving", () => { const onError = vi.fn(); const { container } = renderWithContexts( - , + , ); fireEvent(media(container), new Event("error")); expect(onError).toHaveBeenCalled(); @@ -114,14 +149,21 @@ describe("MediaPlayer", () => { const { container } = renderWithContexts( , ); - fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.4" } }); + fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { + target: { value: "0.4" }, + }); expect(media(container).volume).toBeCloseTo(0.4); await userEvent.click(screen.getByRole("button", { name: "Mute" })); expect(media(container).muted).toBe(true); - expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ volume: 0.4, muted: true }); + expect(JSON.parse(localStorage.getItem("player-volume")!)).toEqual({ + volume: 0.4, + muted: true, + }); expect(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0"); // Raising the volume unmutes. - fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } }); + fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { + target: { value: "0.7" }, + }); expect(media(container).muted).toBe(false); }); @@ -129,7 +171,13 @@ describe("MediaPlayer", () => { const seekRef = { current: null as ((t: number) => void) | null }; const toggleRef = { current: null as (() => void) | null }; const { container, unmount } = renderWithContexts( - , + , ); act(() => seekRef.current!(33)); expect(media(container).currentTime).toBe(33); @@ -141,7 +189,12 @@ describe("MediaPlayer", () => { test("resumes at a start offset once metadata is in", () => { const { container } = renderWithContexts( - , + , ); fireEvent(media(container), new Event("loadedmetadata")); expect(media(container).currentTime).toBe(42); @@ -149,14 +202,23 @@ describe("MediaPlayer", () => { test("video has a fullscreen button", () => { renderWithContexts(); - expect(screen.getByRole("button", { name: "Fullscreen" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fullscreen" })) + .toBeInTheDocument(); }); }); describe("FilePreview", () => { const session = makeSession(); const file = (mime: string, over = {}) => - makeDump({ id: "f1", kind: "file", fileName: "thing", fileMime: mime, fileSize: 2048, url: undefined, ...over }); + makeDump({ + id: "f1", + kind: "file", + fileName: "thing", + fileMime: mime, + fileSize: 2048, + url: undefined, + ...over, + }); test("images show inline, with the media token", () => { renderWithContexts(, { session }); @@ -175,7 +237,9 @@ describe("FilePreview", () => { test("compact audio plays in the global player", async () => { const play = vi.fn(); - renderWithContexts(, { player: { play } }); + renderWithContexts(, { + player: { play }, + }); await userEvent.click(screen.getByRole("button", { name: "Play" })); expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", @@ -189,9 +253,13 @@ describe("FilePreview", () => { test("compact video claims its generated still as artwork", async () => { const play = vi.fn(); - renderWithContexts(, { player: { play } }); + renderWithContexts(, { + player: { play }, + }); await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play.mock.calls[0][0].artworkUrl).toBe("http://localhost:8000/api/thumbnails/f1"); + expect(play.mock.calls[0][0].artworkUrl).toBe( + "http://localhost:8000/api/thumbnails/f1", + ); }); test("the global video button toggles when it's the one playing", async () => { @@ -201,7 +269,11 @@ describe("FilePreview", () => { player: { togglePlay, playing: true, - current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" }, + current: { + kind: "file", + fileUrl: "http://localhost:8000/api/files/f1?v=2048", + mimeType: "video/mp4", + }, }, }); await userEvent.click(screen.getByRole("button", { name: "Pause" })); @@ -211,13 +283,19 @@ describe("FilePreview", () => { test("the global audio waveform starts playback", async () => { const play = vi.fn(); route("GET /api/files/:id", () => new TypeError("no audio in jsdom")); - renderWithContexts(, { player: { play } }); + renderWithContexts(, { + player: { play }, + }); await userEvent.click(screen.getByRole("button", { name: "Play" })); - expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" })); + expect(play).toHaveBeenCalledWith( + expect.objectContaining({ kind: "file", mimeType: "audio/ogg" }), + ); }); test("compact PDFs and text get an icon", () => { - const { container } = renderWithContexts(); + const { container } = renderWithContexts( + , + ); expect(container).toHaveTextContent("📄"); }); }); diff --git a/src/test/components/player.test.tsx b/src/test/components/player.test.tsx index 88abed3d..c4df62c8 100644 --- a/src/test/components/player.test.tsx +++ b/src/test/components/player.test.tsx @@ -6,7 +6,10 @@ import { GlobalPlayer } from "../../components/GlobalPlayer.tsx"; import { JournalCard } from "../../components/JournalCard.tsx"; import { PlaylistCard } from "../../components/PlaylistCard.tsx"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; -import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts"; +import { + PlayerContext, + type PlayerItem, +} from "../../contexts/PlayerContext.ts"; import { makeDump, makePlaylist } from "../fixtures.ts"; import { currentPath, renderWithContexts } from "../render.tsx"; import { markPlaylistVisited } from "../../utils/visited.ts"; @@ -43,7 +46,14 @@ describe("GlobalPlayer", () => { test("hidden until something plays; the body makes room for it", () => { renderPlayer(); expect(document.querySelector(".global-player")).toBeNull(); - act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=x", type: "soundcloud", title: "Mix" })); + act(() => + player.play({ + kind: "embed", + embedUrl: "https://w.soundcloud.com/player/?url=x", + type: "soundcloud", + title: "Mix", + }) + ); expect(document.querySelector(".global-player--soundcloud")).not.toBeNull(); expect(document.body).toHaveClass("has-player"); act(() => player.stop()); @@ -52,27 +62,58 @@ describe("GlobalPlayer", () => { }); test("an embed autoplays on a fresh play only", () => { - localStorage.setItem("player", JSON.stringify({ - v: 2, - queue: [{ kind: "embed", embedUrl: "https://www.youtube.com/embed/abc", type: "youtube" }], - index: 0, - time: 0, - owner: null, - })); + localStorage.setItem( + "player", + JSON.stringify({ + v: 2, + queue: [{ + kind: "embed", + embedUrl: "https://www.youtube.com/embed/abc", + type: "youtube", + }], + index: 0, + time: 0, + owner: null, + }), + ); renderPlayer(); const iframe = () => document.querySelector("iframe")!; expect(iframe().src).toBe("https://www.youtube.com/embed/abc"); - act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/def", type: "youtube" })); + act(() => + player.play({ + kind: "embed", + embedUrl: "https://www.youtube.com/embed/def", + type: "youtube", + }) + ); expect(new URL(iframe().src).searchParams.get("autoplay")).toBe("1"); - act(() => player.play({ kind: "embed", embedUrl: "https://w.soundcloud.com/player/?url=y", type: "soundcloud" })); + act(() => + player.play({ + kind: "embed", + embedUrl: "https://w.soundcloud.com/player/?url=y", + type: "soundcloud", + }) + ); expect(new URL(iframe().src).searchParams.get("auto_play")).toBe("true"); - act(() => player.play({ kind: "embed", embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", type: "bandcamp" })); + act(() => + player.play({ + kind: "embed", + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + type: "bandcamp", + }) + ); expect(iframe().src).toBe("https://bandcamp.com/EmbeddedPlayer/album=1"); }); test("a YouTube playlist embed gets the JS API parameters", () => { renderPlayer(); - act(() => player.play({ kind: "embed", embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", type: "youtube" })); + act(() => + player.play({ + kind: "embed", + embedUrl: "https://www.youtube.com/embed/videoseries?list=PL", + type: "youtube", + }) + ); const src = new URL(document.querySelector("iframe")!.src); expect(src.searchParams.get("enablejsapi")).toBe("1"); expect(src.searchParams.get("list")).toBe("PL"); @@ -82,10 +123,14 @@ describe("GlobalPlayer", () => { const user = userEvent.setup(); renderPlayer(); act(() => player.playQueue([track(0), track(1), track(2)], 0)); - expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute("href", "/dumps/album"); + expect(screen.getByRole("link", { name: "Song 0" })).toHaveAttribute( + "href", + "/dumps/album", + ); expect(screen.getByText("Band")).toBeInTheDocument(); expect(screen.getByText("1 / 3")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Previous track" })) + .toBeDisabled(); const list = screen.getByRole("list"); expect(within(list).getAllByRole("button")).toHaveLength(3); @@ -105,7 +150,8 @@ describe("GlobalPlayer", () => { act(() => player.playQueue([track(0), track(1)])); await user.click(screen.getByRole("button", { name: "Collapse player" })); expect(screen.queryByRole("list")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Expand player" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand player" })) + .toBeInTheDocument(); act(() => player.playQueue([track(2), track(3)])); expect(screen.getByRole("list")).toBeInTheDocument(); }); @@ -120,8 +166,16 @@ describe("GlobalPlayer", () => { test("a file plays in the built-in player", () => { renderPlayer(); - act(() => player.play({ kind: "file", fileUrl: "http://localhost:8000/api/files/x", mimeType: "video/mp4", title: "Clip" })); - expect(document.querySelector(".global-player--file-video video")).not.toBeNull(); + act(() => + player.play({ + kind: "file", + fileUrl: "http://localhost:8000/api/files/x", + mimeType: "video/mp4", + title: "Clip", + }) + ); + expect(document.querySelector(".global-player--file-video video")).not + .toBeNull(); expect(screen.getByText("Clip")).toBeInTheDocument(); }); @@ -138,24 +192,44 @@ describe("GlobalPlayer", () => { }); describe("JournalCard", () => { - const base = { voteCount: 1, voted: false, canVote: true, castVote: vi.fn(), removeVote: vi.fn() }; + const base = { + voteCount: 1, + voted: false, + canVote: true, + castVote: vi.fn(), + removeVote: vi.fn(), + }; test("image, quote and text modes", () => { const { container, unmount } = renderWithContexts(
    - +
, ); const cards = container.querySelectorAll("li"); - expect(cards[0]).toHaveClass("journal-card--image", "journal-card--feature"); + expect(cards[0]).toHaveClass( + "journal-card--image", + "journal-card--feature", + ); expect(cards[1]).toHaveClass("journal-card--quote"); - expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument(); + expect(within(cards[1] as HTMLElement).getByText("A thought")) + .toBeInTheDocument(); expect(cards[2]).toHaveClass("journal-card--text"); expect(cards[2]).toHaveTextContent("🔗"); unmount(); @@ -173,7 +247,13 @@ describe("JournalCard", () => { embedUrl: "https://www.youtube.com/embed/y", }, }); - const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined }); + const pic = makeDump({ + id: "p", + kind: "file", + fileMime: "image/png", + fileName: "p.png", + url: undefined, + }); const { container } = renderWithContexts(
    @@ -183,7 +263,9 @@ describe("JournalCard", () => { ); const cards = container.querySelectorAll("li"); await user.click(cards[0].querySelector(".journal-card-image")!); - expect(play).toHaveBeenCalledWith(expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" })); + expect(play).toHaveBeenCalledWith( + expect.objectContaining({ embedUrl: "https://www.youtube.com/embed/y" }), + ); expect(currentPath()).toBe("/"); await user.click(cards[1].querySelector(".journal-card-image")!); expect(currentPath()).toBe("/dumps/p"); @@ -192,21 +274,50 @@ describe("JournalCard", () => { test("a video file shows its generated still", () => { const { container } = renderWithContexts(
      - +
    , ); - expect(container.querySelector("img")!.getAttribute("src")).toBe("http://localhost:8000/api/thumbnails/v"); + expect(container.querySelector("img")!.getAttribute("src")).toBe( + "http://localhost:8000/api/thumbnails/v", + ); }); }); describe("PlaylistCard", () => { test("title, badge, owner, count; clicking opens it and marks it seen", async () => { const user = userEvent.setup(); - const p = makePlaylist({ id: "p1", slug: "mix", title: "Mix", ownerUsername: "ann", dumpCount: 1, isPublic: false, description: "Tunes" }); - const { container } = renderWithContexts(
    ); - expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute("href", "/playlists/mix"); + const p = makePlaylist({ + id: "p1", + slug: "mix", + title: "Mix", + ownerUsername: "ann", + dumpCount: 1, + isPublic: false, + description: "Tunes", + }); + const { container } = renderWithContexts( +
      + +
    , + ); + expect(screen.getByRole("link", { name: /Mix/ })).toHaveAttribute( + "href", + "/playlists/mix", + ); expect(screen.getByText("private")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute("href", "/users/ann"); + expect(screen.getByRole("link", { name: "@ann" })).toHaveAttribute( + "href", + "/users/ann", + ); expect(screen.getByText("1 dump")).toBeInTheDocument(); expect(screen.getByText("Tunes")).toBeInTheDocument(); await user.click(container.querySelector(".playlist-card-inner")!); @@ -215,28 +326,43 @@ describe("PlaylistCard", () => { test("the owner doesn't see their own name, or an unread dot", () => { const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); - const { container } = renderWithContexts(
    ); + const { container } = renderWithContexts( +
      + +
    , + ); expect(screen.queryByText("@me")).not.toBeInTheDocument(); expect(container.querySelector(".unread-dot")).toBeNull(); }); test("a new playlist has a dot until visited", () => { const p = makePlaylist({ id: "fresh", createdAt: new Date() }); - const first = renderWithContexts(
    ); + const first = renderWithContexts( +
      + +
    , + ); expect(first.container.querySelector(".unread-dot")).not.toBeNull(); first.unmount(); markPlaylistVisited("fresh"); - const again = renderWithContexts(
    ); + const again = renderWithContexts( +
      + +
    , + ); expect(again.container.querySelector(".unread-dot")).toBeNull(); }); test("delete doesn't open the playlist", async () => { const onDelete = vi.fn(); const user = userEvent.setup(); - renderWithContexts(
    ); + renderWithContexts( +
      + +
    , + ); await user.click(screen.getByRole("button", { name: "Delete playlist" })); expect(onDelete).toHaveBeenCalled(); expect(currentPath()).toBe("/"); }); }); - diff --git a/src/test/contexts/PlayerProvider.test.tsx b/src/test/contexts/PlayerProvider.test.tsx index 6066115f..d0ed4db9 100644 --- a/src/test/contexts/PlayerProvider.test.tsx +++ b/src/test/contexts/PlayerProvider.test.tsx @@ -3,7 +3,10 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { type ReactNode, useContext, useState } from "react"; import { AuthContext } from "../../contexts/AuthContext.ts"; import { PlayerProvider } from "../../contexts/PlayerProvider.tsx"; -import { PlayerContext, type PlayerItem } from "../../contexts/PlayerContext.ts"; +import { + PlayerContext, + type PlayerItem, +} from "../../contexts/PlayerContext.ts"; import type { AuthResponse } from "../../model.ts"; import { STREAM_STALE_MS } from "../../utils/streamSources.ts"; import { calls, deferred, fail, route } from "../fetch.ts"; @@ -205,7 +208,13 @@ describe("persistence", () => { test("a saved session comes back paused at its offset", () => { localStorage.setItem( "player", - JSON.stringify({ v: 2, queue: [embed(1), embed(2)], index: 1, time: 17, owner: null }), + JSON.stringify({ + v: 2, + queue: [embed(1), embed(2)], + index: 1, + time: 17, + owner: null, + }), ); const { p } = setup(); expect(p().queueIndex).toBe(1); @@ -223,7 +232,13 @@ describe("persistence", () => { test("another account's queue isn't restored", () => { localStorage.setItem( "player", - JSON.stringify({ v: 2, queue: [file(1)], index: 0, time: 0, owner: "someone-else" }), + JSON.stringify({ + v: 2, + queue: [file(1)], + index: 0, + time: 0, + owner: "someone-else", + }), ); const { p } = setup(makeSession({ id: "me" })); expect(p().current).toBeNull(); @@ -232,7 +247,10 @@ describe("persistence", () => { test("an ownerless session holding file URLs isn't restored; one of embeds is", () => { localStorage.setItem("player", JSON.stringify({ item: file(1), time: 0 })); expect(setup().p().current).toBeNull(); - localStorage.setItem("player", JSON.stringify({ queue: [embed(1)], index: 0, time: 0 })); + localStorage.setItem( + "player", + JSON.stringify({ queue: [embed(1)], index: 0, time: 0 }), + ); expect(setup().p().current).toEqual(embed(1)); }); @@ -241,7 +259,12 @@ describe("persistence", () => { expect(setup().p().current).toBeNull(); localStorage.setItem( "player", - JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }), + JSON.stringify({ + v: 2, + queue: [{ kind: "weird" }], + index: 0, + owner: null, + }), ); expect(setup().p().current).toBeNull(); }); @@ -330,7 +353,9 @@ describe("expiring streams", () => { act(() => p().play(embed(9))); await act(async () => pending.resolve( - new Response(JSON.stringify({ success: true, data: albumResponse([0]) })), + new Response( + JSON.stringify({ success: true, data: albumResponse([0]) }), + ), ) ); expect(p().current).toEqual(embed(9)); @@ -359,7 +384,9 @@ describe("expiring streams", () => { }), ); const { p } = setup(); - await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" })); + await waitFor(() => + expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }) + ); expect(p().autoplay).toBe(false); expect(p().startTime).toBe(25); }); diff --git a/src/test/contexts/WSProvider.test.tsx b/src/test/contexts/WSProvider.test.tsx index 4e53d7cc..4e842ce8 100644 --- a/src/test/contexts/WSProvider.test.tsx +++ b/src/test/contexts/WSProvider.test.tsx @@ -46,7 +46,9 @@ function setup({ signedIn = true } = {}) { return { hook, socket, ws: () => hook.result.current.ws }; } -beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })); +beforeEach(() => + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }) +); afterEach(() => vi.useRealTimers()); describe("connection", () => { @@ -174,7 +176,12 @@ describe("votes", () => { expect(ws().myVotes.has("d1")).toBe(true); expect(ws().voteCounts.d1).toBe(5); expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); - socket.receive({ type: "vote_ack", dumpId: "d1", action: "cast", voteCount: 7 }); + socket.receive({ + type: "vote_ack", + dumpId: "d1", + action: "cast", + voteCount: 7, + }); expect(ws().voteCounts.d1).toBe(7); act(() => vi.advanceTimersByTime(10_000)); expect(ws().myVotes.has("d1")).toBe(true); @@ -194,7 +201,12 @@ describe("votes", () => { socket.welcome({ myVotes: ["d1"] }); act(() => ws().removeVote("d1", 3)); expect(ws().voteCounts.d1).toBe(2); - socket.receive({ type: "error", message: "nope", dumpId: "d1", action: "remove" }); + socket.receive({ + type: "error", + message: "nope", + dumpId: "d1", + action: "remove", + }); expect(ws().myVotes.has("d1")).toBe(true); expect(ws().voteCounts.d1).toBe(3); }); @@ -225,12 +237,34 @@ describe("votes", () => { test("votes_update from another tab of mine syncs myVotes", () => { const { socket, ws } = setup(); socket.welcome(); - socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "cast" }); + socket.receive({ + type: "votes_update", + dumpId: "d9", + voteCount: 1, + voterId: "user-me", + action: "cast", + }); expect(ws().myVotes.has("d9")).toBe(true); - expect(ws().lastVoteEvent).toEqual({ dumpId: "d9", voterId: "user-me", action: "cast" }); - socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 2, voterId: "other", action: "cast" }); + expect(ws().lastVoteEvent).toEqual({ + dumpId: "d9", + voterId: "user-me", + action: "cast", + }); + socket.receive({ + type: "votes_update", + dumpId: "d9", + voteCount: 2, + voterId: "other", + action: "cast", + }); expect(ws().voteCounts.d9).toBe(2); - socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" }); + socket.receive({ + type: "votes_update", + dumpId: "d9", + voteCount: 1, + voterId: "user-me", + action: "remove", + }); expect(ws().myVotes.has("d9")).toBe(false); }); @@ -239,8 +273,12 @@ describe("votes", () => { socket.welcome(); act(() => ws().castVote("d1", 0)); socket.receive({ - type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [], - unreadNotificationCount: 0, unreadChatCount: 0, + type: "welcome", + users: [], + myVotes: ["d1"], + myCommentLikes: [], + unreadNotificationCount: 0, + unreadChatCount: 0, }); act(() => vi.advanceTimersByTime(10_000)); expect(ws().myVotes.has("d1")).toBe(true); @@ -253,11 +291,22 @@ describe("comment likes", () => { socket.welcome(); act(() => ws().castCommentLike("c1", 0)); expect(ws().likeCounts.c1).toBe(1); - expect(socket.messages).toContainEqual({ type: "comment_like_cast", commentId: "c1" }); - socket.receive({ type: "comment_like_ack", commentId: "c1", action: "cast", likeCount: 1 }); + expect(socket.messages).toContainEqual({ + type: "comment_like_cast", + commentId: "c1", + }); + socket.receive({ + type: "comment_like_ack", + commentId: "c1", + action: "cast", + likeCount: 1, + }); act(() => ws().removeCommentLike("c1")); expect(ws().likeCounts.c1).toBe(0); - expect(socket.messages).toContainEqual({ type: "comment_like_remove", commentId: "c1" }); + expect(socket.messages).toContainEqual({ + type: "comment_like_remove", + commentId: "c1", + }); socket.receive({ type: "error", commentId: "c1" }); expect(ws().myLikes.has("c1")).toBe(true); expect(ws().likeCounts.c1).toBe(1); @@ -275,7 +324,13 @@ describe("comment likes", () => { test("comment_likes_update from my other tab", () => { const { socket, ws } = setup(); socket.welcome(); - socket.receive({ type: "comment_likes_update", commentId: "c2", likeCount: 3, likerId: "user-me", action: "cast" }); + socket.receive({ + type: "comment_likes_update", + commentId: "c2", + likeCount: 3, + likerId: "user-me", + action: "cast", + }); expect(ws().myLikes.has("c2")).toBe(true); expect(ws().likeCounts.c2).toBe(3); expect(ws().lastLikeEvent?.commentId).toBe("c2"); @@ -290,7 +345,10 @@ describe("dumps, playlists, comments, users", () => { expect(ws().deletedDumpIds.has("a")).toBe(true); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) }); socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "b" })) }); - socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "again" })) }); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "a", title: "again" })), + }); expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]); expect(ws().recentDumps[0].title).toBe("again"); expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date); @@ -301,7 +359,10 @@ describe("dumps, playlists, comments, users", () => { const { socket, ws } = setup(); socket.welcome(); for (let i = 0; i < 105; i++) { - socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) }); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: `d${i}` })), + }); } expect(ws().recentDumps).toHaveLength(100); expect(ws().recentDumps[0].id).toBe("d104"); @@ -310,10 +371,22 @@ describe("dumps, playlists, comments, users", () => { test("an update refreshes a live entry but doesn't make it an arrival", () => { const { socket, ws } = setup(); socket.welcome(); - socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a", title: "one" })) }); - socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "a", title: "two" })) }); - socket.receive({ type: "dump_updated", dump: wire(makeDump({ id: "old" })) }); - expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([["a", "two"]]); + socket.receive({ + type: "dump_created", + dump: wire(makeDump({ id: "a", title: "one" })), + }); + socket.receive({ + type: "dump_updated", + dump: wire(makeDump({ id: "a", title: "two" })), + }); + socket.receive({ + type: "dump_updated", + dump: wire(makeDump({ id: "old" })), + }); + expect(ws().recentDumps.map((d) => [d.id, d.title])).toEqual([[ + "a", + "two", + ]]); expect(ws().lastDumpEvent?.id).toBe("old"); }); @@ -338,14 +411,31 @@ describe("dumps, playlists, comments, users", () => { socket.welcome(); const p = makePlaylist({ id: "p" }); socket.receive({ type: "playlist_created", playlist: wire(p) }); - expect(ws().lastPlaylistEvent).toMatchObject({ type: "created", playlistId: "p" }); + expect(ws().lastPlaylistEvent).toMatchObject({ + type: "created", + playlistId: "p", + }); socket.receive({ type: "playlist_updated", playlist: wire(p) }); expect(ws().lastPlaylistEvent?.type).toBe("updated"); - socket.receive({ type: "playlist_dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 }); - expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"], dumpCount: 2 }); + socket.receive({ + type: "playlist_dumps_updated", + playlistId: "p", + dumpIds: ["x"], + dumpCount: 2, + }); + expect(ws().lastPlaylistEvent).toEqual({ + type: "dumps_updated", + playlistId: "p", + dumpIds: ["x"], + dumpCount: 2, + }); socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" }); expect(ws().deletedPlaylistIds.has("p")).toBe(true); - expect(ws().lastPlaylistEvent).toEqual({ type: "deleted", playlistId: "p", userId: "u" }); + expect(ws().lastPlaylistEvent).toEqual({ + type: "deleted", + playlistId: "p", + userId: "u", + }); }); test("comment events", () => { @@ -353,27 +443,44 @@ describe("dumps, playlists, comments, users", () => { socket.welcome(); const c = makeComment({ id: "c", dumpId: "d" }); socket.receive({ type: "comment_created", comment: wire(c) }); - expect(ws().lastCommentEvent).toMatchObject({ type: "created", dumpId: "d" }); + expect(ws().lastCommentEvent).toMatchObject({ + type: "created", + dumpId: "d", + }); expect(ws().lastCommentEvent?.comment?.createdAt).toBeInstanceOf(Date); socket.receive({ type: "comment_updated", comment: wire(c) }); expect(ws().lastCommentEvent?.type).toBe("updated"); socket.receive({ type: "comment_deleted", commentId: "c", dumpId: "d" }); - expect(ws().lastCommentEvent).toEqual({ type: "deleted", dumpId: "d", commentId: "c" }); + expect(ws().lastCommentEvent).toEqual({ + type: "deleted", + dumpId: "d", + commentId: "c", + }); }); test("user_updated overrides that user's avatar everywhere", () => { const { socket, hook } = setup(); socket.welcome(); - const u = makeUser({ id: "u1", avatarMime: "image/png", updatedAt: new Date(5000) }); + const u = makeUser({ + id: "u1", + avatarMime: "image/png", + updatedAt: new Date(5000), + }); socket.receive({ type: "user_updated", user: wire(u) }); - expect(hook.result.current.avatars.u1).toEqual({ hasAvatar: true, version: 5000 }); + expect(hook.result.current.avatars.u1).toEqual({ + hasAvatar: true, + version: 5000, + }); expect(hook.result.current.ws.lastUserEvent?.user.id).toBe("u1"); }); test("presence updates", () => { const { socket, ws } = setup(); socket.welcome(); - socket.receive({ type: "presence_update", users: [{ userId: "x", username: "x", hasAvatar: false }] }); + socket.receive({ + type: "presence_update", + users: [{ userId: "x", username: "x", hasAvatar: false }], + }); expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]); }); }); @@ -382,7 +489,10 @@ describe("notifications", () => { test("a new one bumps the count and is remembered", () => { const { socket, ws } = setup(); socket.welcome({ unreadNotificationCount: 1 }); - socket.receive({ type: "notification_created", notification: wire(makeNotification({ id: "n" })) }); + socket.receive({ + type: "notification_created", + notification: wire(makeNotification({ id: "n" })), + }); expect(ws().unreadNotificationCount).toBe(2); expect(ws().lastNotification?.id).toBe("n"); act(() => ws().clearUnreadNotifications()); @@ -397,7 +507,10 @@ describe("chat", () => { const m = makeChatMessage({ id: "m1", userId: "other" }); socket.receive({ type: "chat_message", message: wire(m) }); socket.receive({ type: "chat_message_updated", message: wire(m) }); - socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: "m2", userId: "user-me" })) }); + socket.receive({ + type: "chat_message", + message: wire(makeChatMessage({ id: "m2", userId: "user-me" })), + }); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]); expect(ws().unreadChatCount).toBe(1); }); @@ -406,7 +519,10 @@ describe("chat", () => { const { socket, ws } = setup(); socket.welcome(); for (let i = 0; i < 205; i++) { - socket.receive({ type: "chat_message", message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })) }); + socket.receive({ + type: "chat_message", + message: wire(makeChatMessage({ id: `m${i}`, userId: "user-me" })), + }); } expect(ws().chatMessages).toHaveLength(200); expect(ws().chatMessages[0].id).toBe("m5"); @@ -437,7 +553,9 @@ describe("chat", () => { test("sending shows a pending message, retired by its echo", () => { const { socket, ws } = setup(); socket.welcome(); - act(() => ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" })); + act(() => + ws().sendChatMessage(" hello ", { id: "r", author: "a", body: "b" }) + ); const [pending] = ws().pendingChatMessages; expect(pending).toMatchObject({ body: "hello", status: "sending" }); expect(socket.messages).toContainEqual({ @@ -482,7 +600,9 @@ describe("chat", () => { const retry = ws().pendingChatMessages[0]; expect(retry.status).toBe("sending"); expect(retry.nonce).not.toBe(first); - expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength(2); + expect(socket.messages.filter((m) => m.type === "chat_send")).toHaveLength( + 2, + ); // The first attempt's late echo doesn't retire the retry. socket.receive({ type: "chat_message", @@ -497,8 +617,16 @@ describe("chat", () => { socket.welcome(); act(() => ws().sendChatMessage("hi")); const { nonce } = ws().pendingChatMessages[0]; - socket.receive({ type: "error", scope: "chat", nonce, message: "Too long" }); - expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed", error: "Too long" }); + socket.receive({ + type: "error", + scope: "chat", + nonce, + message: "Too long", + }); + expect(ws().pendingChatMessages[0]).toMatchObject({ + status: "failed", + error: "Too long", + }); act(() => ws().discardChatMessage(nonce)); expect(ws().pendingChatMessages).toEqual([]); }); @@ -534,9 +662,15 @@ describe("chat", () => { ]); const m = makeChatMessage({ id: "m1", body: "old" }); socket.receive({ type: "chat_message", message: wire(m) }); - socket.receive({ type: "chat_message_updated", message: wire({ ...m, body: "new" }) }); + socket.receive({ + type: "chat_message_updated", + message: wire({ ...m, body: "new" }), + }); expect(ws().chatMessages[0].body).toBe("new"); - socket.receive({ type: "chat_message_updated", message: wire(makeChatMessage({ id: "m2" })) }); + socket.receive({ + type: "chat_message_updated", + message: wire(makeChatMessage({ id: "m2" })), + }); expect(ws().chatMessages).toHaveLength(2); socket.receive({ type: "chat_message_deleted", id: "m1" }); expect(ws().chatMessages.map((x) => x.id)).toEqual(["m2"]); @@ -548,7 +682,10 @@ describe("chat", () => { socket.welcome(); act(() => ws().setChatTyping(true)); expect(socket.messages).toEqual([{ type: "chat_typing", typing: true }]); - socket.receive({ type: "chat_typing_update", users: [{ userId: "x", username: "x", hasAvatar: false }] }); + socket.receive({ + type: "chat_typing_update", + users: [{ userId: "x", username: "x", hasAvatar: false }], + }); expect(ws().typingUsers).toHaveLength(1); }); @@ -558,11 +695,15 @@ describe("chat", () => { act(() => ws().markChatRead(new Date(1_000_000))); expect(ws().unreadChatCount).toBe(0); act(() => vi.advanceTimersByTime(0)); - expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1); + expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( + 1, + ); act(() => ws().markChatRead(new Date(2_000_000))); act(() => ws().markChatRead(new Date(3_000_000))); act(() => vi.advanceTimersByTime(999)); - expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(1); + expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( + 1, + ); act(() => vi.advanceTimersByTime(1)); const reads = socket.messages.filter((m) => m.type === "chat_read"); expect(reads).toHaveLength(2); @@ -570,7 +711,9 @@ describe("chat", () => { // Older than what's been sent: ignored. act(() => ws().markChatRead(new Date(10))); act(() => vi.advanceTimersByTime(5_000)); - expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength(2); + expect(socket.messages.filter((m) => m.type === "chat_read")).toHaveLength( + 2, + ); }); test("a marker set offline goes out with the next welcome", () => { @@ -587,7 +730,11 @@ describe("chat", () => { test("chat_read_update from another tab moves the marker", () => { const { socket, ws } = setup(); socket.welcome({ unreadChatCount: 5 }); - socket.receive({ type: "chat_read_update", readAt: "2026-05-01T00:00:00.000Z", unread: 1 }); + socket.receive({ + type: "chat_read_update", + readAt: "2026-05-01T00:00:00.000Z", + unread: 1, + }); expect(ws().unreadChatCount).toBe(1); expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z")); }); @@ -598,7 +745,11 @@ describe("chat", () => { const t0 = Date.now(); const at = (s: number) => new Date(t0 + s * 1000); const early = makeChatMessage({ id: "early", createdAt: at(-100) }); - const edited = makeChatMessage({ id: "edited", body: "old", createdAt: at(-50) }); + const edited = makeChatMessage({ + id: "edited", + body: "old", + createdAt: at(-50), + }); const gone = makeChatMessage({ id: "gone", createdAt: at(-40) }); for (const m of [early, edited, gone]) { socket.receive({ type: "chat_message", message: wire(m) }); @@ -611,7 +762,12 @@ describe("chat", () => { ws().reconcileChat( [ { ...edited, body: "new" }, - makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }), + makeChatMessage({ + id: "echo", + userId: "user-me", + body: "did it land?", + createdAt: at(1), + }), ], at(-60), at(60), @@ -643,6 +799,9 @@ describe("chat", () => { act(() => vi.advanceTimersByTime(500)); FakeWebSocket.last.welcome(); expect(ws().chatMessages).toHaveLength(1); - expect(FakeWebSocket.last.messages).toContainEqual({ type: "chat_focus", open: true }); + expect(FakeWebSocket.last.messages).toContainEqual({ + type: "chat_focus", + open: true, + }); }); }); diff --git a/src/test/contexts/providers.test.tsx b/src/test/contexts/providers.test.tsx index da1e77b0..5eae892f 100644 --- a/src/test/contexts/providers.test.tsx +++ b/src/test/contexts/providers.test.tsx @@ -51,7 +51,10 @@ describe("AuthProvider", () => { test("a role changed on the server is picked up", async () => { const session = makeSession({ role: "user" }); storeSession(session); - route("GET /api/users/me", () => wire({ ...session.user, role: "moderator" })); + route( + "GET /api/users/me", + () => wire({ ...session.user, role: "moderator" }), + ); const { result } = renderHook(useAuth, { wrapper: authWrapper }); await waitFor(() => expect(result.current.user?.role).toBe("moderator")); expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.role).toBe( @@ -99,13 +102,17 @@ describe("AuthProvider", () => { const session = makeSession({ username: "elsewhere" }); storeSession(session); act(() => { - globalThis.dispatchEvent(new StorageEvent("storage", { key: AUTH_STORAGE_KEY })); + globalThis.dispatchEvent( + new StorageEvent("storage", { key: AUTH_STORAGE_KEY }), + ); }); expect(result.current.user?.username).toBe("elsewhere"); localStorage.removeItem(AUTH_STORAGE_KEY); act(() => { - globalThis.dispatchEvent(new StorageEvent("storage", { key: "unrelated" })); + globalThis.dispatchEvent( + new StorageEvent("storage", { key: "unrelated" }), + ); }); expect(result.current.user).not.toBeNull(); act(() => { @@ -132,7 +139,9 @@ describe("AuthProvider", () => { }); describe("FollowProvider", () => { - function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) { + function setup( + status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }, + ) { const session = makeSession(); storeSession(session); route("GET /api/users/me", () => wire(session.user)); @@ -260,7 +269,9 @@ describe("ThemeProvider", () => { expect(result.current.style).toBe("nyt"); expect(result.current.colorScheme).toBe("light"); localStorage.setItem("style", "default"); - expect(renderHook(useTheme, { wrapper }).result.current.style).toBe("smooth"); + expect(renderHook(useTheme, { wrapper }).result.current.style).toBe( + "smooth", + ); }); }); @@ -310,4 +321,3 @@ describe("ChatProvider", () => { expect(chat.isChatOpen).toBe(false); }); }); - diff --git a/src/test/emojibase.ts b/src/test/emojibase.ts index 5dd3d252..6215296d 100644 --- a/src/test/emojibase.ts +++ b/src/test/emojibase.ts @@ -4,8 +4,20 @@ import enMessages from "emojibase-data/en/messages.json"; /** Serves the emoji picker's data as the app does, from /emojibase//. */ export function serveEmojibase() { - route("GET /emojibase/:version/en/data.json", () => new Response(JSON.stringify(enData))); - route("GET /emojibase/:version/en/messages.json", () => new Response(JSON.stringify(enMessages))); - route("HEAD /emojibase/:version/en/data.json", () => new Response(null, { headers: { ETag: "x" } })); - route("HEAD /emojibase/:version/en/messages.json", () => new Response(null, { headers: { ETag: "y" } })); + route( + "GET /emojibase/:version/en/data.json", + () => new Response(JSON.stringify(enData)), + ); + route( + "GET /emojibase/:version/en/messages.json", + () => new Response(JSON.stringify(enMessages)), + ); + route( + "HEAD /emojibase/:version/en/data.json", + () => new Response(null, { headers: { ETag: "x" } }), + ); + route( + "HEAD /emojibase/:version/en/messages.json", + () => new Response(null, { headers: { ETag: "y" } }), + ); } diff --git a/src/test/hooks/data.test.tsx b/src/test/hooks/data.test.tsx index db77d826..fab4e062 100644 --- a/src/test/hooks/data.test.tsx +++ b/src/test/hooks/data.test.tsx @@ -24,11 +24,7 @@ describe("usePlayRichContent", () => { const playQueue = vi.fn(); const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -90,7 +86,13 @@ describe("usePlayRichContent", () => { sourceUrl: bc.url, itemType: "album", resolvedAt: 1, - tracks: [{ index: 0, title: "T", streamUrl: "https://s/0", streamable: true, capped: false }], + tracks: [{ + index: 0, + title: "T", + streamUrl: "https://s/0", + streamable: true, + capped: false, + }], })); const { hook, playQueue, canPlay } = await load("native"); expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(true); @@ -108,7 +110,9 @@ describe("usePlayRichContent", () => { const { hook, play, playQueue } = await load("native"); await act(() => hook.result.current.playRichContent(bc)); expect(playQueue).not.toHaveBeenCalled(); - expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "embed" })); + expect(play).toHaveBeenCalledWith( + expect.objectContaining({ kind: "embed" }), + ); }); test("an unknown mode reads as embed", async () => { @@ -119,7 +123,9 @@ describe("usePlayRichContent", () => { describe("useUserDumpFeed", () => { const ann = makeUser({ username: "ann" }); - function setup(opts: { session?: ReturnType | null } = {}) { + function setup( + opts: { session?: ReturnType | null } = {}, + ) { const session = opts.session ?? null; function Wrapper({ children }: { children: ReactNode }) { const [authResponse, setAuthResponse] = useState(session); @@ -147,7 +153,10 @@ describe("useUserDumpFeed", () => { const session = makeSession(); const { result } = setup({ session }); await waitFor(() => expect(result.current.state.status).toBe("loaded")); - const s = result.current.state as Extract; + const s = result.current.state as Extract< + typeof result.current.state, + { status: "loaded" } + >; expect(s.profileUser.username).toBe("ann"); expect(s.items[0].createdAt).toBeInstanceOf(Date); expect(s.hasMore).toBe(true); @@ -167,35 +176,51 @@ describe("useUserDumpFeed", () => { route("GET /api/users/:u", () => new TypeError("Failed to fetch")); route("GET /api/users/:u/dumps", () => page([])); const { result } = setup(); - await waitFor(() => expect(result.current.state).toMatchObject({ - status: "error", - error: expect.stringMatching(/Could not connect/), - })); + await waitFor(() => + expect(result.current.state).toMatchObject({ + status: "error", + error: expect.stringMatching(/Could not connect/), + }) + ); }); test("scrolling to the sentinel loads the next page", async () => { route("GET /api/users/:u", () => wire(ann)); - route("GET /api/users/:u/dumps", (req) => - req.url.searchParams.get("page") === "1" - ? page([makeDump({ id: "a" })], true) - : page([makeDump({ id: "b" })], false)); + route( + "GET /api/users/:u/dumps", + (req) => + req.url.searchParams.get("page") === "1" + ? page([makeDump({ id: "a" })], true) + : page([makeDump({ id: "b" })], false), + ); const { result } = setup(); await waitFor(() => expect(result.current.state.status).toBe("loaded")); const sentinel = document.createElement("div"); - act(() => (result.current.sentinelRef as (el: HTMLDivElement) => void)(sentinel)); + act(() => { + result.current.sentinelRef(sentinel); + }); const obs = FakeIntersectionObserver.instances.at(-1)!; - act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never)); + act(() => obs.report(true)); await waitFor(() => { const s = result.current.state; - expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]); + expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([ + "a", + "b", + ]); }); - const s = result.current.state as Extract; + const s = result.current.state as Extract< + typeof result.current.state, + { status: "loaded" } + >; expect(s.hasMore).toBe(false); expect(s.page).toBe(2); }); test("changing user resets to loading", async () => { - route("GET /api/users/:u", (req) => wire(makeUser({ username: req.params.u }))); + route( + "GET /api/users/:u", + (req) => wire(makeUser({ username: req.params.u })), + ); route("GET /api/users/:u/dumps", () => page([])); const { result, rerender } = setup(); await waitFor(() => expect(result.current.state.status).toBe("loaded")); @@ -206,17 +231,26 @@ describe("useUserDumpFeed", () => { test("setItems edits the loaded list", async () => { route("GET /api/users/:u", () => wire(ann)); - route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" }), makeDump({ id: "b" })])); + route( + "GET /api/users/:u/dumps", + () => page([makeDump({ id: "a" }), makeDump({ id: "b" })]), + ); const { result } = setup(); await waitFor(() => expect(result.current.state.status).toBe("loaded")); act(() => result.current.setItems((prev) => prev.slice(1))); - const s = result.current.state as Extract; + const s = result.current.state as Extract< + typeof result.current.state, + { status: "loaded" } + >; expect(s.items.map((d) => d.id)).toEqual(["b"]); }); }); describe("useYouTubePlaylist", () => { - type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void }; + type Events = { + onReady?: () => void; + onStateChange?: (e: { data: number }) => void; + }; function installYT(ids = ["a", "b", "c"]) { const player = { events: {} as Events, @@ -231,23 +265,33 @@ describe("useYouTubePlaylist", () => { }, destroy: vi.fn(), }; - (globalThis as unknown as { YT: unknown }).YT = { - Player: class { - constructor(_el: unknown, opts: { events: Events }) { - player.events = opts.events; - return player; - } - }, - }; + // Forwards to `player`, which the tests inspect and steer. + class Player { + constructor(_el: HTMLIFrameElement, opts: { events: Events }) { + player.events = opts.events; + } + playVideo = player.playVideo; + playVideoAt = player.playVideoAt; + nextVideo = player.nextVideo; + previousVideo = player.previousVideo; + getPlaylist = player.getPlaylist; + getPlaylistIndex = () => player.index; + destroy = player.destroy; + } + window.YT = { Player }; return player; } // The IFrame API loads once per document and is cached in the module. async function setup(embedUrl: string | null) { vi.resetModules(); - const { useYouTubePlaylist } = await import("../../hooks/useYouTubePlaylist.ts"); + const { useYouTubePlaylist } = await import( + "../../hooks/useYouTubePlaylist.ts" + ); return renderHook(({ url }) => { - const ref = useRef(document.createElement("iframe")); + const ref = useRef( + document.createElement("iframe"), + ); return useYouTubePlaylist(url, ref); }, { initialProps: { url: embedUrl } }); } @@ -261,7 +305,9 @@ describe("useYouTubePlaylist", () => { { videoId: "c", title: "C", duration: 30 }, ], })); - const { result } = await setup("https://www.youtube.com/embed/videoseries?list=PL1"); + const { result } = await setup( + "https://www.youtube.com/embed/videoseries?list=PL1", + ); await waitFor(() => expect(result.current.rows).toHaveLength(2)); expect(result.current.ready).toBe(false); await waitFor(() => expect(player.events.onReady).toBeDefined()); @@ -285,12 +331,17 @@ describe("useYouTubePlaylist", () => { test("a pause as the tab goes to the background is undone, a few times at most", async () => { const player = installYT(); - route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] })); + route( + "GET /api/youtube/playlist", + () => ({ tracks: [{ videoId: "a", title: "A" }] }), + ); await setup("https://www.youtube.com/embed/x?list=PL2"); await waitFor(() => expect(player.events.onStateChange).toBeDefined()); const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true); document.dispatchEvent(new Event("visibilitychange")); - for (let i = 0; i < 5; i++) act(() => player.events.onStateChange!({ data: 2 })); + for (let i = 0; i < 5; i++) { + act(() => player.events.onStateChange!({ data: 2 })); + } expect(player.playVideo).toHaveBeenCalledTimes(3); hidden.mockReturnValue(false); document.dispatchEvent(new Event("visibilitychange")); @@ -307,8 +358,13 @@ describe("useYouTubePlaylist", () => { test("a new embed drops the old list; unmount destroys the player", async () => { vi.spyOn(console, "warn").mockImplementation(() => {}); const player = installYT(); - route("GET /api/youtube/playlist", () => ({ tracks: [{ videoId: "a", title: "A" }] })); - const { result, rerender, unmount } = await setup("https://www.youtube.com/embed/x?list=PL3"); + route( + "GET /api/youtube/playlist", + () => ({ tracks: [{ videoId: "a", title: "A" }] }), + ); + const { result, rerender, unmount } = await setup( + "https://www.youtube.com/embed/x?list=PL3", + ); await waitFor(() => expect(result.current.rows).toHaveLength(1)); await waitFor(() => expect(player.events.onReady).toBeDefined()); route("GET /api/youtube/playlist", () => fail("x", 500)); diff --git a/src/test/hooks/editor.test.tsx b/src/test/hooks/editor.test.tsx index bda9cda2..9a1c744a 100644 --- a/src/test/hooks/editor.test.tsx +++ b/src/test/hooks/editor.test.tsx @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; -import type { KeyboardEvent } from "react"; import { useEmojiTrigger } from "../../hooks/useEmojiTrigger.ts"; -import { useMentionAutocomplete } from "../../hooks/useMentionAutocomplete.ts"; +import { + type MentionKey, + useMentionAutocomplete, +} from "../../hooks/useMentionAutocomplete.ts"; import { calls, deferred, route } from "../fetch.ts"; -function key(k: string, mods: Partial = {}) { +function key(k: string, mods: Partial = {}): MentionKey { return { key: k, ctrlKey: false, @@ -15,7 +17,7 @@ function key(k: string, mods: Partial = {}) { preventDefault: vi.fn(), stopPropagation: vi.fn(), ...mods, - } as unknown as KeyboardEvent; + }; } describe("useEmojiTrigger", () => { @@ -125,13 +127,20 @@ describe("useMentionAutocomplete", () => { } test("looks up the @query after a pause and opens with results", async () => { - route("GET /api/users/search", (req) => - users.filter((u) => u.username.startsWith(req.url.searchParams.get("q")!))); + route( + "GET /api/users/search", + (req) => + users.filter((u) => + u.username.startsWith(req.url.searchParams.get("q")!) + ), + ); const { hook, type } = setup(); await type("hi @al"); expect(hook.result.current.mentionOpen).toBe(true); expect(hook.result.current.mentionResults).toHaveLength(3); - expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe("al"); + expect(calls("GET /api/users/search")[0].url.searchParams.get("q")).toBe( + "al", + ); }); test("debounces keystrokes into one lookup", async () => { @@ -168,7 +177,9 @@ describe("useMentionAutocomplete", () => { const { hook, type } = setup(); await type("@al"); await type("@alz"); - await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users })))); + await act(async () => + slow.resolve(new Response(JSON.stringify({ success: true, data: users }))) + ); expect(hook.result.current.mentionOpen).toBe(false); }); @@ -231,7 +242,9 @@ describe("useMentionAutocomplete", () => { test("keys pass through while closed", () => { const { hook } = setup(); - expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false); + expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe( + false, + ); }); test("moving the caret off the mention closes it", async () => { diff --git a/src/test/hooks/misc.test.tsx b/src/test/hooks/misc.test.tsx index 58784761..94b75d92 100644 --- a/src/test/hooks/misc.test.tsx +++ b/src/test/hooks/misc.test.tsx @@ -1,7 +1,19 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { act, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { + act, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; import { type ReactNode, useRef } from "react"; -import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router"; import { useFading } from "../../hooks/useFading.ts"; import { useFeedCache } from "../../hooks/useFeedCache.ts"; import { useTabParam } from "../../hooks/useTabParam.ts"; @@ -112,7 +124,13 @@ describe("useFeedCache", () => { test("a fresh (PUSH) visit ignores the cache", () => { sessionStorage.setItem( "k", - JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }), + JSON.stringify({ + items: [1], + page: 1, + hasMore: false, + scrollY: 0, + savedAt: Date.now(), + }), ); function Probe() { const { cached } = useFeedCache("k", (x) => x); @@ -120,7 +138,11 @@ describe("useFeedCache", () => { } function Home() { const navigate = useNavigate(); - return ; + return ( + + ); } render( @@ -156,7 +178,13 @@ describe("useTabParam", () => { return useTabParam(TABS, "dumps"); }, { wrapper: ({ children }) => ( - + {children} ), @@ -165,8 +193,12 @@ describe("useTabParam", () => { } test("reads the tab after /~/", () => { - expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe("playlists"); - expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe("playlists"); + expect(setup("/users/ann/~/playlists").hook.result.current[0]).toBe( + "playlists", + ); + expect(setup("/users/ann/~/playlists/").hook.result.current[0]).toBe( + "playlists", + ); expect(setup("/users/ann").hook.result.current[0]).toBe("dumps"); expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps"); }); @@ -197,7 +229,8 @@ describe("useTabParam", () => { describe("useDocumentTitle", () => { test("composes page, site name and unread badge", () => { const { rerender } = renderHook( - ({ title }: { title: string | null | undefined }) => useDocumentTitle(title), + ({ title }: { title: string | null | undefined }) => + useDocumentTitle(title), { initialProps: { title: "Cats" as string | null | undefined } }, ); expect(document.title).toBe(`Cats · ${SITE_NAME}`); @@ -226,12 +259,7 @@ describe("useInfiniteScroll", () => { } const intersect = (isIntersecting: boolean) => { const obs = FakeIntersectionObserver.instances.at(-1)!; - act(() => - obs.callback( - [{ isIntersecting } as IntersectionObserverEntry], - obs as unknown as IntersectionObserver, - ) - ); + act(() => obs.report(isIntersecting)); }; test("loads more when the sentinel shows, only while enabled", () => { @@ -269,7 +297,16 @@ describe("useInfiniteScroll", () => { } delays.push(waited); } - expect(delays).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]); + expect(delays).toEqual([ + 1000, + 2000, + 4000, + 8000, + 16000, + 30000, + 30000, + 30000, + ]); }); test("re-rendering doesn't restart the wait", () => { @@ -292,18 +329,13 @@ describe("useInfiniteScroll", () => { render(); const at = (top: number) => { const obs = FakeIntersectionObserver.instances.at(-1)!; - act(() => - obs.callback( - [{ isIntersecting: true, boundingClientRect: { top } } as IntersectionObserverEntry], - obs as unknown as IntersectionObserver, - ) - ); + act(() => obs.report(true, top)); }; at(500); at(500); expect(onMore).toHaveBeenCalledTimes(1); // waiting - intersect(false); - intersect(true); + act(() => FakeIntersectionObserver.instances.at(-1)!.report(false, 500)); + at(500); expect(onMore).toHaveBeenCalledTimes(2); at(500); at(900); // the page grew: that load worked @@ -395,7 +427,7 @@ describe("useDumpCardLink", () => {