From 54e86c4366c72aa4be40b2ceae06e2e540525f84 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 14:35:41 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20frontend=20gets=20a=20test=20suite,?= =?UTF-8?q?=20and=20the=20bugs=20it=20turned=20up=20are=20fixed=20?= =?UTF-8?q?=E2=80=94=20`deno=20task=20test:web`=20runs=20612=20tests=20ove?= =?UTF-8?q?r=20src/=20in=20about=2011s:=20Vitest=20on=20jsdom=20with=20Tes?= =?UTF-8?q?ting=20Library,=20built=20on=20the=20app's=20own=20Vite=20confi?= =?UTF-8?q?g=20so=20the=20SWC/Lingui=20transform=20and=20the=20.po=20loade?= =?UTF-8?q?r=20are=20what=20ship.=20src/test/=20holds=20the=20harness:=20a?= =?UTF-8?q?=20routing=20fetch=20stub=20(queued=20once-responses,=20fallbac?= =?UTF-8?q?k=20defaults),=20a=20FakeWebSocket=20the=20tests=20open,=20feed?= =?UTF-8?q?=20and=20drop,=20jsdom=20polyfills,=20fixtures,=20and=20two=20r?= =?UTF-8?q?ender=20helpers=20=E2=80=94=20one=20over=20hand-set=20context?= =?UTF-8?q?=20values,=20one=20over=20the=20real=20provider=20tree=20(auth,?= =?UTF-8?q?=20socket,=20player,=20follows,=20categories,=20chat).=20It=20c?= =?UTF-8?q?overs=20every=20util=20and=20hook,=20every=20provider=20(the=20?= =?UTF-8?q?socket=20protocol=20end=20to=20end:=20votes=20and=20likes=20wit?= =?UTF-8?q?h=20ack/timeout/error=20rollback,=20chat=20sends/echoes/retries?= =?UTF-8?q?/read=20markers/reconcile,=20reconnect=20backfill),=20the=20com?= =?UTF-8?q?ponents=20(header=20and=20search=20history,=20cards,=20Markdown?= =?UTF-8?q?,=20thumbnails,=20media=20and=20global=20player,=20the=20text?= =?UTF-8?q?=20editor's=20mentions/emoji/uploads,=20comments,=20the=20new-d?= =?UTF-8?q?ump=20modal,=20the=20chat),=20and=20every=20page.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened. Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it. Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose. Co-Authored-By: Claude Opus 5.5 --- api/services/playlist-service.ts | 9 +- api/test/playlists.test.ts | 4 +- deno.json | 3 +- deno.lock | 486 +++++++++++++- eslint.config.ts | 9 + package.json | 8 +- src/App.css | 9 +- src/components/CategoryManager.tsx | Bin 6946 -> 6981 bytes src/components/CommentThread.tsx | 9 + src/components/ConfirmModal.tsx | 25 +- src/components/FeedTabBar.tsx | 3 +- src/components/FilePreview.tsx | 11 +- src/components/MediaPlayer.tsx | 32 +- src/components/Modal.tsx | 12 +- src/components/PlaylistCard.tsx | 5 +- src/components/PlaylistMembershipPanel.tsx | 11 +- src/components/RichContentCard.tsx | 9 +- src/components/UserListPopover.tsx | 8 +- src/components/form/SubmitButton.tsx | 10 +- src/locales/en.js | 2 +- src/locales/en.po | 581 ++++++++++------- src/locales/fr.js | 2 +- src/locales/fr.po | 581 ++++++++++------- src/model.ts | 3 +- src/pages/Dump.tsx | 27 +- src/pages/DumpEdit.tsx | 93 ++- src/pages/Index.tsx | 5 +- src/pages/Notifications.tsx | 67 +- src/pages/PlaylistDetail.tsx | 86 ++- src/pages/Search.tsx | 18 +- src/pages/UserLogin.tsx | 14 +- src/pages/UserPlaylists.tsx | 21 +- src/pages/UserPublicProfile.tsx | 85 ++- src/pages/UserUpvoted.tsx | 13 +- src/pages/index/FollowedFeed.tsx | 30 +- src/test/components/ChatModal.test.tsx | 304 +++++++++ src/test/components/CommentThread.test.tsx | 200 ++++++ src/test/components/DumpCreateModal.test.tsx | 336 ++++++++++ src/test/components/Modal.test.tsx | 105 +++ src/test/components/TextEditor.test.tsx | 295 +++++++++ src/test/components/buttons.test.tsx | 292 +++++++++ src/test/components/content.test.tsx | 307 +++++++++ src/test/components/header.test.tsx | 430 ++++++++++++ src/test/components/media.test.tsx | 225 +++++++ src/test/components/player.test.tsx | 242 +++++++ src/test/contexts/PlayerProvider.test.tsx | 370 +++++++++++ src/test/contexts/WSProvider.test.tsx | 648 +++++++++++++++++++ src/test/contexts/providers.test.tsx | 313 +++++++++ src/test/emojibase.ts | 11 + src/test/fetch.ts | 159 +++++ src/test/fixtures.ts | 144 +++++ src/test/hooks/data.test.tsx | 321 +++++++++ src/test/hooks/editor.test.tsx | 271 ++++++++ src/test/hooks/misc.test.tsx | 452 +++++++++++++ src/test/hooks/sync.test.tsx | 238 +++++++ src/test/hooks/useAuth.test.tsx | 261 ++++++++ src/test/pages/Dump.test.tsx | 338 ++++++++++ src/test/pages/Index.test.tsx | 198 ++++++ src/test/pages/Notifications.test.tsx | 128 ++++ src/test/pages/PlaylistDetail.test.tsx | 220 +++++++ src/test/pages/Search.test.tsx | 98 +++ src/test/pages/UserPlaylists.test.tsx | 77 +++ src/test/pages/UserPublicProfile.test.tsx | 316 +++++++++ src/test/pages/UserSubpages.test.tsx | 117 ++++ src/test/pages/auth.test.tsx | 237 +++++++ src/test/po.d.ts | 4 + src/test/render.tsx | 213 ++++++ src/test/setup.ts | 95 +++ src/test/utils/journal.test.ts | 171 +++++ src/test/utils/misc.test.ts | 166 +++++ src/test/utils/model.test.ts | 113 ++++ src/test/utils/remote.test.ts | 197 ++++++ src/test/utils/sound.test.ts | 135 ++++ src/test/utils/storage.test.ts | 183 ++++++ src/test/utils/text.test.ts | 157 +++++ src/test/ws.ts | 101 +++ src/utils/urls.ts | 9 + vitest.config.ts | 19 + 78 files changed, 10816 insertions(+), 691 deletions(-) create mode 100644 src/test/components/ChatModal.test.tsx create mode 100644 src/test/components/CommentThread.test.tsx create mode 100644 src/test/components/DumpCreateModal.test.tsx create mode 100644 src/test/components/Modal.test.tsx create mode 100644 src/test/components/TextEditor.test.tsx create mode 100644 src/test/components/buttons.test.tsx create mode 100644 src/test/components/content.test.tsx create mode 100644 src/test/components/header.test.tsx create mode 100644 src/test/components/media.test.tsx create mode 100644 src/test/components/player.test.tsx create mode 100644 src/test/contexts/PlayerProvider.test.tsx create mode 100644 src/test/contexts/WSProvider.test.tsx create mode 100644 src/test/contexts/providers.test.tsx create mode 100644 src/test/emojibase.ts create mode 100644 src/test/fetch.ts create mode 100644 src/test/fixtures.ts create mode 100644 src/test/hooks/data.test.tsx create mode 100644 src/test/hooks/editor.test.tsx create mode 100644 src/test/hooks/misc.test.tsx create mode 100644 src/test/hooks/sync.test.tsx create mode 100644 src/test/hooks/useAuth.test.tsx create mode 100644 src/test/pages/Dump.test.tsx create mode 100644 src/test/pages/Index.test.tsx create mode 100644 src/test/pages/Notifications.test.tsx create mode 100644 src/test/pages/PlaylistDetail.test.tsx create mode 100644 src/test/pages/Search.test.tsx create mode 100644 src/test/pages/UserPlaylists.test.tsx create mode 100644 src/test/pages/UserPublicProfile.test.tsx create mode 100644 src/test/pages/UserSubpages.test.tsx create mode 100644 src/test/pages/auth.test.tsx create mode 100644 src/test/po.d.ts create mode 100644 src/test/render.tsx create mode 100644 src/test/setup.ts create mode 100644 src/test/utils/journal.test.ts create mode 100644 src/test/utils/misc.test.ts create mode 100644 src/test/utils/model.test.ts create mode 100644 src/test/utils/remote.test.ts create mode 100644 src/test/utils/sound.test.ts create mode 100644 src/test/utils/storage.test.ts create mode 100644 src/test/utils/text.test.ts create mode 100644 src/test/ws.ts create mode 100644 vitest.config.ts diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index e4d0e32f..b9aaf8b9 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -235,10 +235,11 @@ export function setPlaylistImage( if (playlist.userId !== requestingUserId && !canModerate) { throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); } - db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run( - imageMime, - playlist.id, - ); + // A new cover is an edit: bumping updated_at also moves the `?v=` the + // client puts on the image URL, so a cached old cover isn't shown. + db.prepare( + `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, + ).run(imageMime, new Date().toISOString(), playlist.id); const updated = getPlaylistById(playlist.id); broadcastPlaylistUpdated(updated, playlist.isPublic); return updated; diff --git a/api/test/playlists.test.ts b/api/test/playlists.test.ts index 2d828f67..cbb48828 100644 --- a/api/test/playlists.test.ts +++ b/api/test/playlists.test.ts @@ -12,7 +12,7 @@ import { put, test, } from "./helpers.ts"; -import { assertEquals, assertMatch } from "@std/assert"; +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); @@ -297,6 +297,8 @@ test("playlist images: upload and serve", async () => { }); assertEquals(res.status, 200); assertEquals(res.body.data.imageMime, "image/png"); + // A new cover counts as an edit, so image URLs versioned on it change. + assert(res.body.data.updatedAt); const served = await get(`/api/playlists/${slug}/image`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); diff --git a/deno.json b/deno.json index 37dfca60..bc1f79f4 100644 --- a/deno.json +++ b/deno.json @@ -8,7 +8,8 @@ "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts", - "test": "deno test -A api/" + "test": "deno test -A api/", + "test:web": "deno run -A npm:vitest run" }, "nodeModulesDir": "auto", "compilerOptions": { diff --git a/deno.lock b/deno.lock index ffd866e1..4bc257b2 100644 --- a/deno.lock +++ b/deno.lock @@ -16,13 +16,17 @@ "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/fmt@1": "1.0.9", - "jsr:@std/fs@1": "1.0.23", + "jsr:@std/fs@1": "1.0.24", + "jsr:@std/fs@^1.0.24": "1.0.24", "jsr:@std/http@1": "1.0.25", - "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/media-types@1": "1.1.0", - "jsr:@std/path@1": "1.1.4", + "jsr:@std/path@1": "1.1.6", "jsr:@std/path@1.0": "1.0.9", "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/path@^1.1.5": "1.1.6", + "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", @@ -41,6 +45,10 @@ "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:@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/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", @@ -53,6 +61,7 @@ "npm:frimousse@0.3": "0.3.0_react@19.2.7_typescript@6.0.3", "npm:globals@^17.7.0": "17.7.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:path-to-regexp@^6.3.0": "6.3.0", @@ -66,7 +75,9 @@ "npm:typescript@*": "6.0.3", "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: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" }, "jsr": { "@db/sqlite@0.13.0": { @@ -81,7 +92,7 @@ "dependencies": [ "jsr:@std/encoding@1", "jsr:@std/fmt", - "jsr:@std/fs", + "jsr:@std/fs@1", "jsr:@std/path@1" ] }, @@ -114,7 +125,7 @@ "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" ] }, "@std/async@1.5.1": { @@ -138,10 +149,17 @@ "@std/fs@1.0.23": { "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.12", "jsr:@std/path@^1.1.4" ] }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, "@std/http@1.0.25": { "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", "dependencies": [ @@ -151,6 +169,9 @@ "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, @@ -160,7 +181,13 @@ "@std/path@1.1.4": { "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal@^1.0.14" ] }, "@std/testing@1.0.21": { @@ -168,7 +195,10 @@ "dependencies": [ "jsr:@std/assert@^1.0.19", "jsr:@std/async", - "jsr:@std/data-structures" + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.24", + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.6" ] }, "@tajpouria/cors@1.2.1": { @@ -176,6 +206,28 @@ } }, "npm": { + "@adobe/css-tools@4.5.0": { + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==" + }, + "@asamuzakjp/css-color@7.1.0": { + "integrity": "sha512-s6vMrO4hds+skR2OMmdwSORZiiEbqhAjd56W/0gc/x4PYpTibh/ey5Oy9ywkZ+ycX9EgTcJruhE1T/KkbU7LNA==", + "dependencies": [ + "@csstools/css-calc", + "@csstools/css-color-parser", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer", + "lru-cache@11.5.3" + ] + }, + "@asamuzakjp/dom-selector@9.2.1": { + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "dependencies": [ + "bidi-js", + "css-tree", + "is-potential-custom-element-name", + "lru-cache@11.5.3" + ] + }, "@babel/code-frame@7.29.7": { "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dependencies": [ @@ -223,7 +275,7 @@ "@babel/compat-data", "@babel/helper-validator-option", "browserslist", - "lru-cache", + "lru-cache@5.1.1", "semver@6.3.1" ] }, @@ -269,6 +321,9 @@ ], "bin": true }, + "@babel/runtime@7.29.7": { + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, "@babel/template@7.29.7": { "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dependencies": [ @@ -296,9 +351,53 @@ "@babel/helper-validator-identifier" ] }, + "@bramus/specificity@2.4.2": { + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dependencies": [ + "css-tree" + ], + "bin": true + }, "@colors/colors@1.5.0": { "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==" }, + "@csstools/color-helpers@6.1.2": { + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==" + }, + "@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": { + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "dependencies": [ + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": { + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "dependencies": [ + "@csstools/color-helpers", + "@csstools/css-calc", + "@csstools/css-parser-algorithms", + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": { + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "dependencies": [ + "@csstools/css-tokenizer" + ] + }, + "@csstools/css-syntax-patches-for-csstree@1.1.14_css-tree@3.2.1": { + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dependencies": [ + "css-tree" + ], + "optionalPeers": [ + "css-tree" + ] + }, + "@csstools/css-tokenizer@4.0.2": { + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==" + }, "@emnapi/core@1.11.1": { "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dependencies": [ @@ -497,6 +596,9 @@ "levn" ] }, + "@exodus/bytes@1.16.0": { + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==" + }, "@fontsource-variable/fraunces@5.3.0": { "integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==" }, @@ -569,8 +671,8 @@ "@jridgewell/resolve-uri@3.1.2": { "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" }, - "@jridgewell/sourcemap-codec@1.5.5": { - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + "@jridgewell/sourcemap-codec@1.6.0": { + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" }, "@jridgewell/trace-mapping@0.3.31": { "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", @@ -880,18 +982,81 @@ "@swc/counter" ] }, + "@testing-library/dom@10.4.2": { + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dependencies": [ + "@babel/code-frame", + "@babel/runtime", + "@types/aria-query", + "aria-query", + "dom-accessibility-api@0.5.16", + "lz-string", + "picocolors", + "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": { + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dependencies": [ + "@adobe/css-tools", + "@testing-library/dom", + "aria-query", + "css.escape", + "dom-accessibility-api@0.6.3", + "picocolors", + "redent", + "vitest" + ], + "optionalPeers": [ + "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": { + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dependencies": [ + "@babel/runtime", + "@testing-library/dom", + "@types/react", + "@types/react-dom", + "react", + "react-dom" + ], + "optionalPeers": [ + "@types/react", + "@types/react-dom" + ] + }, + "@testing-library/user-event@14.6.7_@testing-library+dom@10.4.2": { + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dependencies": [ + "@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==" + }, + "@types/chai@5.2.3": { + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dependencies": [ + "@types/deep-eql", + "assertion-error" + ] + }, "@types/debug@4.1.13": { "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dependencies": [ "@types/ms" ] }, + "@types/deep-eql@4.0.2": { + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" + }, "@types/esrecurse@4.3.1": { "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==" }, @@ -1080,6 +1245,22 @@ "vite" ] }, + "@vitest/mocker@5.0.2_vite@8.1.0__@types+node@26.0.1__jiti@2.7.0": { + "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==", + "dependencies": [ + "@jridgewell/trace-mapping", + "@vitest/spy", + "estree-walker", + "magic-string", + "vite" + ], + "optionalPeers": [ + "vite" + ] + }, + "@vitest/spy@5.0.2": { + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==" + }, "acorn-jsx@5.3.2_acorn@8.17.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ @@ -1114,6 +1295,15 @@ "ansi-styles@5.2.0": { "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, + "aria-query@5.3.0": { + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": [ + "dequal" + ] + }, + "assertion-error@2.0.1": { + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, "bail@2.0.2": { "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==" }, @@ -1124,6 +1314,12 @@ "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", "bin": true }, + "bidi-js@1.1.0": { + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dependencies": [ + "require-from-string" + ] + }, "brace-expansion@5.0.4": { "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dependencies": [ @@ -1156,6 +1352,9 @@ "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" }, + "chai@6.2.2": { + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" + }, "chalk@4.1.2": { "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": [ @@ -1234,15 +1433,35 @@ "which" ] }, + "css-tree@3.2.1": { + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dependencies": [ + "mdn-data", + "source-map-js" + ] + }, + "css.escape@1.5.1": { + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + }, "csstype@3.2.3": { "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "data-urls@7.0.0": { + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dependencies": [ + "whatwg-mimetype", + "whatwg-url@16.0.1" + ] + }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ "ms" ] }, + "decimal.js@10.6.0": { + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" + }, "decode-named-character-reference@1.3.0": { "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "dependencies": [ @@ -1264,6 +1483,12 @@ "dequal" ] }, + "dom-accessibility-api@0.5.16": { + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, + "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==" }, @@ -1279,6 +1504,12 @@ "emojibase@17.0.0": { "integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==" }, + "entities@8.1.0": { + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==" + }, + "es-module-lexer@2.3.2": { + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==" + }, "esbuild@0.25.12": { "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "optionalDependencies": [ @@ -1419,9 +1650,18 @@ "estree-util-is-identifier-name@3.0.0": { "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==" }, + "estree-walker@3.0.3": { + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": [ + "@types/estree" + ] + }, "esutils@2.0.3": { "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "expect-type@1.4.0": { + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==" + }, "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, @@ -1434,13 +1674,13 @@ "fast-levenshtein@2.0.6": { "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, - "fdir@6.5.0_picomatch@4.0.4": { + "fdir@6.5.0_picomatch@4.0.7": { "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ - "picomatch@4.0.4" + "picomatch@4.0.7" ], "optionalPeers": [ - "picomatch@4.0.4" + "picomatch@4.0.7" ] }, "file-entry-cache@8.0.0": { @@ -1540,6 +1780,12 @@ "hermes-estree" ] }, + "html-encoding-sniffer@7.0.0": { + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dependencies": [ + "@exodus/bytes" + ] + }, "html-url-attributes@3.0.1": { "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==" }, @@ -1552,6 +1798,9 @@ "imurmurhash@0.1.4": { "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, + "indent-string@4.0.0": { + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==" + }, "inline-style-parser@0.2.7": { "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" }, @@ -1592,6 +1841,9 @@ "is-plain-obj@4.1.0": { "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" }, + "is-potential-custom-element-name@1.0.1": { + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + }, "is-unicode-supported@2.1.0": { "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==" }, @@ -1609,7 +1861,7 @@ "chalk@4.1.2", "jest-get-type", "leven", - "pretty-format" + "pretty-format@29.7.0" ] }, "jiti@2.7.0": { @@ -1622,6 +1874,31 @@ "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "jsdom@30.1.1": { + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dependencies": [ + "@asamuzakjp/css-color", + "@asamuzakjp/dom-selector", + "@bramus/specificity", + "@csstools/css-syntax-patches-for-csstree", + "@exodus/bytes", + "css-tree", + "data-urls", + "decimal.js", + "html-encoding-sniffer", + "is-potential-custom-element-name", + "lru-cache@11.5.3", + "parse5", + "saxes", + "tough-cookie", + "undici", + "w3c-xmlserializer", + "webidl-conversions", + "whatwg-mimetype", + "whatwg-url@17.1.2", + "xml-name-validator" + ] + }, "jsesc@3.1.0": { "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": true @@ -1748,12 +2025,25 @@ "longest-streak@3.1.0": { "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==" }, + "lru-cache@11.5.3": { + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ "yallist" ] }, + "lz-string@1.5.0": { + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": true + }, + "magic-string@1.4.2": { + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, "markdown-table@3.0.4": { "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" }, @@ -1926,6 +2216,9 @@ "@types/mdast" ] }, + "mdn-data@2.27.1": { + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==" + }, "micromark-core-commonmark@2.0.3": { "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", "dependencies": [ @@ -2177,6 +2470,9 @@ "mimic-function@5.0.1": { "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==" }, + "min-indent@1.0.1": { + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" + }, "minimatch@10.2.4": { "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dependencies": [ @@ -2205,6 +2501,9 @@ "normalize-path@3.0.0": { "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" }, + "obug@2.2.1": { + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==" + }, "onetime@7.0.0": { "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dependencies": [ @@ -2259,6 +2558,12 @@ "is-hexadecimal" ] }, + "parse5@8.0.1": { + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dependencies": [ + "entities" + ] + }, "path-exists@4.0.0": { "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" }, @@ -2274,8 +2579,8 @@ "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, - "picomatch@4.0.4": { - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==" + "picomatch@4.0.7": { + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==" }, "pofile-ts@4.0.3": { "integrity": "sha512-sz1pnjgEfPyZ+QvaeX3NtCmbYnEvG01LZRLoN/uXoLtPZtxCIH5IctL7yXXc0fFyk/fqV6K8g3hlNfr6IJwupA==" @@ -2291,12 +2596,20 @@ "prelude-ls@1.2.1": { "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "pretty-format@27.5.1": { + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": [ + "ansi-regex@5.0.1", + "ansi-styles@5.2.0", + "react-is@17.0.2" + ] + }, "pretty-format@29.7.0": { "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", "dependencies": [ "@jest/schemas", "ansi-styles@5.2.0", - "react-is" + "react-is@18.3.1" ] }, "property-information@7.1.0": { @@ -2325,6 +2638,9 @@ "react" ] }, + "react-is@17.0.2": { + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "react-is@18.3.1": { "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, @@ -2363,6 +2679,13 @@ "readdirp@5.0.0": { "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==" }, + "redent@3.0.0": { + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dependencies": [ + "indent-string", + "strip-indent" + ] + }, "remark-gfm@4.0.1": { "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "dependencies": [ @@ -2401,6 +2724,9 @@ "unified" ] }, + "require-from-string@2.0.2": { + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "restore-cursor@5.1.0": { "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dependencies": [ @@ -2433,6 +2759,12 @@ ], "bin": true }, + "saxes@6.0.0": { + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": [ + "xmlchars" + ] + }, "scheduler@0.27.0": { "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" }, @@ -2465,6 +2797,9 @@ "space-separated-tokens@2.0.2": { "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" }, + "std-env@4.2.0": { + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==" + }, "stdin-discarder@0.3.2": { "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==" }, @@ -2502,6 +2837,12 @@ "ansi-regex@6.2.2" ] }, + "strip-indent@3.0.0": { + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dependencies": [ + "min-indent" + ] + }, "style-to-js@1.1.21": { "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", "dependencies": [ @@ -2520,22 +2861,50 @@ "has-flag" ] }, + "tinybench@6.2.0": { + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==" + }, + "tinyexec@1.3.1": { + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==" + }, "tinyglobby@0.2.17": { "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dependencies": [ "fdir", - "picomatch@4.0.4" + "picomatch@4.0.7" ] }, "tinypool@2.1.0": { "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==" }, + "tldts-core@7.4.15": { + "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==" + }, + "tldts@7.4.15": { + "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", + "dependencies": [ + "tldts-core" + ], + "bin": true + }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ "is-number" ] }, + "tough-cookie@6.0.2": { + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dependencies": [ + "tldts" + ] + }, + "tr46@6.0.0": { + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dependencies": [ + "punycode" + ] + }, "trim-lines@3.0.1": { "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" }, @@ -2575,6 +2944,9 @@ "undici-types@8.3.0": { "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, + "undici@8.11.2": { + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==" + }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dependencies": [ @@ -2655,7 +3027,7 @@ "@types/node", "jiti", "lightningcss", - "picomatch@4.0.4", + "picomatch@4.0.7", "postcss", "rolldown", "tinyglobby" @@ -2669,6 +3041,60 @@ ], "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": { + "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==", + "dependencies": [ + "@types/chai", + "@types/node", + "@vitest/mocker", + "chai", + "es-module-lexer", + "expect-type", + "jsdom", + "magic-string", + "obug", + "picomatch@4.0.7", + "std-env", + "tinybench", + "tinyexec", + "tinyglobby", + "vite", + "why-is-node-running" + ], + "optionalPeers": [ + "@types/node", + "jsdom" + ], + "bin": true + }, + "w3c-xmlserializer@6.0.0": { + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dependencies": [ + "xml-name-validator" + ] + }, + "webidl-conversions@8.0.1": { + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==" + }, + "whatwg-mimetype@5.0.0": { + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==" + }, + "whatwg-url@16.0.1": { + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dependencies": [ + "@exodus/bytes", + "tr46", + "webidl-conversions" + ] + }, + "whatwg-url@17.1.2": { + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dependencies": [ + "@exodus/bytes", + "tr46", + "webidl-conversions" + ] + }, "which@2.0.2": { "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ @@ -2676,9 +3102,19 @@ ], "bin": true }, + "why-is-node-running@3.2.2": { + "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", + "bin": true + }, "word-wrap@1.2.5": { "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" }, + "xml-name-validator@5.0.0": { + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==" + }, + "xmlchars@2.2.0": { + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, @@ -2728,6 +3164,10 @@ "npm:@lingui/react@6.4.0", "npm:@lingui/swc-plugin@6.5.0", "npm:@lingui/vite-plugin@6.4.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", @@ -2739,6 +3179,7 @@ "npm:frimousse@0.3", "npm:globals@^17.7.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-markdown@^10.1.0", @@ -2747,7 +3188,8 @@ "npm:remark-gfm@^4.0.1", "npm:typescript-eslint@^8.62.0", "npm:typescript@~6.0.3", - "npm:vite@^8.1.0" + "npm:vite@^8.1.0", + "npm:vitest@^5.0.2" ] } } diff --git a/eslint.config.ts b/eslint.config.ts index fe309ca9..791071e6 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -29,4 +29,13 @@ export default defineConfig([ ], }, }, + { + // Test harness: helper modules export functions next to components, and + // probe components hand their context out to the test on purpose. + files: ["src/test/**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": "off", + "react-hooks/globals": "off", + }, + }, ]); diff --git a/package.json b/package.json index 9db42a88..9057c20e 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,20 @@ "devDependencies": { "@lingui/vite-plugin": "6.4.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", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", + "jsdom": "^30.1.1", "typescript": "~6.0.3", "typescript-eslint": "^8.62.0", - "vite": "^8.1.0" + "vite": "^8.1.0", + "vitest": "^5.0.2" } } diff --git a/src/App.css b/src/App.css index 29c0e3ff..607ae470 100644 --- a/src/App.css +++ b/src/App.css @@ -249,7 +249,8 @@ pointer-events: none; } -.dump-title-view--editable:hover .dump-title-edit-btn { +.dump-title-view--editable:hover .dump-title-edit-btn, +.dump-title-view--editable:focus-visible .dump-title-edit-btn { opacity: 1; } @@ -2304,7 +2305,8 @@ body.has-player .chat-fab { pointer-events: none; } -.profile-description-view--editable:hover .profile-description-edit-btn { +.profile-description-view--editable:hover .profile-description-edit-btn, +.profile-description-view--editable:focus-visible .profile-description-edit-btn { opacity: 1; } @@ -2331,7 +2333,8 @@ body.has-player .chat-fab { background: var(--color-surface); } -.profile-email-display:hover .profile-description-edit-btn { +.profile-email-display:hover .profile-description-edit-btn, +.profile-email-display:focus-visible .profile-description-edit-btn { opacity: 1; } diff --git a/src/components/CategoryManager.tsx b/src/components/CategoryManager.tsx index c9e43bd21148a185a17bfb8c228fa72f2fe97de2..a116193b9ca345429e5e44bcafaaba9d487c8719 100644 GIT binary patch delta 25 hcmZ2vcGPS`mDuDPoB}MtiDjvid&Lzu_lQkn0RV$E3331c delta 16 YcmX?Vw#aNlmDuF{;zFC(i%nwz06kj( `${editOpen}:${replyOpen}`, ); @@ -227,11 +228,14 @@ function CommentNode({ async function handleDelete() { if (!token) return; + setDeleteFailed(false); const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, { method: "DELETE", }).catch(() => null); if (res?.ok) { onCommentDeleted(comment.id); + } else { + setDeleteFailed(true); } } @@ -407,6 +411,11 @@ function CommentNode({ /> )} + {deleteFailed && ( +

+ This comment couldn't be deleted. Try again. +

+ )} {replyOpen && ( { + // A confirm can sit on top of a Modal (deleting a chat message), whose + // own Escape handler is also on the document and was added first. Take + // the key in the capture phase and mark it handled, so the Modal leaves + // it alone and only the confirm goes. const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onCancel(); + if (e.key !== "Escape" || e.defaultPrevented) return; + e.preventDefault(); + onCancel(); }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); }, [onCancel]); return createPortal(
-
e.stopPropagation()}> -

{message}

+
e.stopPropagation()} + > +

{message}

@@ -208,7 +210,7 @@ export default function FilePreview( e.stopPropagation(); play(filePlayerItem(dump, fileUrl, mime, token)); }} - aria-label="Play" + aria-label={t`Play`} >
); @@ -369,12 +372,27 @@ export function MediaPlayer( setPlaying(false); onErrorRef.current?.(); }; + // The element can also be started or stopped from outside the page — a + // media key, a headset, the OS's media controls — so follow it rather + // than only what our own button did. + const onPlayEvent = () => { + setPlaying(true); + onPlayStateChangeRef.current?.(true); + }; + const onPauseEvent = () => { + setPlaying(false); + onPlayStateChangeRef.current?.(false); + }; a.addEventListener("timeupdate", onTime); + a.addEventListener("play", onPlayEvent); + a.addEventListener("pause", onPauseEvent); a.addEventListener("durationchange", onDuration); a.addEventListener("ended", onEndedEvent); a.addEventListener("error", onErrorEvent); return () => { a.removeEventListener("timeupdate", onTime); + a.removeEventListener("play", onPlayEvent); + a.removeEventListener("pause", onPauseEvent); a.removeEventListener("durationchange", onDuration); a.removeEventListener("ended", onEndedEvent); a.removeEventListener("error", onErrorEvent); @@ -462,7 +480,7 @@ export function MediaPlayer( type="button" className="audio-player-btn" onClick={toggle} - aria-label={playing ? "Pause" : "Play"} + aria-label={playing ? t`Pause` : t`Play`} > {playing ? : } @@ -478,7 +496,7 @@ export function MediaPlayer( type="button" className="audio-player-vol-btn" onClick={toggleMute} - aria-label={muted ? "Unmute" : "Mute"} + aria-label={muted ? t`Unmute` : t`Mute`} > @@ -495,7 +513,7 @@ export function MediaPlayer( step={0.01} value={muted ? 0 : volume} onChange={changeVolume} - aria-label="Volume" + aria-label={t`Volume`} />
@@ -505,7 +523,7 @@ export function MediaPlayer( type="button" className="audio-player-vol-btn" onClick={goFullscreen} - aria-label="Fullscreen" + aria-label={t`Fullscreen`} > diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 6d80832c..77edb076 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useEffect, useRef } from "react"; +import { type ReactNode, useCallback, useEffect, useId, useRef } from "react"; import { createPortal } from "react-dom"; import { t } from "@lingui/core/macro"; import { useScrollLock } from "../hooks/useScrollLock.ts"; @@ -20,6 +20,7 @@ export function Modal( { title, onClose, children, wide = false, onBeforeClose }: ModalProps, ) { const backdropRef = useRef(null); + const titleId = useId(); const requestClose = useCallback(() => { if (onBeforeClose?.() === false) return; @@ -44,9 +45,14 @@ export function Modal( if (e.target === backdropRef.current) requestClose(); }} > -
+
- {title} + {title} ); diff --git a/src/locales/en.js b/src/locales/en.js index af60c54e..7ed0d025 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1 +1 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2ygf_L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"9xDZu_\":[\"Could not load.\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AnRu_j\":[\"Cancel reply\"],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Registering…\"],\"D8DRYV\":[\"Delete this message?\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Loading messages…\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"uD0qXQ\":[\"Drop a file here\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}")}; \ No newline at end of file +/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1QfxQT\":[\"Dismiss\"],\"1YKhJ3\":[\"1 new message\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2ygf_L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3qkggm\":[\"Fullscreen\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Edit email\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4OjqAQ\":[\"Keep editing\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8-8abB\":[\"Disconnected — reconnecting…\"],\"8F1i42\":[\"Page not found\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"9xDZu_\":[\"Could not load.\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AEfu-D\":[\"In collections\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"AS7WoE\":[\"Start fresh\"],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AhNP40\":[\"Seek\"],\"AnRu_j\":[\"Cancel reply\"],\"B4LeYP\":[\"Delete failed (\",[\"0\"],\")\"],\"BjitBo\":[\"Download \",[\"0\"]],\"BlpJ2G\":[\"Picked up where you left off.\"],\"CI50ct\":[\"Upvoted\"],\"C_qQE4\":[\"Already dumped by \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Registering…\"],\"D8DRYV\":[\"Delete this message?\"],\"DI3NBO\":[\"Open on \",[\"0\"]],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connection lost — this message may not have been sent\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"EarrCe\":[\"Search emoji\"],\"Evcm2Z\":[\"Close without posting? The attached file won't be kept — everything you typed will.\"],\"EwVrWF\":[\"The dump couldn't be removed. Try again.\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FdTlEG\":[\"Track \",[\"0\"]],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GCWCCo\":[\"Close player\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GY6uSc\":[\"Next →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"H4MGkI\":[\"Expand player\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"H_PnT8\":[\"Insert emoji\"],\"I-x669\":[\"Invitees\"],\"IO9KL_\":[\"Next track\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"ImOQa9\":[\"Reply\"],\"JJ-Bhk\":[\"Your email address\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"KDw4GX\":[\"Try again\"],\"KKZxBU\":[\"Uploading \",[\"0\"],\"%\"],\"K_F6pa\":[\"Saving…\"],\"KtYF-t\":[\"Sounds\"],\"L-rMC9\":[\"Reset to default\"],\"L7_rsx\":[\"Uploading image…\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"O32mmL\":[\"Edit description\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PN3pBl\":[\"No reply from the server — this message may not have been sent\"],\"PUe8Yk\":[\"Collapse player\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q4WUjR\":[\"Search emoji…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"R7SsBE\":[\"Mute\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"RvVJBH\":[\"Refreshing stream…\"],\"S1xV5B\":[\"At most \",[\"maxLength\"],\" characters.\"],\"SBTElJ\":[\"Searching…\"],\"Sad2tK\":[\"Sending…\"],\"So7gCD\":[\"Paste a link…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T8ejtZ\":[\"This comment couldn't be deleted. Try again.\"],\"T91vKp\":[\"Play\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Loading messages…\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W9FRBT\":[\"Like\"],\"WAcnnR\":[\"The image couldn't be uploaded.\"],\"WE_r9K\":[\"Failed to create category\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XHqhoS\":[\"Couldn't load the chat.\"],\"XJVYo8\":[\"Why & where\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YZxXK8\":[\"Close emoji picker\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"Z5HWHd\":[\"On\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZWxjuD\":[\"Some images couldn't be uploaded.\"],\"ZmD2o6\":[\"Create & Add\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_cF7Rs\":[\"Volume\"],\"_karOe\":[\"You're offline — not sent\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"az8lvo\":[\"Off\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"bzSI52\":[\"Discard\"],\"cILfnJ\":[\"Remove file\"],\"cNu4my\":[[\"0\"],\" more online: \",[\"restNames\"]],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"dau1Pl\":[[\"unseenCount\"],\" new messages\"],\"dqwCfR\":[\"Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.\"],\"dznB-Y\":[\"Only images can be dropped here.\"],\"eFSqvc\":[\"Failed to post reply\"],\"eIEHHV\":[\"Change skin tone\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"eloqXQ\":[\"You can post it anyway.\"],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gL9KKi\":[\"Who can see it\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"goWEhR\":[\"Previous track\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Reset to the suggested title\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"hpCc_Z\":[\"Close anyway\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"k4b5_X\":[\"edited\"],\"kLttbL\":[\"Registration failed\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"mb_Wju\":[\"Drop a file here, or paste one\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"and \",\"#\",\" earlier dump\"],\"other\":[\"and \",\"#\",\" earlier dumps\"]}]],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qsqt0w\":[\"Invite link\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rn6SBY\":[\"Unmute\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Link or file\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"sgX7B-\":[\"Single use, valid for 7 days.\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"uD0qXQ\":[\"Drop a file here\"],\"uISnsY\":[\"Could not delete\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"v-Wp--\":[\"Open source\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"yzF66j\":[\"Link\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}")}; \ No newline at end of file diff --git a/src/locales/en.po b/src/locales/en.po index 7875f312..d11c7c6b 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -13,7 +13,7 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -#: src/components/CommentThread.tsx:270 +#: src/components/CommentThread.tsx:274 msgid "[deleted]" msgstr "[deleted]" @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:86 +#: src/components/PlaylistCard.tsx:85 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" @@ -55,7 +55,7 @@ msgstr "{0} more online: {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores" -#: src/pages/Search.tsx:178 +#: src/pages/Search.tsx:192 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -63,25 +63,25 @@ msgstr "{label} ({count})" msgid "{unseenCount} new messages" msgstr "{unseenCount} new messages" -#: src/components/CommentThread.tsx:475 +#: src/components/CommentThread.tsx:484 msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}" #: src/components/DumpCreateModal.tsx:942 -#: src/pages/PlaylistDetail.tsx:572 -#: src/pages/UserPublicProfile.tsx:790 +#: src/pages/PlaylistDetail.tsx:603 +#: src/pages/UserPublicProfile.tsx:819 msgid "← Back" msgstr "← Back" #: src/pages/Dump.tsx:290 -#: src/pages/Dump.tsx:520 -#: src/pages/DumpEdit.tsx:187 +#: src/pages/Dump.tsx:533 +#: src/pages/DumpEdit.tsx:185 msgid "← Back to all dumps" msgstr "← Back to all dumps" #: src/pages/UserDumps.tsx:66 -#: src/pages/UserPlaylists.tsx:357 -#: src/pages/UserUpvoted.tsx:137 +#: src/pages/UserPlaylists.tsx:368 +#: src/pages/UserUpvoted.tsx:144 msgid "← Back to profile" msgstr "← Back to profile" @@ -89,57 +89,57 @@ msgstr "← Back to profile" msgid "+ Invite someone" msgstr "+ Invite someone" -#: src/components/PlaylistMembershipPanel.tsx:81 +#: src/components/PlaylistMembershipPanel.tsx:90 msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:361 +#: src/pages/Dump.tsx:368 msgid "+ Playlist" msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:197 +#: src/pages/Notifications.tsx:198 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:157 +#: src/pages/Notifications.tsx:158 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:207 +#: src/pages/Notifications.tsx:208 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:223 +#: src/pages/Notifications.tsx:224 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:167 +#: src/pages/Notifications.tsx:168 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:148 +#: src/pages/Notifications.tsx:149 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:187 +#: src/pages/Notifications.tsx:188 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:177 +#: src/pages/Notifications.tsx:178 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" @@ -147,19 +147,19 @@ msgstr "<0>{0} was added to <1>{1}" msgid "1 new message" msgstr "1 new message" -#: src/pages/Notifications.tsx:218 +#: src/pages/Notifications.tsx:219 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:222 msgid "a post" msgstr "a post" -#: src/pages/UserPublicProfile.tsx:1171 +#: src/pages/UserPublicProfile.tsx:1227 msgid "Account" msgstr "Account" -#: src/components/CommentThread.tsx:495 +#: src/components/CommentThread.tsx:504 msgid "Add a comment…" msgstr "Add a comment…" @@ -168,7 +168,7 @@ msgstr "Add a comment…" msgid "Add category" msgstr "Add category" -#: src/pages/UserPublicProfile.tsx:868 +#: src/pages/UserPublicProfile.tsx:905 msgid "Add email…" msgstr "Add email…" @@ -177,7 +177,7 @@ msgstr "Add email…" msgid "Add to playlist" msgstr "Add to playlist" -#: src/pages/UserPublicProfile.tsx:1635 +#: src/pages/UserPublicProfile.tsx:1697 msgid "Admin" msgstr "Admin" @@ -196,7 +196,7 @@ msgstr "Already dumped by {0} {1}" msgid "Already have an account? <0>Log in" msgstr "Already have an account? <0>Log in" -#: src/pages/UserPublicProfile.tsx:1190 +#: src/pages/UserPublicProfile.tsx:1246 msgid "Appearance" msgstr "Appearance" @@ -211,7 +211,7 @@ msgstr "At least {0} characters" msgid "At most {maxLength} characters." msgstr "At most {maxLength} characters." -#: src/pages/UserPublicProfile.tsx:1252 +#: src/pages/UserPublicProfile.tsx:1308 msgid "Auto" msgstr "Auto" @@ -225,22 +225,22 @@ msgstr "Back to login" 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:210 #: src/components/CategoryManager.tsx:211 +#: src/components/CategoryManager.tsx:212 #: src/components/ChatModal.tsx:235 #: src/components/CommentThread.tsx:129 -#: src/components/ConfirmModal.tsx:34 +#: src/components/ConfirmModal.tsx:47 #: src/components/DumpCreateModal.tsx:952 #: src/components/form/FormActions.tsx:32 -#: src/pages/Dump.tsx:402 -#: src/pages/DumpEdit.tsx:483 -#: src/pages/PlaylistDetail.tsx:929 -#: src/pages/UserPublicProfile.tsx:1764 -#: src/pages/UserPublicProfile.tsx:1838 +#: src/pages/Dump.tsx:409 +#: src/pages/DumpEdit.tsx:506 +#: src/pages/PlaylistDetail.tsx:969 +#: src/pages/UserPublicProfile.tsx:1826 +#: src/pages/UserPublicProfile.tsx:1901 msgid "Cancel" msgstr "Cancel" -#: src/pages/PlaylistDetail.tsx:747 +#: src/pages/PlaylistDetail.tsx:782 msgid "Cancel removal" msgstr "Cancel removal" @@ -254,7 +254,7 @@ msgid "Cancel search" msgstr "Cancel search" #: src/components/CategorySelect.tsx:22 -#: src/pages/UserPublicProfile.tsx:1367 +#: src/pages/UserPublicProfile.tsx:1423 msgid "Categories" msgstr "Categories" @@ -262,7 +262,7 @@ msgstr "Categories" msgid "Category" msgstr "Category" -#: src/pages/UserPublicProfile.tsx:817 +#: src/pages/UserPublicProfile.tsx:846 msgid "Change avatar" msgstr "Change avatar" @@ -271,11 +271,11 @@ msgstr "Change avatar" msgid "Change password" msgstr "Change password" -#: src/pages/UserPublicProfile.tsx:1183 +#: src/pages/UserPublicProfile.tsx:1239 msgid "Change password…" msgstr "Change password…" -#: src/components/TextEditor.tsx:619 +#: src/components/TextEditor.tsx:648 msgid "Change skin tone" msgstr "Change skin tone" @@ -296,7 +296,7 @@ msgid "Checking invite…" msgstr "Checking invite…" #: src/components/ChangePasswordModal.tsx:59 -#: src/components/Modal.tsx:54 +#: src/components/Modal.tsx:60 msgid "Close" msgstr "Close" @@ -304,7 +304,7 @@ msgstr "Close" msgid "Close anyway" msgstr "Close anyway" -#: src/components/TextEditor.tsx:628 +#: src/components/TextEditor.tsx:657 msgid "Close emoji picker" msgstr "Close emoji picker" @@ -320,7 +320,7 @@ msgstr "Close without posting? The attached file won't be kept — everything yo msgid "Collapse player" msgstr "Collapse player" -#: src/pages/UserPublicProfile.tsx:1244 +#: src/pages/UserPublicProfile.tsx:1300 msgid "Color scheme" msgstr "Color scheme" @@ -345,11 +345,16 @@ msgstr "Copy" msgid "Could not change password" msgstr "Could not change password" +#: src/pages/DumpEdit.tsx:494 +#: src/pages/UserPlaylists.tsx:400 +msgid "Could not delete" +msgstr "Could not delete" + #: src/components/UserListPopover.tsx:141 msgid "Could not load." msgstr "Could not load." -#: src/pages/DumpEdit.tsx:434 +#: src/pages/DumpEdit.tsx:454 msgid "Could not save" msgstr "Could not save" @@ -371,7 +376,7 @@ msgstr "Create & Add" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:391 +#: src/pages/UserPlaylists.tsx:406 msgid "Created ({0}{1})" msgstr "Created ({0}{1})" @@ -383,47 +388,52 @@ msgstr "Creating…" msgid "Current password" msgstr "Current password" -#: src/pages/UserPublicProfile.tsx:1266 +#: src/pages/UserPublicProfile.tsx:1322 msgid "Dark" msgstr "Dark" -#: src/pages/UserPublicProfile.tsx:1279 +#: src/pages/UserPublicProfile.tsx:1335 msgid "Default tab" msgstr "Default tab" -#: src/components/CategoryManager.tsx:221 #: src/components/CategoryManager.tsx:222 +#: src/components/CategoryManager.tsx:223 #: src/components/ChatModal.tsx:347 #: src/components/ChatModal.tsx:348 #: src/components/ChatModal.tsx:446 -#: src/components/CommentThread.tsx:395 -#: src/components/CommentThread.tsx:401 +#: src/components/CommentThread.tsx:399 +#: src/components/CommentThread.tsx:405 #: src/components/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:925 +#: src/pages/PlaylistDetail.tsx:965 msgid "Delete" msgstr "Delete" -#: src/components/CategoryManager.tsx:236 +#: src/components/CategoryManager.tsx:237 msgid "Delete category" msgstr "Delete category" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:235 +#: src/components/CategoryManager.tsx:236 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:479 +#: src/pages/DumpEdit.tsx:502 msgid "Delete dump" msgstr "Delete dump" -#: src/components/PlaylistCard.tsx:109 -#: src/pages/PlaylistDetail.tsx:760 -#: src/pages/UserPlaylists.tsx:468 +#. placeholder {0}: res.status +#: src/pages/DumpEdit.tsx:148 +msgid "Delete failed ({0})" +msgstr "Delete failed ({0})" + +#: src/components/PlaylistCard.tsx:108 +#: src/pages/PlaylistDetail.tsx:795 +#: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Delete playlist" -#: src/components/CommentThread.tsx:400 +#: src/components/CommentThread.tsx:404 msgid "Delete this comment?" msgstr "Delete this comment?" @@ -435,8 +445,8 @@ msgstr "Delete this dump? This cannot be undone." msgid "Delete this message?" msgstr "Delete this message?" -#: src/pages/PlaylistDetail.tsx:759 -#: src/pages/UserPlaylists.tsx:467 +#: src/pages/PlaylistDetail.tsx:794 +#: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." @@ -445,7 +455,7 @@ msgid "deleted message" msgstr "deleted message" #: src/components/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:886 +#: src/pages/PlaylistDetail.tsx:926 msgid "Description (optional)" msgstr "Description (optional)" @@ -466,6 +476,11 @@ msgstr "Dismiss" msgid "Done" msgstr "Done" +#. placeholder {0}: dump.fileName +#: src/components/FilePreview.tsx:317 +msgid "Download {0}" +msgstr "Download {0}" + #: src/components/FileDropZone.tsx:29 msgid "Drop a file here" msgstr "Drop a file here" @@ -474,7 +489,7 @@ msgstr "Drop a file here" msgid "Drop a file here, or paste one" msgstr "Drop a file here, or paste one" -#: src/pages/DumpEdit.tsx:426 +#: src/pages/DumpEdit.tsx:446 msgid "Drop a replacement here" msgstr "Drop a replacement here" @@ -490,28 +505,28 @@ msgstr "Dump it" msgid "Dumped!" msgstr "Dumped!" -#: src/pages/Search.tsx:174 +#: src/pages/Search.tsx:188 #: src/pages/UserDumps.tsx:80 -#: src/pages/UserPublicProfile.tsx:940 +#: src/pages/UserPublicProfile.tsx:992 msgid "Dumps" msgstr "Dumps" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:960 +#: src/pages/UserPublicProfile.tsx:1012 msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" -#: src/pages/Notifications.tsx:401 +#: src/pages/Notifications.tsx:422 msgid "Earlier" msgstr "Earlier" #: src/components/ChatModal.tsx:336 #: src/components/ChatModal.tsx:337 #: src/components/ChatModal.tsx:526 -#: src/components/CommentThread.tsx:386 -#: src/pages/Dump.tsx:516 -#: src/pages/PlaylistDetail.tsx:627 +#: src/components/CommentThread.tsx:390 +#: src/pages/Dump.tsx:529 +#: src/pages/PlaylistDetail.tsx:658 msgid "Edit" msgstr "Edit" @@ -520,7 +535,16 @@ msgstr "Edit" msgid "Edit {0}" msgstr "Edit {0}" +#: src/pages/UserPublicProfile.tsx:956 +msgid "Edit description" +msgstr "Edit description" + +#: src/pages/UserPublicProfile.tsx:903 +msgid "Edit email" +msgstr "Edit email" + #: src/components/DumpCreateModal.tsx:197 +#: src/pages/Dump.tsx:425 msgid "Edit title" msgstr "Edit title" @@ -532,9 +556,9 @@ msgstr "edited" #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/ChatModal.tsx:361 -#: src/components/CommentThread.tsx:335 -#: src/pages/Dump.tsx:455 -#: src/pages/PlaylistDetail.tsx:666 +#: src/components/CommentThread.tsx:339 +#: src/pages/Dump.tsx:468 +#: src/pages/PlaylistDetail.tsx:697 msgid "edited {0}" msgstr "edited {0}" @@ -542,18 +566,18 @@ msgstr "edited {0}" #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/ChatModal.tsx:357 -#: src/components/CommentThread.tsx:333 -#: src/pages/Dump.tsx:453 -#: src/pages/PlaylistDetail.tsx:663 +#: src/components/CommentThread.tsx:337 +#: src/pages/Dump.tsx:466 +#: src/pages/PlaylistDetail.tsx:694 msgid "Edited {0}" msgstr "Edited {0}" -#: src/pages/DumpEdit.tsx:212 +#: src/pages/DumpEdit.tsx:210 msgid "Editing" msgstr "Editing" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:48 +#: src/pages/DumpEdit.tsx:49 msgid "Editing {0}" msgstr "Editing {0}" @@ -561,7 +585,7 @@ msgstr "Editing {0}" msgid "Email address" msgstr "Email address" -#: src/pages/Search.tsx:207 +#: src/pages/Search.tsx:221 msgid "Enter a query to search." msgstr "Enter a query to search." @@ -569,7 +593,7 @@ msgstr "Enter a query to search." msgid "Expand player" msgstr "Expand player" -#: src/components/CategoryManager.tsx:230 +#: src/components/CategoryManager.tsx:231 msgid "Failed to create category" msgstr "Failed to create category" @@ -587,14 +611,14 @@ msgstr "Failed to delete" msgid "Failed to generate invite" msgstr "Failed to generate invite" -#: src/pages/index/FollowedFeed.tsx:83 +#: src/pages/index/FollowedFeed.tsx:93 #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:375 -#: src/pages/UserPublicProfile.tsx:1062 -#: src/pages/UserPublicProfile.tsx:1104 -#: src/pages/UserPublicProfile.tsx:1149 +#: src/pages/Notifications.tsx:396 +#: src/pages/UserPublicProfile.tsx:1114 +#: src/pages/UserPublicProfile.tsx:1160 +#: src/pages/UserPublicProfile.tsx:1205 msgid "Failed to load" msgstr "Failed to load" @@ -602,37 +626,37 @@ msgstr "Failed to load" msgid "Failed to post" msgstr "Failed to post" -#: src/components/CommentThread.tsx:494 +#: src/components/CommentThread.tsx:503 msgid "Failed to post comment" msgstr "Failed to post comment" -#: src/components/CommentThread.tsx:416 +#: src/components/CommentThread.tsx:425 msgid "Failed to post reply" msgstr "Failed to post reply" -#: src/pages/PlaylistDetail.tsx:917 -#: src/pages/UserPublicProfile.tsx:1767 -#: src/pages/UserPublicProfile.tsx:1840 +#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/UserPublicProfile.tsx:1829 +#: src/pages/UserPublicProfile.tsx:1903 msgid "Failed to save" msgstr "Failed to save" -#: src/components/CategoryManager.tsx:230 +#: src/components/CategoryManager.tsx:231 msgid "Failed to save category" msgstr "Failed to save category" -#: src/components/CommentThread.tsx:348 +#: src/components/CommentThread.tsx:352 msgid "Failed to save edit" msgstr "Failed to save edit" -#: src/pages/UserPublicProfile.tsx:877 +#: src/pages/UserPublicProfile.tsx:914 msgid "Failed to update avatar" msgstr "Failed to update avatar" -#: src/pages/UserPublicProfile.tsx:1705 +#: src/pages/UserPublicProfile.tsx:1767 msgid "Failed to update role" msgstr "Failed to update role" -#: src/pages/UserPublicProfile.tsx:1274 +#: src/pages/UserPublicProfile.tsx:1330 msgid "Feeds" msgstr "Feeds" @@ -657,33 +681,33 @@ msgstr "Follow {targetUsername}" msgid "Follow playlist" msgstr "Follow playlist" -#: src/pages/index/FollowedFeed.tsx:373 +#: src/pages/index/FollowedFeed.tsx:379 msgid "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here." -#: src/pages/index/FollowedFeed.tsx:359 +#: src/pages/index/FollowedFeed.tsx:365 msgid "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here." -#: src/components/FeedTabBar.tsx:34 -#: src/pages/UserPublicProfile.tsx:942 -#: src/pages/UserPublicProfile.tsx:1308 +#: src/components/FeedTabBar.tsx:35 +#: src/pages/UserPublicProfile.tsx:994 +#: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" msgstr "Followed" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:433 +#: src/pages/UserPlaylists.tsx:448 msgid "Followed ({0}{1})" msgstr "Followed ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1093 +#: src/pages/UserPublicProfile.tsx:1149 msgid "Followed playlists" msgstr "Followed playlists" #: src/components/FollowButton.tsx:37 #: src/components/FollowButton.tsx:64 -#: src/pages/UserPublicProfile.tsx:1051 +#: src/pages/UserPublicProfile.tsx:1103 msgid "Following" msgstr "Following" @@ -691,14 +715,18 @@ msgstr "Following" msgid "Forgot password?" msgstr "Forgot password?" -#: src/pages/index/FollowedFeed.tsx:342 +#: src/pages/index/FollowedFeed.tsx:348 msgid "From people" msgstr "From people" -#: src/pages/index/FollowedFeed.tsx:343 +#: src/pages/index/FollowedFeed.tsx:349 msgid "From playlists" msgstr "From playlists" +#: src/components/MediaPlayer.tsx:526 +msgid "Fullscreen" +msgstr "Fullscreen" + #: src/pages/NotFound.tsx:16 msgid "Go home" msgstr "Go home" @@ -707,12 +735,12 @@ msgstr "Go home" msgid "Go to login" msgstr "Go to login" -#: src/components/FeedTabBar.tsx:30 -#: src/pages/UserPublicProfile.tsx:1287 +#: src/components/FeedTabBar.tsx:31 +#: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Hot" -#: src/pages/UserLogin.tsx:122 +#: src/pages/UserLogin.tsx:126 msgid "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly." @@ -720,12 +748,12 @@ msgstr "If that address is registered you'll receive a reset link shortly." msgid "image" msgstr "image" -#: src/pages/Dump.tsx:550 +#: src/pages/Dump.tsx:563 msgid "In collections" msgstr "In collections" -#: src/components/TextEditor.tsx:504 -#: src/components/TextEditor.tsx:506 +#: src/components/TextEditor.tsx:533 +#: src/components/TextEditor.tsx:535 msgid "Insert emoji" msgstr "Insert emoji" @@ -742,17 +770,17 @@ msgstr "Invalid link" msgid "Invite link" msgstr "Invite link" -#: src/pages/UserPublicProfile.tsx:839 +#: src/pages/UserPublicProfile.tsx:868 msgid "invited by" msgstr "invited by" -#: src/pages/UserPublicProfile.tsx:943 -#: src/pages/UserPublicProfile.tsx:1138 +#: src/pages/UserPublicProfile.tsx:995 +#: src/pages/UserPublicProfile.tsx:1194 msgid "Invitees" msgstr "Invitees" -#: src/components/FeedTabBar.tsx:32 -#: src/pages/UserPublicProfile.tsx:1301 +#: src/components/FeedTabBar.tsx:33 +#: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" @@ -760,7 +788,7 @@ msgstr "Journal" msgid "Keep editing" msgstr "Keep editing" -#: src/pages/UserPublicProfile.tsx:1259 +#: src/pages/UserPublicProfile.tsx:1315 msgid "Light" msgstr "Light" @@ -784,8 +812,9 @@ msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates unavailable." msgstr "Live updates unavailable." -#: src/components/UserListPopover.tsx:231 -#: src/pages/Notifications.tsx:448 +#: src/components/UserListPopover.tsx:237 +#: src/pages/Notifications.tsx:469 +#: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Load more" @@ -794,7 +823,7 @@ msgid "Load older messages" msgstr "Load older messages" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:163 +#: src/pages/DumpEdit.tsx:161 msgid "Loading dump…" msgstr "Loading dump…" @@ -802,44 +831,45 @@ msgstr "Loading dump…" msgid "Loading messages…" msgstr "Loading messages…" -#: src/pages/index/FollowedFeed.tsx:111 +#: src/pages/index/FollowedFeed.tsx:121 #: src/pages/index/HotFeed.tsx:64 #: src/pages/index/JournalFeed.tsx:73 #: src/pages/index/NewFeed.tsx:64 -#: src/pages/Search.tsx:244 +#: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 -#: src/pages/UserPlaylists.tsx:420 -#: src/pages/UserPlaylists.tsx:455 -#: src/pages/UserUpvoted.tsx:190 +#: src/pages/UserPlaylists.tsx:435 +#: src/pages/UserPlaylists.tsx:470 +#: src/pages/UserUpvoted.tsx:197 msgid "Loading more…" msgstr "Loading more…" -#: src/pages/PlaylistDetail.tsx:556 +#: src/pages/PlaylistDetail.tsx:587 msgid "Loading playlist…" msgstr "Loading playlist…" -#: src/pages/UserPublicProfile.tsx:773 +#: src/pages/UserPublicProfile.tsx:802 msgid "Loading profile…" msgstr "Loading profile…" #: src/components/CategoryManager.tsx:52 #: src/components/ChatModal.tsx:1192 #: src/components/PlaylistMembershipPanel.tsx:28 -#: src/components/TextEditor.tsx:640 -#: src/components/UserListPopover.tsx:192 -#: src/components/UserListPopover.tsx:230 -#: src/pages/index/FollowedFeed.tsx:78 +#: src/components/TextEditor.tsx:669 +#: src/components/UserListPopover.tsx:198 +#: src/components/UserListPopover.tsx:236 +#: src/pages/index/FollowedFeed.tsx:88 #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:371 -#: src/pages/Notifications.tsx:447 +#: src/pages/Notifications.tsx:392 +#: src/pages/Notifications.tsx:468 #: src/pages/UserDumps.tsx:54 -#: src/pages/UserPlaylists.tsx:345 -#: src/pages/UserPublicProfile.tsx:1056 -#: src/pages/UserPublicProfile.tsx:1098 -#: src/pages/UserPublicProfile.tsx:1143 -#: src/pages/UserUpvoted.tsx:125 +#: src/pages/UserPlaylists.tsx:356 +#: src/pages/UserPublicProfile.tsx:1108 +#: src/pages/UserPublicProfile.tsx:1139 +#: src/pages/UserPublicProfile.tsx:1154 +#: src/pages/UserPublicProfile.tsx:1199 +#: src/pages/UserUpvoted.tsx:132 msgid "Loading…" msgstr "Loading…" @@ -858,8 +888,8 @@ msgstr "Log in to like" msgid "Log in to vote" msgstr "Log in to vote" -#: src/pages/UserPublicProfile.tsx:794 -#: src/pages/UserPublicProfile.tsx:890 +#: src/pages/UserPublicProfile.tsx:823 +#: src/pages/UserPublicProfile.tsx:927 msgid "Log out" msgstr "Log out" @@ -871,7 +901,7 @@ msgstr "Logging in…" msgid "Login failed" msgstr "Login failed" -#: src/pages/UserPublicProfile.tsx:948 +#: src/pages/UserPublicProfile.tsx:1000 msgid "Manage" msgstr "Manage" @@ -879,22 +909,26 @@ msgstr "Manage" msgid "Max 50 MB" msgstr "Max 50 MB" -#: src/pages/UserPublicProfile.tsx:1634 +#: src/pages/UserPublicProfile.tsx:1696 msgid "Moderator" msgstr "Moderator" +#: src/components/MediaPlayer.tsx:499 +msgid "Mute" +msgstr "Mute" + #: src/components/CategoryManager.tsx:178 #: src/components/CategoryManager.tsx:179 msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:364 +#: src/pages/Notifications.tsx:385 msgid "new" msgstr "new" #: src/components/ChatModal.tsx:1121 -#: src/components/FeedTabBar.tsx:31 -#: src/pages/UserPublicProfile.tsx:1294 +#: src/components/FeedTabBar.tsx:32 +#: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "New" @@ -902,7 +936,7 @@ msgstr "New" #: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:1413 +#: src/pages/UserPublicProfile.tsx:1469 msgid "New dump" msgstr "New dump" @@ -924,11 +958,11 @@ msgstr "Next →" msgid "Next track" msgstr "Next track" -#: src/pages/PlaylistDetail.tsx:682 +#: src/pages/PlaylistDetail.tsx:717 msgid "No dumps in this playlist yet." msgstr "No dumps in this playlist yet." -#: src/pages/Search.tsx:224 +#: src/pages/Search.tsx:238 msgid "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"." @@ -938,16 +972,16 @@ msgstr "No dumps match \"{q}\"." msgid "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!" -#: src/components/TextEditor.tsx:643 +#: src/components/TextEditor.tsx:672 msgid "No emoji found." msgstr "No emoji found." -#: src/pages/UserPlaylists.tsx:442 -#: src/pages/UserPublicProfile.tsx:1111 +#: src/pages/UserPlaylists.tsx:457 +#: src/pages/UserPublicProfile.tsx:1167 msgid "No followed playlists yet." msgstr "No followed playlists yet." -#: src/pages/UserPublicProfile.tsx:1156 +#: src/pages/UserPublicProfile.tsx:1212 msgid "No invitees yet." msgstr "No invitees yet." @@ -955,17 +989,17 @@ msgstr "No invitees yet." msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" -#: src/components/UserListPopover.tsx:200 +#: src/components/UserListPopover.tsx:206 msgid "No one yet." msgstr "No one yet." -#: src/pages/Search.tsx:283 +#: src/pages/Search.tsx:297 msgid "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"." #: src/components/PlaylistMembershipPanel.tsx:34 -#: src/pages/UserPlaylists.tsx:400 -#: src/pages/UserPublicProfile.tsx:1022 +#: src/pages/UserPlaylists.tsx:415 +#: src/pages/UserPublicProfile.tsx:1074 msgid "No playlists yet." msgstr "No playlists yet." @@ -973,26 +1007,26 @@ msgstr "No playlists yet." 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" -#: src/pages/Search.tsx:257 +#: src/pages/Search.tsx:271 msgid "No users match \"{q}\"." msgstr "No users match \"{q}\"." -#: src/pages/UserPublicProfile.tsx:1069 +#: src/pages/UserPublicProfile.tsx:1121 msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/pages/Notifications.tsx:382 +#: src/pages/Notifications.tsx:403 #: src/pages/UserDumps.tsx:99 -#: src/pages/UserPublicProfile.tsx:1424 -#: src/pages/UserPublicProfile.tsx:1546 -#: src/pages/UserUpvoted.tsx:159 +#: src/pages/UserPublicProfile.tsx:1480 +#: src/pages/UserPublicProfile.tsx:1608 +#: src/pages/UserUpvoted.tsx:166 msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:266 -#: src/pages/Notifications.tsx:360 -#: src/pages/UserPublicProfile.tsx:1316 +#: src/pages/Notifications.tsx:267 +#: src/pages/Notifications.tsx:381 +#: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" @@ -1000,18 +1034,27 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} unread)" -#: src/pages/UserPublicProfile.tsx:1340 +#: src/pages/UserPublicProfile.tsx:1396 msgid "Off" msgstr "Off" -#: src/pages/UserPublicProfile.tsx:1333 +#: src/pages/UserPublicProfile.tsx:1389 msgid "On" msgstr "On" -#: src/components/TextEditor.tsx:338 +#: src/components/TextEditor.tsx:347 msgid "Only images can be dropped here." msgstr "Only images can be dropped here." +#. placeholder {0}: richContent.siteName +#: src/components/RichContentCard.tsx:101 +msgid "Open on {0}" +msgstr "Open on {0}" + +#: src/components/RichContentCard.tsx:102 +msgid "Open source" +msgstr "Open source" + #: src/components/FileDropZone.tsx:134 msgid "or <0>browse files" msgstr "or <0>browse files" @@ -1021,7 +1064,7 @@ msgid "Page not found" msgstr "Page not found" #: src/pages/UserLogin.tsx:76 -#: src/pages/UserPublicProfile.tsx:1176 +#: src/pages/UserPublicProfile.tsx:1232 msgid "Password" msgstr "Password" @@ -1047,39 +1090,56 @@ msgstr "Passwords do not match" 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/RichContentCard.tsx:83 +msgid "Pause" +msgstr "Pause" + #: src/components/DumpCreateModal.tsx:707 msgid "Picked up where you left off." msgstr "Picked up where you left off." -#: src/pages/PlaylistDetail.tsx:870 +#: src/components/FilePreview.tsx:108 +#: src/components/FilePreview.tsx:213 +#: src/components/FilePreview.tsx:234 +#: src/components/FilePreview.tsx:269 +#: src/components/MediaPlayer.tsx:483 +#: src/components/RichContentCard.tsx:83 +#: src/components/RichContentCard.tsx:129 +msgid "Play" +msgstr "Play" + +#: src/pages/PlaylistDetail.tsx:910 msgid "Playlist title" msgstr "Playlist title" #: src/components/AppHeader.tsx:148 #: src/components/DumpCreateModal.tsx:68 #: src/components/UserMenu.tsx:62 -#: src/pages/Search.tsx:177 -#: src/pages/UserPlaylists.tsx:371 -#: src/pages/UserPublicProfile.tsx:941 +#: src/pages/Search.tsx:191 +#: src/pages/UserPlaylists.tsx:382 +#: src/pages/UserPublicProfile.tsx:993 msgid "Playlists" msgstr "Playlists" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:991 +#: src/pages/UserPublicProfile.tsx:1043 msgid "Playlists ({0}{1})" msgstr "Playlists ({0}{1})" -#: src/components/CommentThread.tsx:492 +#: src/components/CommentThread.tsx:501 msgid "Post comment" msgstr "Post comment" -#: src/components/CommentThread.tsx:414 +#: src/components/CommentThread.tsx:423 msgid "Post reply" msgstr "Post reply" -#: src/components/CommentThread.tsx:415 -#: src/components/CommentThread.tsx:493 +#: src/components/CommentThread.tsx:424 +#: src/components/CommentThread.tsx:502 #: src/components/DumpCreateModal.tsx:958 msgid "Posting…" msgstr "Posting…" @@ -1090,29 +1150,29 @@ msgstr "Previous track" #: src/components/DumpCard.tsx:118 #: src/components/JournalCard.tsx:119 -#: src/components/PlaylistCard.tsx:73 -#: src/components/PlaylistMembershipPanel.tsx:55 -#: src/pages/Dump.tsx:461 -#: src/pages/PlaylistDetail.tsx:646 +#: src/components/PlaylistCard.tsx:72 +#: src/components/PlaylistMembershipPanel.tsx:64 +#: src/pages/Dump.tsx:474 +#: src/pages/PlaylistDetail.tsx:677 msgid "private" msgstr "private" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:911 +#: src/pages/PlaylistDetail.tsx:951 msgid "Private" msgstr "Private" -#: src/components/PlaylistCard.tsx:72 -#: src/pages/PlaylistDetail.tsx:645 +#: src/components/PlaylistCard.tsx:71 +#: src/pages/PlaylistDetail.tsx:676 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:904 +#: src/pages/PlaylistDetail.tsx:944 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:245 +#: src/pages/DumpEdit.tsx:243 msgid "Refresh metadata" msgstr "Refresh metadata" @@ -1120,7 +1180,7 @@ msgstr "Refresh metadata" msgid "Refreshing stream…" msgstr "Refreshing stream…" -#: src/pages/DumpEdit.tsx:244 +#: src/pages/DumpEdit.tsx:242 msgid "Refreshing…" msgstr "Refreshing…" @@ -1138,7 +1198,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:528 +#: src/pages/Dump.tsx:541 msgid "Related" msgstr "Related" @@ -1146,7 +1206,7 @@ msgstr "Related" msgid "Remove file" msgstr "Remove file" -#: src/pages/PlaylistDetail.tsx:737 +#: src/pages/PlaylistDetail.tsx:772 msgid "Remove from playlist" msgstr "Remove from playlist" @@ -1158,13 +1218,13 @@ msgstr "Remove like" msgid "Remove vote" msgstr "Remove vote" -#: src/pages/DumpEdit.tsx:418 +#: src/pages/DumpEdit.tsx:438 msgid "Replace file" msgstr "Replace file" #: src/components/ChatModal.tsx:326 #: src/components/ChatModal.tsx:327 -#: src/components/CommentThread.tsx:374 +#: src/components/CommentThread.tsx:378 msgid "Reply" msgstr "Reply" @@ -1172,7 +1232,7 @@ msgstr "Reply" msgid "Replying to" msgstr "Replying to" -#: src/pages/UserLogin.tsx:134 +#: src/pages/UserLogin.tsx:138 msgid "Request failed" msgstr "Request failed" @@ -1184,8 +1244,8 @@ msgstr "Reset failed" msgid "Reset password" msgstr "Reset password" -#: src/pages/DumpEdit.tsx:399 -#: src/pages/DumpEdit.tsx:448 +#: src/pages/DumpEdit.tsx:414 +#: src/pages/DumpEdit.tsx:468 msgid "Reset to default" msgstr "Reset to default" @@ -1196,37 +1256,38 @@ msgstr "Reset to the suggested title" #: src/components/ChatModal.tsx:519 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:180 +#: src/pages/DumpEdit.tsx:178 msgid "Retry" msgstr "Retry" -#: src/pages/UserPublicProfile.tsx:1689 +#: src/pages/UserPublicProfile.tsx:1751 msgid "Role" msgstr "Role" +#: src/components/CategoryManager.tsx:202 #: src/components/ChatModal.tsx:232 -#: src/components/CommentThread.tsx:346 -#: src/pages/Dump.tsx:394 -#: src/pages/DumpEdit.tsx:486 -#: src/pages/PlaylistDetail.tsx:936 -#: src/pages/UserPublicProfile.tsx:1756 -#: src/pages/UserPublicProfile.tsx:1830 +#: src/components/CommentThread.tsx:350 +#: src/pages/Dump.tsx:401 +#: src/pages/DumpEdit.tsx:509 +#: src/pages/PlaylistDetail.tsx:976 +#: src/pages/UserPublicProfile.tsx:1818 +#: src/pages/UserPublicProfile.tsx:1893 msgid "Save" msgstr "Save" #: src/components/ChangePasswordModal.tsx:103 -#: src/components/CommentThread.tsx:347 -#: src/pages/Dump.tsx:393 -#: src/pages/PlaylistDetail.tsx:932 +#: src/components/CommentThread.tsx:351 +#: src/pages/Dump.tsx:400 +#: src/pages/PlaylistDetail.tsx:972 #: src/pages/ResetPassword.tsx:126 -#: src/pages/UserPublicProfile.tsx:1753 -#: src/pages/UserPublicProfile.tsx:1827 +#: src/pages/UserPublicProfile.tsx:1815 +#: src/pages/UserPublicProfile.tsx:1890 msgid "Saving…" msgstr "Saving…" #: src/components/AppHeader.tsx:127 #: src/components/SearchBar.tsx:73 -#: src/pages/Search.tsx:50 +#: src/pages/Search.tsx:51 msgid "Search" msgstr "Search" @@ -1234,32 +1295,36 @@ msgstr "Search" msgid "Search dumps, users, playlists…" msgstr "Search dumps, users, playlists…" -#: src/components/TextEditor.tsx:612 +#: src/components/TextEditor.tsx:641 msgid "Search emoji" msgstr "Search emoji" -#: src/components/TextEditor.tsx:611 +#: src/components/TextEditor.tsx:640 msgid "Search emoji…" msgstr "Search emoji…" -#: src/pages/Search.tsx:218 +#: src/pages/Search.tsx:232 msgid "Search failed" msgstr "Search failed" -#: src/pages/Search.tsx:213 +#: src/pages/Search.tsx:227 msgid "Searching…" msgstr "Searching…" +#: src/components/MediaPlayer.tsx:179 +msgid "Seek" +msgstr "Seek" + #: src/components/ChatModal.tsx:1296 msgid "Send" msgstr "Send" -#: src/pages/UserLogin.tsx:143 +#: src/pages/UserLogin.tsx:147 msgid "Send reset link" msgstr "Send reset link" #: src/components/ChatModal.tsx:537 -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:146 msgid "Sending…" msgstr "Sending…" @@ -1272,7 +1337,7 @@ msgstr "Server unreachable" msgid "Set new password" msgstr "Set new password" -#: src/pages/UserPublicProfile.tsx:945 +#: src/pages/UserPublicProfile.tsx:997 msgid "Settings" msgstr "Settings" @@ -1288,17 +1353,18 @@ msgstr "Single use, valid for 7 days." msgid "slug" msgstr "slug" -#: src/components/TextEditor.tsx:297 +#: src/components/TextEditor.tsx:306 msgid "Some images couldn't be uploaded." msgstr "Some images couldn't be uploaded." #: src/components/form/FormError.tsx:15 #: src/components/PageError.tsx:13 #: src/contexts/WSProvider.tsx:663 +#: src/pages/PlaylistDetail.tsx:711 msgid "Something went wrong" msgstr "Something went wrong" -#: src/pages/UserPublicProfile.tsx:1321 +#: src/pages/UserPublicProfile.tsx:1377 msgid "Sounds" msgstr "Sounds" @@ -1306,7 +1372,7 @@ msgstr "Sounds" msgid "Start fresh" msgstr "Start fresh" -#: src/pages/UserPublicProfile.tsx:1195 +#: src/pages/UserPublicProfile.tsx:1251 msgid "Style" msgstr "Style" @@ -1314,14 +1380,22 @@ msgstr "Style" msgid "Submit search" msgstr "Submit search" -#: src/pages/Notifications.tsx:220 +#: src/pages/Notifications.tsx:221 msgid "the chat" msgstr "the chat" -#: src/components/TextEditor.tsx:298 +#: src/pages/PlaylistDetail.tsx:505 +msgid "The dump couldn't be removed. Try again." +msgstr "The dump couldn't be removed. Try again." + +#: src/components/TextEditor.tsx:307 msgid "The image couldn't be uploaded." msgstr "The image couldn't be uploaded." +#: src/components/CommentThread.tsx:416 +msgid "This comment couldn't be deleted. Try again." +msgstr "This comment couldn't be deleted. Try again." + #: src/pages/UserRegister.tsx:97 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." @@ -1338,13 +1412,13 @@ 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:383 +#: src/pages/DumpEdit.tsx:398 msgid "Thumbnail" msgstr "Thumbnail" #: src/components/DumpCreateModal.tsx:174 #: src/components/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:438 +#: src/pages/DumpEdit.tsx:458 msgid "Title" msgstr "Title" @@ -1353,7 +1427,7 @@ msgid "Title is required." msgstr "Title is required." #: src/components/ChatModal.tsx:147 -#: src/pages/Notifications.tsx:398 +#: src/pages/Notifications.tsx:419 msgid "Today" msgstr "Today" @@ -1370,7 +1444,7 @@ msgstr "Try again" msgid "Type a message…" msgstr "Type a message…" -#: src/pages/PlaylistDetail.tsx:749 +#: src/pages/PlaylistDetail.tsx:784 msgid "Undo" msgstr "Undo" @@ -1382,7 +1456,11 @@ msgstr "Unfollow {targetUsername}" msgid "Unfollow playlist" msgstr "Unfollow playlist" -#: src/pages/UserPublicProfile.tsx:744 +#: src/components/MediaPlayer.tsx:499 +msgid "Unmute" +msgstr "Unmute" + +#: src/pages/UserPublicProfile.tsx:773 msgid "Upload failed" msgstr "Upload failed" @@ -1391,7 +1469,7 @@ msgstr "Upload failed" msgid "Uploading {0}%" msgstr "Uploading {0}%" -#: src/components/TextEditor.tsx:526 +#: src/components/TextEditor.tsx:555 msgid "Uploading image…" msgstr "Uploading image…" @@ -1403,17 +1481,17 @@ msgstr "Uploading…" msgid "Upvote" msgstr "Upvote" -#: src/pages/UserUpvoted.tsx:154 +#: src/pages/UserUpvoted.tsx:161 msgid "Upvoted" msgstr "Upvoted" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:971 +#: src/pages/UserPublicProfile.tsx:1023 msgid "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})" -#: src/pages/DumpEdit.tsx:410 +#: src/pages/DumpEdit.tsx:430 msgid "URL" msgstr "URL" @@ -1425,11 +1503,11 @@ msgstr "URL is required." msgid "URL slug" msgstr "URL slug" -#: src/pages/UserPublicProfile.tsx:1633 +#: src/pages/UserPublicProfile.tsx:1695 msgid "User" msgstr "User" -#: src/pages/UserPublicProfile.tsx:1352 +#: src/pages/UserPublicProfile.tsx:1408 msgid "User management" msgstr "User management" @@ -1442,15 +1520,14 @@ msgstr "User menu" msgid "Username" msgstr "Username" -#: src/pages/Search.tsx:176 +#: src/pages/Search.tsx:190 msgid "Users" msgstr "Users" -#: src/pages/UserPublicProfile.tsx:1041 -#: src/pages/UserPublicProfile.tsx:1084 -#: src/pages/UserPublicProfile.tsx:1126 -#: src/pages/UserPublicProfile.tsx:1445 -#: src/pages/UserPublicProfile.tsx:1576 +#: src/pages/UserPublicProfile.tsx:1093 +#: src/pages/UserPublicProfile.tsx:1182 +#: src/pages/UserPublicProfile.tsx:1501 +#: src/pages/UserPublicProfile.tsx:1638 msgid "View all →" msgstr "View all →" @@ -1458,13 +1535,17 @@ msgstr "View all →" msgid "View dump →" msgstr "View dump →" +#: src/components/MediaPlayer.tsx:516 +msgid "Volume" +msgstr "Volume" + #: src/components/DumpCreateModal.tsx:912 -#: src/pages/DumpEdit.tsx:459 +#: src/pages/DumpEdit.tsx:479 msgid "What makes it worth it?" msgstr "What makes it worth it?" -#: src/pages/UserPublicProfile.tsx:925 -#: src/pages/UserPublicProfile.tsx:1819 +#: src/pages/UserPublicProfile.tsx:977 +#: src/pages/UserPublicProfile.tsx:1882 msgid "Who am I?" msgstr "Who am I?" @@ -1477,16 +1558,16 @@ msgid "Why & where" msgstr "Why & where" #: src/components/DumpCreateModal.tsx:911 -#: src/pages/DumpEdit.tsx:458 +#: src/pages/DumpEdit.tsx:478 msgid "Why?" msgstr "Why?" -#: src/components/CommentThread.tsx:418 +#: src/components/CommentThread.tsx:427 msgid "Write a reply…" msgstr "Write a reply…" #: src/components/ChatModal.tsx:150 -#: src/pages/Notifications.tsx:400 +#: src/pages/Notifications.tsx:421 msgid "Yesterday" msgstr "Yesterday" @@ -1494,7 +1575,7 @@ msgstr "Yesterday" msgid "You can post it anyway." msgstr "You can post it anyway." -#: src/pages/Notifications.tsx:385 +#: src/pages/Notifications.tsx:406 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." @@ -1502,19 +1583,19 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum msgid "You're offline — not sent" msgstr "You're offline — not sent" -#: src/pages/index/FollowedFeed.tsx:116 +#: src/pages/index/FollowedFeed.tsx:126 #: src/pages/index/HotFeed.tsx:69 #: src/pages/index/JournalFeed.tsx:78 #: src/pages/index/NewFeed.tsx:69 -#: src/pages/Search.tsx:249 +#: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 -#: src/pages/UserPlaylists.tsx:425 -#: src/pages/UserPlaylists.tsx:460 -#: src/pages/UserUpvoted.tsx:195 +#: src/pages/UserPlaylists.tsx:440 +#: src/pages/UserPlaylists.tsx:475 +#: src/pages/UserUpvoted.tsx:202 msgid "You've reached the end." msgstr "You've reached the end." -#: src/pages/UserLogin.tsx:138 +#: src/pages/UserLogin.tsx:142 msgid "Your email address" msgstr "Your email address" diff --git a/src/locales/fr.js b/src/locales/fr.js index 30f4907b..677c544a 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1 +1 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2ygf_L\":[\"← Retour\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"9xDZu_\":[\"Impossible de charger.\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AnRu_j\":[\"Annuler la réponse\"],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Inscription…\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Chargement des messages…\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"f_akpP\":[\"Max 50 Mo\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jbernk\":[\"Chargement du profil…\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pvnfJD\":[\"Sombre\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"wbXKOv\":[\"Fichier trop volumineux (max 50 Mo).\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}")}; \ No newline at end of file +/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1QfxQT\":[\"Fermer\"],\"1YKhJ3\":[\"1 nouveau message\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2ygf_L\":[\"← Retour\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3qkggm\":[\"Plein écran\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"48e2R8\":[\"Modifier l'e-mail\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4OjqAQ\":[\"Continuer l'édition\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8-8abB\":[\"Déconnecté — reconnexion…\"],\"8F1i42\":[\"Page introuvable\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"9xDZu_\":[\"Impossible de charger.\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AEfu-D\":[\"Dans les collections\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"AS7WoE\":[\"Repartir de zéro\"],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AhNP40\":[\"Position\"],\"AnRu_j\":[\"Annuler la réponse\"],\"B4LeYP\":[\"Échec de la suppression (\",[\"0\"],\")\"],\"BjitBo\":[\"Télécharger \",[\"0\"]],\"BlpJ2G\":[\"Reprise de votre brouillon.\"],\"CI50ct\":[\"Voté\"],\"C_qQE4\":[\"Déjà recommandé par \",[\"0\"],\" \",[\"1\"]],\"Cj24wt\":[\"Inscription…\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DI3NBO\":[\"Ouvrir sur \",[\"0\"]],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"EBU6O6\":[\"Connexion perdue — ce message n'a peut-être pas été envoyé\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"EarrCe\":[\"Rechercher un emoji\"],\"Evcm2Z\":[\"Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé.\"],\"EwVrWF\":[\"Le dump n'a pas pu être retiré. Réessayez.\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FdTlEG\":[\"Piste \",[\"0\"]],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GCWCCo\":[\"Fermer le lecteur\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GY6uSc\":[\"Suivant →\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"H4MGkI\":[\"Agrandir le lecteur\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"H_PnT8\":[\"Insérer un emoji\"],\"I-x669\":[\"Invités\"],\"IO9KL_\":[\"Piste suivante\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"IgrLD_\":[\"Pause\"],\"ImOQa9\":[\"Répondre\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"KDw4GX\":[\"Réessayer\"],\"KKZxBU\":[\"Envoi \",[\"0\"],\" %\"],\"K_F6pa\":[\"Enregistrement…\"],\"KtYF-t\":[\"Sons\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"L7_rsx\":[\"Envoi de l'image…\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"O32mmL\":[\"Modifier la description\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"PN3pBl\":[\"Pas de réponse du serveur — ce message n'a peut-être pas été envoyé\"],\"PUe8Yk\":[\"Réduire le lecteur\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q4WUjR\":[\"Rechercher un emoji…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"R7SsBE\":[\"Couper le son\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"RvVJBH\":[\"Actualisation du flux…\"],\"S1xV5B\":[[\"maxLength\"],\" caractères au maximum.\"],\"SBTElJ\":[\"Recherche…\"],\"Sad2tK\":[\"Envoi…\"],\"So7gCD\":[\"Collez un lien…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T8ejtZ\":[\"Ce commentaire n'a pas pu être supprimé. Réessayez.\"],\"T91vKp\":[\"Lecture\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TAtbho\":[\"Chargement des messages…\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W9FRBT\":[\"Aimer\"],\"WAcnnR\":[\"L'image n'a pas pu être envoyée.\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XHqhoS\":[\"Impossible de charger le chat.\"],\"XJVYo8\":[\"Pourquoi & où\"],\"XJy2oN\":[\"Connexion…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YZxXK8\":[\"Fermer le sélecteur d'emoji\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"Z5HWHd\":[\"Activés\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZWxjuD\":[\"Certaines images n'ont pas pu être envoyées.\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_cF7Rs\":[\"Volume\"],\"_karOe\":[\"Hors ligne — message non envoyé\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"az8lvo\":[\"Désactivés\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"bzSI52\":[\"Abandonner\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cNu4my\":[[\"0\"],\" de plus en ligne : \",[\"restNames\"]],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"dau1Pl\":[[\"unseenCount\"],\" nouveaux messages\"],\"dqwCfR\":[\"Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard.\"],\"dznB-Y\":[\"Seules les images peuvent être déposées ici.\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eIEHHV\":[\"Changer la couleur de peau\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"eloqXQ\":[\"Vous pouvez la publier quand même.\"],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"f_akpP\":[\"Max 50 Mo\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gL9KKi\":[\"Qui peut la voir\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"goWEhR\":[\"Piste précédente\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hT8hgO\":[\"Rétablir le titre suggéré\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"hpCc_Z\":[\"Fermer quand même\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jbernk\":[\"Chargement du profil…\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"k4b5_X\":[\"modifié\"],\"kLttbL\":[\"Inscription échouée\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"mb_Wju\":[\"Déposez un fichier ici, ou collez-en un\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"oTYvvN\":[[\"0\",\"plural\",{\"one\":[\"et \",\"#\",\" reco plus ancienne\"],\"other\":[\"et \",\"#\",\" recos plus anciennes\"]}]],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pvnfJD\":[\"Sombre\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qsqt0w\":[\"Lien d'invitation\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rn6SBY\":[\"Rétablir le son\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"sEXJPi\":[\"Lien ou fichier\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"sgX7B-\":[\"Usage unique, valable 7 jours.\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"teaneZ\":[\"image\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uISnsY\":[\"Suppression impossible\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"v-Wp--\":[\"Ouvrir la source\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"wbXKOv\":[\"Fichier trop volumineux (max 50 Mo).\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yz7wBu\":[\"Fermer\"],\"yzF66j\":[\"Lien\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}")}; \ No newline at end of file diff --git a/src/locales/fr.po b/src/locales/fr.po index 1f22423d..cb47ef6e 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -13,7 +13,7 @@ msgstr "" "Last-Translator: \n" "Language-Team: \n" -#: src/components/CommentThread.tsx:270 +#: src/components/CommentThread.tsx:274 msgid "[deleted]" msgstr "[supprimé]" @@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}" #. placeholder {0}: playlist.dumpCount -#: src/components/PlaylistCard.tsx:86 +#: src/components/PlaylistCard.tsx:85 msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" @@ -55,7 +55,7 @@ msgstr "{0} de plus en ligne : {restNames}" msgid "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" -#: src/pages/Search.tsx:178 +#: src/pages/Search.tsx:192 msgid "{label} ({count})" msgstr "{label} ({count})" @@ -63,25 +63,25 @@ msgstr "{label} ({count})" msgid "{unseenCount} new messages" msgstr "{unseenCount} nouveaux messages" -#: src/components/CommentThread.tsx:475 +#: src/components/CommentThread.tsx:484 msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" #: src/components/DumpCreateModal.tsx:942 -#: src/pages/PlaylistDetail.tsx:572 -#: src/pages/UserPublicProfile.tsx:790 +#: src/pages/PlaylistDetail.tsx:603 +#: src/pages/UserPublicProfile.tsx:819 msgid "← Back" msgstr "← Retour" #: src/pages/Dump.tsx:290 -#: src/pages/Dump.tsx:520 -#: src/pages/DumpEdit.tsx:187 +#: src/pages/Dump.tsx:533 +#: src/pages/DumpEdit.tsx:185 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" #: src/pages/UserDumps.tsx:66 -#: src/pages/UserPlaylists.tsx:357 -#: src/pages/UserUpvoted.tsx:137 +#: src/pages/UserPlaylists.tsx:368 +#: src/pages/UserUpvoted.tsx:144 msgid "← Back to profile" msgstr "← Retour au profil" @@ -89,57 +89,57 @@ msgstr "← Retour au profil" msgid "+ Invite someone" msgstr "+ Inviter quelqu'un" -#: src/components/PlaylistMembershipPanel.tsx:81 +#: src/components/PlaylistMembershipPanel.tsx:90 msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:361 +#: src/pages/Dump.tsx:368 msgid "+ Playlist" msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:197 +#: src/pages/Notifications.tsx:198 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:157 +#: src/pages/Notifications.tsx:158 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:207 +#: src/pages/Notifications.tsx:208 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:223 +#: src/pages/Notifications.tsx:224 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:167 +#: src/pages/Notifications.tsx:168 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:148 +#: src/pages/Notifications.tsx:149 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:187 +#: src/pages/Notifications.tsx:188 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:177 +#: src/pages/Notifications.tsx:178 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" @@ -147,19 +147,19 @@ msgstr "<0>{0} a été ajouté à <1>{1}" msgid "1 new message" msgstr "1 nouveau message" -#: src/pages/Notifications.tsx:218 +#: src/pages/Notifications.tsx:219 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:221 +#: src/pages/Notifications.tsx:222 msgid "a post" msgstr "une publication" -#: src/pages/UserPublicProfile.tsx:1171 +#: src/pages/UserPublicProfile.tsx:1227 msgid "Account" msgstr "Compte" -#: src/components/CommentThread.tsx:495 +#: src/components/CommentThread.tsx:504 msgid "Add a comment…" msgstr "Ajouter un commentaire…" @@ -168,7 +168,7 @@ msgstr "Ajouter un commentaire…" msgid "Add category" msgstr "Ajouter une catégorie" -#: src/pages/UserPublicProfile.tsx:868 +#: src/pages/UserPublicProfile.tsx:905 msgid "Add email…" msgstr "Ajouter un e-mail…" @@ -177,7 +177,7 @@ msgstr "Ajouter un e-mail…" msgid "Add to playlist" msgstr "Ajouter à la collection" -#: src/pages/UserPublicProfile.tsx:1635 +#: src/pages/UserPublicProfile.tsx:1697 msgid "Admin" msgstr "Administrateur" @@ -196,7 +196,7 @@ msgstr "Déjà recommandé par {0} {1}" msgid "Already have an account? <0>Log in" msgstr "Vous avez déjà un compte ? <0>Se connecter" -#: src/pages/UserPublicProfile.tsx:1190 +#: src/pages/UserPublicProfile.tsx:1246 msgid "Appearance" msgstr "Apparence" @@ -211,7 +211,7 @@ msgstr "Au moins {0} caractères" msgid "At most {maxLength} characters." msgstr "{maxLength} caractères au maximum." -#: src/pages/UserPublicProfile.tsx:1252 +#: src/pages/UserPublicProfile.tsx:1308 msgid "Auto" msgstr "Auto" @@ -225,22 +225,22 @@ msgstr "Retour à la connexion" 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:210 #: src/components/CategoryManager.tsx:211 +#: src/components/CategoryManager.tsx:212 #: src/components/ChatModal.tsx:235 #: src/components/CommentThread.tsx:129 -#: src/components/ConfirmModal.tsx:34 +#: src/components/ConfirmModal.tsx:47 #: src/components/DumpCreateModal.tsx:952 #: src/components/form/FormActions.tsx:32 -#: src/pages/Dump.tsx:402 -#: src/pages/DumpEdit.tsx:483 -#: src/pages/PlaylistDetail.tsx:929 -#: src/pages/UserPublicProfile.tsx:1764 -#: src/pages/UserPublicProfile.tsx:1838 +#: src/pages/Dump.tsx:409 +#: src/pages/DumpEdit.tsx:506 +#: src/pages/PlaylistDetail.tsx:969 +#: src/pages/UserPublicProfile.tsx:1826 +#: src/pages/UserPublicProfile.tsx:1901 msgid "Cancel" msgstr "Annuler" -#: src/pages/PlaylistDetail.tsx:747 +#: src/pages/PlaylistDetail.tsx:782 msgid "Cancel removal" msgstr "Annuler la suppression" @@ -254,7 +254,7 @@ msgid "Cancel search" msgstr "Annuler la recherche" #: src/components/CategorySelect.tsx:22 -#: src/pages/UserPublicProfile.tsx:1367 +#: src/pages/UserPublicProfile.tsx:1423 msgid "Categories" msgstr "Catégories" @@ -262,7 +262,7 @@ msgstr "Catégories" msgid "Category" msgstr "Catégorie" -#: src/pages/UserPublicProfile.tsx:817 +#: src/pages/UserPublicProfile.tsx:846 msgid "Change avatar" msgstr "Changer l'avatar" @@ -271,11 +271,11 @@ msgstr "Changer l'avatar" msgid "Change password" msgstr "Changer le mot de passe" -#: src/pages/UserPublicProfile.tsx:1183 +#: src/pages/UserPublicProfile.tsx:1239 msgid "Change password…" msgstr "Changer le mot de passe…" -#: src/components/TextEditor.tsx:619 +#: src/components/TextEditor.tsx:648 msgid "Change skin tone" msgstr "Changer la couleur de peau" @@ -296,7 +296,7 @@ msgid "Checking invite…" msgstr "Vérification de l'invitation…" #: src/components/ChangePasswordModal.tsx:59 -#: src/components/Modal.tsx:54 +#: src/components/Modal.tsx:60 msgid "Close" msgstr "Fermer" @@ -304,7 +304,7 @@ msgstr "Fermer" msgid "Close anyway" msgstr "Fermer quand même" -#: src/components/TextEditor.tsx:628 +#: src/components/TextEditor.tsx:657 msgid "Close emoji picker" msgstr "Fermer le sélecteur d'emoji" @@ -320,7 +320,7 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez msgid "Collapse player" msgstr "Réduire le lecteur" -#: src/pages/UserPublicProfile.tsx:1244 +#: src/pages/UserPublicProfile.tsx:1300 msgid "Color scheme" msgstr "Thème de couleur" @@ -345,11 +345,16 @@ msgstr "Copier" msgid "Could not change password" msgstr "Impossible de changer le mot de passe" +#: src/pages/DumpEdit.tsx:494 +#: src/pages/UserPlaylists.tsx:400 +msgid "Could not delete" +msgstr "Suppression impossible" + #: src/components/UserListPopover.tsx:141 msgid "Could not load." msgstr "Impossible de charger." -#: src/pages/DumpEdit.tsx:434 +#: src/pages/DumpEdit.tsx:454 msgid "Could not save" msgstr "Sauvegarde impossible" @@ -371,7 +376,7 @@ msgstr "Créer et ajouter" #. placeholder {0}: created.items.length #. placeholder {1}: created.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:391 +#: src/pages/UserPlaylists.tsx:406 msgid "Created ({0}{1})" msgstr "Créées ({0}{1})" @@ -383,47 +388,52 @@ msgstr "Création…" msgid "Current password" msgstr "Mot de passe actuel" -#: src/pages/UserPublicProfile.tsx:1266 +#: src/pages/UserPublicProfile.tsx:1322 msgid "Dark" msgstr "Sombre" -#: src/pages/UserPublicProfile.tsx:1279 +#: src/pages/UserPublicProfile.tsx:1335 msgid "Default tab" msgstr "Onglet par défaut" -#: src/components/CategoryManager.tsx:221 #: src/components/CategoryManager.tsx:222 +#: src/components/CategoryManager.tsx:223 #: src/components/ChatModal.tsx:347 #: src/components/ChatModal.tsx:348 #: src/components/ChatModal.tsx:446 -#: src/components/CommentThread.tsx:395 -#: src/components/CommentThread.tsx:401 +#: src/components/CommentThread.tsx:399 +#: src/components/CommentThread.tsx:405 #: src/components/ConfirmModal.tsx:17 -#: src/pages/PlaylistDetail.tsx:925 +#: src/pages/PlaylistDetail.tsx:965 msgid "Delete" msgstr "Supprimer" -#: src/components/CategoryManager.tsx:236 +#: src/components/CategoryManager.tsx:237 msgid "Delete category" msgstr "Supprimer la catégorie" #. placeholder {0}: category.name -#: src/components/CategoryManager.tsx:235 +#: src/components/CategoryManager.tsx:236 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:479 +#: src/pages/DumpEdit.tsx:502 msgid "Delete dump" msgstr "Supprimer la reco" -#: src/components/PlaylistCard.tsx:109 -#: src/pages/PlaylistDetail.tsx:760 -#: src/pages/UserPlaylists.tsx:468 +#. placeholder {0}: res.status +#: src/pages/DumpEdit.tsx:148 +msgid "Delete failed ({0})" +msgstr "Échec de la suppression ({0})" + +#: src/components/PlaylistCard.tsx:108 +#: src/pages/PlaylistDetail.tsx:795 +#: src/pages/UserPlaylists.tsx:483 msgid "Delete playlist" msgstr "Supprimer la collection" -#: src/components/CommentThread.tsx:400 +#: src/components/CommentThread.tsx:404 msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" @@ -435,8 +445,8 @@ msgstr "Supprimer cette reco ? Cette action est irréversible." msgid "Delete this message?" msgstr "Supprimer ce message ?" -#: src/pages/PlaylistDetail.tsx:759 -#: src/pages/UserPlaylists.tsx:467 +#: src/pages/PlaylistDetail.tsx:794 +#: src/pages/UserPlaylists.tsx:482 msgid "Delete this playlist? This cannot be undone." msgstr "Supprimer cette collection ? Cette action est irréversible." @@ -445,7 +455,7 @@ msgid "deleted message" msgstr "message supprimé" #: src/components/PlaylistCreateForm.tsx:82 -#: src/pages/PlaylistDetail.tsx:886 +#: src/pages/PlaylistDetail.tsx:926 msgid "Description (optional)" msgstr "Description (facultatif)" @@ -466,6 +476,11 @@ msgstr "Fermer" msgid "Done" msgstr "Terminé" +#. placeholder {0}: dump.fileName +#: src/components/FilePreview.tsx:317 +msgid "Download {0}" +msgstr "Télécharger {0}" + #: src/components/FileDropZone.tsx:29 msgid "Drop a file here" msgstr "Déposez un fichier ici" @@ -474,7 +489,7 @@ msgstr "Déposez un fichier ici" msgid "Drop a file here, or paste one" msgstr "Déposez un fichier ici, ou collez-en un" -#: src/pages/DumpEdit.tsx:426 +#: src/pages/DumpEdit.tsx:446 msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" @@ -490,28 +505,28 @@ msgstr "Recommander" msgid "Dumped!" msgstr "Recommandé !" -#: src/pages/Search.tsx:174 +#: src/pages/Search.tsx:188 #: src/pages/UserDumps.tsx:80 -#: src/pages/UserPublicProfile.tsx:940 +#: src/pages/UserPublicProfile.tsx:992 msgid "Dumps" msgstr "Recos" #. placeholder {0}: dumps.items.length #. placeholder {1}: dumps.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:960 +#: src/pages/UserPublicProfile.tsx:1012 msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" -#: src/pages/Notifications.tsx:401 +#: src/pages/Notifications.tsx:422 msgid "Earlier" msgstr "Plus tôt" #: src/components/ChatModal.tsx:336 #: src/components/ChatModal.tsx:337 #: src/components/ChatModal.tsx:526 -#: src/components/CommentThread.tsx:386 -#: src/pages/Dump.tsx:516 -#: src/pages/PlaylistDetail.tsx:627 +#: src/components/CommentThread.tsx:390 +#: src/pages/Dump.tsx:529 +#: src/pages/PlaylistDetail.tsx:658 msgid "Edit" msgstr "Modifier" @@ -520,7 +535,16 @@ msgstr "Modifier" msgid "Edit {0}" msgstr "Modifier {0}" +#: src/pages/UserPublicProfile.tsx:956 +msgid "Edit description" +msgstr "Modifier la description" + +#: src/pages/UserPublicProfile.tsx:903 +msgid "Edit email" +msgstr "Modifier l'e-mail" + #: src/components/DumpCreateModal.tsx:197 +#: src/pages/Dump.tsx:425 msgid "Edit title" msgstr "Modifier le titre" @@ -532,9 +556,9 @@ msgstr "modifié" #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) #: src/components/ChatModal.tsx:361 -#: src/components/CommentThread.tsx:335 -#: src/pages/Dump.tsx:455 -#: src/pages/PlaylistDetail.tsx:666 +#: src/components/CommentThread.tsx:339 +#: src/pages/Dump.tsx:468 +#: src/pages/PlaylistDetail.tsx:697 msgid "edited {0}" msgstr "modifié {0}" @@ -542,18 +566,18 @@ msgstr "modifié {0}" #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() #: src/components/ChatModal.tsx:357 -#: src/components/CommentThread.tsx:333 -#: src/pages/Dump.tsx:453 -#: src/pages/PlaylistDetail.tsx:663 +#: src/components/CommentThread.tsx:337 +#: src/pages/Dump.tsx:466 +#: src/pages/PlaylistDetail.tsx:694 msgid "Edited {0}" msgstr "Modifié le {0}" -#: src/pages/DumpEdit.tsx:212 +#: src/pages/DumpEdit.tsx:210 msgid "Editing" msgstr "Modification" #. placeholder {0}: state.dump.title -#: src/pages/DumpEdit.tsx:48 +#: src/pages/DumpEdit.tsx:49 msgid "Editing {0}" msgstr "Édition de {0}" @@ -561,7 +585,7 @@ msgstr "Édition de {0}" msgid "Email address" msgstr "Adresse e-mail" -#: src/pages/Search.tsx:207 +#: src/pages/Search.tsx:221 msgid "Enter a query to search." msgstr "Saisissez une recherche." @@ -569,7 +593,7 @@ msgstr "Saisissez une recherche." msgid "Expand player" msgstr "Agrandir le lecteur" -#: src/components/CategoryManager.tsx:230 +#: src/components/CategoryManager.tsx:231 msgid "Failed to create category" msgstr "Échec de la création de la catégorie" @@ -587,14 +611,14 @@ msgstr "Suppression échouée" msgid "Failed to generate invite" msgstr "Impossible de générer une invitation" -#: src/pages/index/FollowedFeed.tsx:83 +#: src/pages/index/FollowedFeed.tsx:93 #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:375 -#: src/pages/UserPublicProfile.tsx:1062 -#: src/pages/UserPublicProfile.tsx:1104 -#: src/pages/UserPublicProfile.tsx:1149 +#: src/pages/Notifications.tsx:396 +#: src/pages/UserPublicProfile.tsx:1114 +#: src/pages/UserPublicProfile.tsx:1160 +#: src/pages/UserPublicProfile.tsx:1205 msgid "Failed to load" msgstr "Chargement échoué" @@ -602,37 +626,37 @@ msgstr "Chargement échoué" msgid "Failed to post" msgstr "Publication échouée" -#: src/components/CommentThread.tsx:494 +#: src/components/CommentThread.tsx:503 msgid "Failed to post comment" msgstr "Impossible de publier le commentaire" -#: src/components/CommentThread.tsx:416 +#: src/components/CommentThread.tsx:425 msgid "Failed to post reply" msgstr "Impossible de publier la réponse" -#: src/pages/PlaylistDetail.tsx:917 -#: src/pages/UserPublicProfile.tsx:1767 -#: src/pages/UserPublicProfile.tsx:1840 +#: src/pages/PlaylistDetail.tsx:957 +#: src/pages/UserPublicProfile.tsx:1829 +#: src/pages/UserPublicProfile.tsx:1903 msgid "Failed to save" msgstr "Enregistrement échoué" -#: src/components/CategoryManager.tsx:230 +#: src/components/CategoryManager.tsx:231 msgid "Failed to save category" msgstr "Échec de l’enregistrement de la catégorie" -#: src/components/CommentThread.tsx:348 +#: src/components/CommentThread.tsx:352 msgid "Failed to save edit" msgstr "Impossible d'enregistrer la modification" -#: src/pages/UserPublicProfile.tsx:877 +#: src/pages/UserPublicProfile.tsx:914 msgid "Failed to update avatar" msgstr "Impossible de mettre à jour l'avatar" -#: src/pages/UserPublicProfile.tsx:1705 +#: src/pages/UserPublicProfile.tsx:1767 msgid "Failed to update role" msgstr "Erreur lors de la mise à jour du rôle" -#: src/pages/UserPublicProfile.tsx:1274 +#: src/pages/UserPublicProfile.tsx:1330 msgid "Feeds" msgstr "Flux" @@ -657,33 +681,33 @@ msgstr "Suivre {targetUsername}" msgid "Follow playlist" msgstr "Suivre la collection" -#: src/pages/index/FollowedFeed.tsx:373 +#: src/pages/index/FollowedFeed.tsx:379 msgid "Follow some public playlists to see their dumps here." msgstr "Suivez des collections publiques pour voir leurs recos ici." -#: src/pages/index/FollowedFeed.tsx:359 +#: src/pages/index/FollowedFeed.tsx:365 msgid "Follow some users to see their dumps here." msgstr "Suivez des utilisateurs pour voir leurs recos ici." -#: src/components/FeedTabBar.tsx:34 -#: src/pages/UserPublicProfile.tsx:942 -#: src/pages/UserPublicProfile.tsx:1308 +#: src/components/FeedTabBar.tsx:35 +#: src/pages/UserPublicProfile.tsx:994 +#: src/pages/UserPublicProfile.tsx:1364 msgid "Followed" msgstr "Suivi" #. placeholder {0}: followed.items.length #. placeholder {1}: followed.hasMore ? "+" : "" -#: src/pages/UserPlaylists.tsx:433 +#: src/pages/UserPlaylists.tsx:448 msgid "Followed ({0}{1})" msgstr "Suivies ({0}{1})" -#: src/pages/UserPublicProfile.tsx:1093 +#: src/pages/UserPublicProfile.tsx:1149 msgid "Followed playlists" msgstr "Collections suivies" #: src/components/FollowButton.tsx:37 #: src/components/FollowButton.tsx:64 -#: src/pages/UserPublicProfile.tsx:1051 +#: src/pages/UserPublicProfile.tsx:1103 msgid "Following" msgstr "Abonné" @@ -691,14 +715,18 @@ msgstr "Abonné" msgid "Forgot password?" msgstr "Mot de passe oublié ?" -#: src/pages/index/FollowedFeed.tsx:342 +#: src/pages/index/FollowedFeed.tsx:348 msgid "From people" msgstr "De personnes" -#: src/pages/index/FollowedFeed.tsx:343 +#: src/pages/index/FollowedFeed.tsx:349 msgid "From playlists" msgstr "De collections" +#: src/components/MediaPlayer.tsx:526 +msgid "Fullscreen" +msgstr "Plein écran" + #: src/pages/NotFound.tsx:16 msgid "Go home" msgstr "Accueil" @@ -707,12 +735,12 @@ msgstr "Accueil" msgid "Go to login" msgstr "Aller à la connexion" -#: src/components/FeedTabBar.tsx:30 -#: src/pages/UserPublicProfile.tsx:1287 +#: src/components/FeedTabBar.tsx:31 +#: src/pages/UserPublicProfile.tsx:1343 msgid "Hot" msgstr "Tendances" -#: src/pages/UserLogin.tsx:122 +#: src/pages/UserLogin.tsx:126 msgid "If that address is registered you'll receive a reset link shortly." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." @@ -720,12 +748,12 @@ msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialis msgid "image" msgstr "image" -#: src/pages/Dump.tsx:550 +#: src/pages/Dump.tsx:563 msgid "In collections" msgstr "Dans les collections" -#: src/components/TextEditor.tsx:504 -#: src/components/TextEditor.tsx:506 +#: src/components/TextEditor.tsx:533 +#: src/components/TextEditor.tsx:535 msgid "Insert emoji" msgstr "Insérer un emoji" @@ -742,17 +770,17 @@ msgstr "Lien invalide" msgid "Invite link" msgstr "Lien d'invitation" -#: src/pages/UserPublicProfile.tsx:839 +#: src/pages/UserPublicProfile.tsx:868 msgid "invited by" msgstr "invité par" -#: src/pages/UserPublicProfile.tsx:943 -#: src/pages/UserPublicProfile.tsx:1138 +#: src/pages/UserPublicProfile.tsx:995 +#: src/pages/UserPublicProfile.tsx:1194 msgid "Invitees" msgstr "Invités" -#: src/components/FeedTabBar.tsx:32 -#: src/pages/UserPublicProfile.tsx:1301 +#: src/components/FeedTabBar.tsx:33 +#: src/pages/UserPublicProfile.tsx:1357 msgid "Journal" msgstr "Journal" @@ -760,7 +788,7 @@ msgstr "Journal" msgid "Keep editing" msgstr "Continuer l'édition" -#: src/pages/UserPublicProfile.tsx:1259 +#: src/pages/UserPublicProfile.tsx:1315 msgid "Light" msgstr "Clair" @@ -784,8 +812,9 @@ msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." -#: src/components/UserListPopover.tsx:231 -#: src/pages/Notifications.tsx:448 +#: src/components/UserListPopover.tsx:237 +#: src/pages/Notifications.tsx:469 +#: src/pages/UserPublicProfile.tsx:1140 msgid "Load more" msgstr "Charger plus" @@ -794,7 +823,7 @@ msgid "Load older messages" msgstr "Charger les messages plus anciens" #: src/pages/Dump.tsx:266 -#: src/pages/DumpEdit.tsx:163 +#: src/pages/DumpEdit.tsx:161 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -802,44 +831,45 @@ msgstr "Chargement de la reco…" msgid "Loading messages…" msgstr "Chargement des messages…" -#: src/pages/index/FollowedFeed.tsx:111 +#: src/pages/index/FollowedFeed.tsx:121 #: src/pages/index/HotFeed.tsx:64 #: src/pages/index/JournalFeed.tsx:73 #: src/pages/index/NewFeed.tsx:64 -#: src/pages/Search.tsx:244 +#: src/pages/Search.tsx:258 #: src/pages/UserDumps.tsx:121 -#: src/pages/UserPlaylists.tsx:420 -#: src/pages/UserPlaylists.tsx:455 -#: src/pages/UserUpvoted.tsx:190 +#: src/pages/UserPlaylists.tsx:435 +#: src/pages/UserPlaylists.tsx:470 +#: src/pages/UserUpvoted.tsx:197 msgid "Loading more…" msgstr "Chargement…" -#: src/pages/PlaylistDetail.tsx:556 +#: src/pages/PlaylistDetail.tsx:587 msgid "Loading playlist…" msgstr "Chargement de la collection…" -#: src/pages/UserPublicProfile.tsx:773 +#: src/pages/UserPublicProfile.tsx:802 msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/CategoryManager.tsx:52 #: src/components/ChatModal.tsx:1192 #: src/components/PlaylistMembershipPanel.tsx:28 -#: src/components/TextEditor.tsx:640 -#: src/components/UserListPopover.tsx:192 -#: src/components/UserListPopover.tsx:230 -#: src/pages/index/FollowedFeed.tsx:78 +#: src/components/TextEditor.tsx:669 +#: src/components/UserListPopover.tsx:198 +#: src/components/UserListPopover.tsx:236 +#: src/pages/index/FollowedFeed.tsx:88 #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:371 -#: src/pages/Notifications.tsx:447 +#: src/pages/Notifications.tsx:392 +#: src/pages/Notifications.tsx:468 #: src/pages/UserDumps.tsx:54 -#: src/pages/UserPlaylists.tsx:345 -#: src/pages/UserPublicProfile.tsx:1056 -#: src/pages/UserPublicProfile.tsx:1098 -#: src/pages/UserPublicProfile.tsx:1143 -#: src/pages/UserUpvoted.tsx:125 +#: src/pages/UserPlaylists.tsx:356 +#: src/pages/UserPublicProfile.tsx:1108 +#: src/pages/UserPublicProfile.tsx:1139 +#: src/pages/UserPublicProfile.tsx:1154 +#: src/pages/UserPublicProfile.tsx:1199 +#: src/pages/UserUpvoted.tsx:132 msgid "Loading…" msgstr "Chargement…" @@ -858,8 +888,8 @@ msgstr "Se connecter pour aimer" msgid "Log in to vote" msgstr "Se connecter pour voter" -#: src/pages/UserPublicProfile.tsx:794 -#: src/pages/UserPublicProfile.tsx:890 +#: src/pages/UserPublicProfile.tsx:823 +#: src/pages/UserPublicProfile.tsx:927 msgid "Log out" msgstr "Se déconnecter" @@ -871,7 +901,7 @@ msgstr "Connexion…" msgid "Login failed" msgstr "Connexion échouée" -#: src/pages/UserPublicProfile.tsx:948 +#: src/pages/UserPublicProfile.tsx:1000 msgid "Manage" msgstr "Administration" @@ -879,22 +909,26 @@ msgstr "Administration" msgid "Max 50 MB" msgstr "Max 50 Mo" -#: src/pages/UserPublicProfile.tsx:1634 +#: src/pages/UserPublicProfile.tsx:1696 msgid "Moderator" msgstr "Modérateur" +#: src/components/MediaPlayer.tsx:499 +msgid "Mute" +msgstr "Couper le son" + #: src/components/CategoryManager.tsx:178 #: src/components/CategoryManager.tsx:179 msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:364 +#: src/pages/Notifications.tsx:385 msgid "new" msgstr "nouveau" #: src/components/ChatModal.tsx:1121 -#: src/components/FeedTabBar.tsx:31 -#: src/pages/UserPublicProfile.tsx:1294 +#: src/components/FeedTabBar.tsx:32 +#: src/pages/UserPublicProfile.tsx:1350 msgid "New" msgstr "Nouveau" @@ -902,7 +936,7 @@ msgstr "Nouveau" #: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:66 #: src/pages/UserDumps.tsx:88 -#: src/pages/UserPublicProfile.tsx:1413 +#: src/pages/UserPublicProfile.tsx:1469 msgid "New dump" msgstr "Nouvelle reco" @@ -924,11 +958,11 @@ msgstr "Suivant →" msgid "Next track" msgstr "Piste suivante" -#: src/pages/PlaylistDetail.tsx:682 +#: src/pages/PlaylistDetail.tsx:717 msgid "No dumps in this playlist yet." msgstr "Aucune reco dans cette collection pour l'instant." -#: src/pages/Search.tsx:224 +#: src/pages/Search.tsx:238 msgid "No dumps match \"{q}\"." msgstr "Aucune reco ne correspond à « {q} »." @@ -938,16 +972,16 @@ msgstr "Aucune reco ne correspond à « {q} »." msgid "No dumps yet. Be the first!" msgstr "Pas encore de recos. Soyez le premier !" -#: src/components/TextEditor.tsx:643 +#: src/components/TextEditor.tsx:672 msgid "No emoji found." msgstr "Aucun emoji trouvé." -#: src/pages/UserPlaylists.tsx:442 -#: src/pages/UserPublicProfile.tsx:1111 +#: src/pages/UserPlaylists.tsx:457 +#: src/pages/UserPublicProfile.tsx:1167 msgid "No followed playlists yet." msgstr "Pas encore de collections suivies." -#: src/pages/UserPublicProfile.tsx:1156 +#: src/pages/UserPublicProfile.tsx:1212 msgid "No invitees yet." msgstr "Aucun invité pour le moment." @@ -955,17 +989,17 @@ msgstr "Aucun invité pour le moment." msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" -#: src/components/UserListPopover.tsx:200 +#: src/components/UserListPopover.tsx:206 msgid "No one yet." msgstr "Personne pour le moment." -#: src/pages/Search.tsx:283 +#: src/pages/Search.tsx:297 msgid "No playlists match \"{q}\"." msgstr "Aucune collection ne correspond à « {q} »." #: src/components/PlaylistMembershipPanel.tsx:34 -#: src/pages/UserPlaylists.tsx:400 -#: src/pages/UserPublicProfile.tsx:1022 +#: src/pages/UserPlaylists.tsx:415 +#: src/pages/UserPublicProfile.tsx:1074 msgid "No playlists yet." msgstr "Pas encore de collections." @@ -973,26 +1007,26 @@ msgstr "Pas encore de collections." 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é" -#: src/pages/Search.tsx:257 +#: src/pages/Search.tsx:271 msgid "No users match \"{q}\"." msgstr "Aucun utilisateur ne correspond à « {q} »." -#: src/pages/UserPublicProfile.tsx:1069 +#: src/pages/UserPublicProfile.tsx:1121 msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/pages/Notifications.tsx:382 +#: src/pages/Notifications.tsx:403 #: src/pages/UserDumps.tsx:99 -#: src/pages/UserPublicProfile.tsx:1424 -#: src/pages/UserPublicProfile.tsx:1546 -#: src/pages/UserUpvoted.tsx:159 +#: src/pages/UserPublicProfile.tsx:1480 +#: src/pages/UserPublicProfile.tsx:1608 +#: src/pages/UserUpvoted.tsx:166 msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:266 -#: src/pages/Notifications.tsx:360 -#: src/pages/UserPublicProfile.tsx:1316 +#: src/pages/Notifications.tsx:267 +#: src/pages/Notifications.tsx:381 +#: src/pages/UserPublicProfile.tsx:1372 msgid "Notifications" msgstr "Notifications" @@ -1000,18 +1034,27 @@ msgstr "Notifications" msgid "Notifications ({unreadNotificationCount} unread)" msgstr "Notifications ({unreadNotificationCount} non lues)" -#: src/pages/UserPublicProfile.tsx:1340 +#: src/pages/UserPublicProfile.tsx:1396 msgid "Off" msgstr "Désactivés" -#: src/pages/UserPublicProfile.tsx:1333 +#: src/pages/UserPublicProfile.tsx:1389 msgid "On" msgstr "Activés" -#: src/components/TextEditor.tsx:338 +#: src/components/TextEditor.tsx:347 msgid "Only images can be dropped here." msgstr "Seules les images peuvent être déposées ici." +#. placeholder {0}: richContent.siteName +#: src/components/RichContentCard.tsx:101 +msgid "Open on {0}" +msgstr "Ouvrir sur {0}" + +#: src/components/RichContentCard.tsx:102 +msgid "Open source" +msgstr "Ouvrir la source" + #: src/components/FileDropZone.tsx:134 msgid "or <0>browse files" msgstr "ou <0>parcourir les fichiers" @@ -1021,7 +1064,7 @@ msgid "Page not found" msgstr "Page introuvable" #: src/pages/UserLogin.tsx:76 -#: src/pages/UserPublicProfile.tsx:1176 +#: src/pages/UserPublicProfile.tsx:1232 msgid "Password" msgstr "Mot de passe" @@ -1047,39 +1090,56 @@ msgstr "Les mots de passe ne correspondent pas" 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/RichContentCard.tsx:83 +msgid "Pause" +msgstr "Pause" + #: src/components/DumpCreateModal.tsx:707 msgid "Picked up where you left off." msgstr "Reprise de votre brouillon." -#: src/pages/PlaylistDetail.tsx:870 +#: src/components/FilePreview.tsx:108 +#: src/components/FilePreview.tsx:213 +#: src/components/FilePreview.tsx:234 +#: src/components/FilePreview.tsx:269 +#: src/components/MediaPlayer.tsx:483 +#: src/components/RichContentCard.tsx:83 +#: src/components/RichContentCard.tsx:129 +msgid "Play" +msgstr "Lecture" + +#: src/pages/PlaylistDetail.tsx:910 msgid "Playlist title" msgstr "Titre de la collection" #: src/components/AppHeader.tsx:148 #: src/components/DumpCreateModal.tsx:68 #: src/components/UserMenu.tsx:62 -#: src/pages/Search.tsx:177 -#: src/pages/UserPlaylists.tsx:371 -#: src/pages/UserPublicProfile.tsx:941 +#: src/pages/Search.tsx:191 +#: src/pages/UserPlaylists.tsx:382 +#: src/pages/UserPublicProfile.tsx:993 msgid "Playlists" msgstr "Collections" #. placeholder {0}: playlists.items.length #. placeholder {1}: playlists.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:991 +#: src/pages/UserPublicProfile.tsx:1043 msgid "Playlists ({0}{1})" msgstr "Collections ({0}{1})" -#: src/components/CommentThread.tsx:492 +#: src/components/CommentThread.tsx:501 msgid "Post comment" msgstr "Publier le commentaire" -#: src/components/CommentThread.tsx:414 +#: src/components/CommentThread.tsx:423 msgid "Post reply" msgstr "Publier la réponse" -#: src/components/CommentThread.tsx:415 -#: src/components/CommentThread.tsx:493 +#: src/components/CommentThread.tsx:424 +#: src/components/CommentThread.tsx:502 #: src/components/DumpCreateModal.tsx:958 msgid "Posting…" msgstr "Publication…" @@ -1090,29 +1150,29 @@ msgstr "Piste précédente" #: src/components/DumpCard.tsx:118 #: src/components/JournalCard.tsx:119 -#: src/components/PlaylistCard.tsx:73 -#: src/components/PlaylistMembershipPanel.tsx:55 -#: src/pages/Dump.tsx:461 -#: src/pages/PlaylistDetail.tsx:646 +#: src/components/PlaylistCard.tsx:72 +#: src/components/PlaylistMembershipPanel.tsx:64 +#: src/pages/Dump.tsx:474 +#: src/pages/PlaylistDetail.tsx:677 msgid "private" msgstr "privé" #: src/components/form/SegmentedField.tsx:81 -#: src/pages/PlaylistDetail.tsx:911 +#: src/pages/PlaylistDetail.tsx:951 msgid "Private" msgstr "Privé" -#: src/components/PlaylistCard.tsx:72 -#: src/pages/PlaylistDetail.tsx:645 +#: src/components/PlaylistCard.tsx:71 +#: src/pages/PlaylistDetail.tsx:676 msgid "public" msgstr "public" #: src/components/form/SegmentedField.tsx:80 -#: src/pages/PlaylistDetail.tsx:904 +#: src/pages/PlaylistDetail.tsx:944 msgid "Public" msgstr "Public" -#: src/pages/DumpEdit.tsx:245 +#: src/pages/DumpEdit.tsx:243 msgid "Refresh metadata" msgstr "Actualiser les métadonnées" @@ -1120,7 +1180,7 @@ msgstr "Actualiser les métadonnées" msgid "Refreshing stream…" msgstr "Actualisation du flux…" -#: src/pages/DumpEdit.tsx:244 +#: src/pages/DumpEdit.tsx:242 msgid "Refreshing…" msgstr "Actualisation…" @@ -1138,7 +1198,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:528 +#: src/pages/Dump.tsx:541 msgid "Related" msgstr "Connexe" @@ -1146,7 +1206,7 @@ msgstr "Connexe" msgid "Remove file" msgstr "Supprimer le fichier" -#: src/pages/PlaylistDetail.tsx:737 +#: src/pages/PlaylistDetail.tsx:772 msgid "Remove from playlist" msgstr "Retirer de la collection" @@ -1158,13 +1218,13 @@ msgstr "Retirer le j'aime" msgid "Remove vote" msgstr "Retirer le vote" -#: src/pages/DumpEdit.tsx:418 +#: src/pages/DumpEdit.tsx:438 msgid "Replace file" msgstr "Remplacer le fichier" #: src/components/ChatModal.tsx:326 #: src/components/ChatModal.tsx:327 -#: src/components/CommentThread.tsx:374 +#: src/components/CommentThread.tsx:378 msgid "Reply" msgstr "Répondre" @@ -1172,7 +1232,7 @@ msgstr "Répondre" msgid "Replying to" msgstr "En réponse à" -#: src/pages/UserLogin.tsx:134 +#: src/pages/UserLogin.tsx:138 msgid "Request failed" msgstr "Échec de la demande" @@ -1184,8 +1244,8 @@ msgstr "Échec de la réinitialisation" msgid "Reset password" msgstr "Réinitialiser le mot de passe" -#: src/pages/DumpEdit.tsx:399 -#: src/pages/DumpEdit.tsx:448 +#: src/pages/DumpEdit.tsx:414 +#: src/pages/DumpEdit.tsx:468 msgid "Reset to default" msgstr "Réinitialiser par défaut" @@ -1196,37 +1256,38 @@ msgstr "Rétablir le titre suggéré" #: src/components/ChatModal.tsx:519 #: src/pages/Dump.tsx:283 -#: src/pages/DumpEdit.tsx:180 +#: src/pages/DumpEdit.tsx:178 msgid "Retry" msgstr "Réessayer" -#: src/pages/UserPublicProfile.tsx:1689 +#: src/pages/UserPublicProfile.tsx:1751 msgid "Role" msgstr "Rôle" +#: src/components/CategoryManager.tsx:202 #: src/components/ChatModal.tsx:232 -#: src/components/CommentThread.tsx:346 -#: src/pages/Dump.tsx:394 -#: src/pages/DumpEdit.tsx:486 -#: src/pages/PlaylistDetail.tsx:936 -#: src/pages/UserPublicProfile.tsx:1756 -#: src/pages/UserPublicProfile.tsx:1830 +#: src/components/CommentThread.tsx:350 +#: src/pages/Dump.tsx:401 +#: src/pages/DumpEdit.tsx:509 +#: src/pages/PlaylistDetail.tsx:976 +#: src/pages/UserPublicProfile.tsx:1818 +#: src/pages/UserPublicProfile.tsx:1893 msgid "Save" msgstr "Enregistrer" #: src/components/ChangePasswordModal.tsx:103 -#: src/components/CommentThread.tsx:347 -#: src/pages/Dump.tsx:393 -#: src/pages/PlaylistDetail.tsx:932 +#: src/components/CommentThread.tsx:351 +#: src/pages/Dump.tsx:400 +#: src/pages/PlaylistDetail.tsx:972 #: src/pages/ResetPassword.tsx:126 -#: src/pages/UserPublicProfile.tsx:1753 -#: src/pages/UserPublicProfile.tsx:1827 +#: src/pages/UserPublicProfile.tsx:1815 +#: src/pages/UserPublicProfile.tsx:1890 msgid "Saving…" msgstr "Enregistrement…" #: src/components/AppHeader.tsx:127 #: src/components/SearchBar.tsx:73 -#: src/pages/Search.tsx:50 +#: src/pages/Search.tsx:51 msgid "Search" msgstr "Rechercher" @@ -1234,32 +1295,36 @@ msgstr "Rechercher" msgid "Search dumps, users, playlists…" msgstr "Rechercher des recos, utilisateurs, collections…" -#: src/components/TextEditor.tsx:612 +#: src/components/TextEditor.tsx:641 msgid "Search emoji" msgstr "Rechercher un emoji" -#: src/components/TextEditor.tsx:611 +#: src/components/TextEditor.tsx:640 msgid "Search emoji…" msgstr "Rechercher un emoji…" -#: src/pages/Search.tsx:218 +#: src/pages/Search.tsx:232 msgid "Search failed" msgstr "Recherche échouée" -#: src/pages/Search.tsx:213 +#: src/pages/Search.tsx:227 msgid "Searching…" msgstr "Recherche…" +#: src/components/MediaPlayer.tsx:179 +msgid "Seek" +msgstr "Position" + #: src/components/ChatModal.tsx:1296 msgid "Send" msgstr "Envoyer" -#: src/pages/UserLogin.tsx:143 +#: src/pages/UserLogin.tsx:147 msgid "Send reset link" msgstr "Envoyer le lien de réinitialisation" #: src/components/ChatModal.tsx:537 -#: src/pages/UserLogin.tsx:142 +#: src/pages/UserLogin.tsx:146 msgid "Sending…" msgstr "Envoi…" @@ -1272,7 +1337,7 @@ msgstr "Serveur inaccessible" msgid "Set new password" msgstr "Définir un nouveau mot de passe" -#: src/pages/UserPublicProfile.tsx:945 +#: src/pages/UserPublicProfile.tsx:997 msgid "Settings" msgstr "Paramètres" @@ -1288,17 +1353,18 @@ msgstr "Usage unique, valable 7 jours." msgid "slug" msgstr "identifiant" -#: src/components/TextEditor.tsx:297 +#: src/components/TextEditor.tsx:306 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/PageError.tsx:13 #: src/contexts/WSProvider.tsx:663 +#: src/pages/PlaylistDetail.tsx:711 msgid "Something went wrong" msgstr "Une erreur est survenue" -#: src/pages/UserPublicProfile.tsx:1321 +#: src/pages/UserPublicProfile.tsx:1377 msgid "Sounds" msgstr "Sons" @@ -1306,7 +1372,7 @@ msgstr "Sons" msgid "Start fresh" msgstr "Repartir de zéro" -#: src/pages/UserPublicProfile.tsx:1195 +#: src/pages/UserPublicProfile.tsx:1251 msgid "Style" msgstr "Style" @@ -1314,14 +1380,22 @@ msgstr "Style" msgid "Submit search" msgstr "Lancer la recherche" -#: src/pages/Notifications.tsx:220 +#: src/pages/Notifications.tsx:221 msgid "the chat" msgstr "la tribune" -#: src/components/TextEditor.tsx:298 +#: src/pages/PlaylistDetail.tsx:505 +msgid "The dump couldn't be removed. Try again." +msgstr "Le dump n'a pas pu être retiré. Réessayez." + +#: src/components/TextEditor.tsx:307 msgid "The image couldn't be uploaded." msgstr "L'image n'a pas pu être envoyée." +#: src/components/CommentThread.tsx:416 +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 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." @@ -1338,13 +1412,13 @@ 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:383 +#: src/pages/DumpEdit.tsx:398 msgid "Thumbnail" msgstr "Miniature" #: src/components/DumpCreateModal.tsx:174 #: src/components/PlaylistCreateForm.tsx:74 -#: src/pages/DumpEdit.tsx:438 +#: src/pages/DumpEdit.tsx:458 msgid "Title" msgstr "Titre" @@ -1353,7 +1427,7 @@ msgid "Title is required." msgstr "Un titre est requis." #: src/components/ChatModal.tsx:147 -#: src/pages/Notifications.tsx:398 +#: src/pages/Notifications.tsx:419 msgid "Today" msgstr "Aujourd'hui" @@ -1370,7 +1444,7 @@ msgstr "Réessayer" msgid "Type a message…" msgstr "Écrivez un message…" -#: src/pages/PlaylistDetail.tsx:749 +#: src/pages/PlaylistDetail.tsx:784 msgid "Undo" msgstr "Annuler" @@ -1382,7 +1456,11 @@ msgstr "Ne plus suivre {targetUsername}" msgid "Unfollow playlist" msgstr "Ne plus suivre la collection" -#: src/pages/UserPublicProfile.tsx:744 +#: src/components/MediaPlayer.tsx:499 +msgid "Unmute" +msgstr "Rétablir le son" + +#: src/pages/UserPublicProfile.tsx:773 msgid "Upload failed" msgstr "Envoi échoué" @@ -1391,7 +1469,7 @@ msgstr "Envoi échoué" msgid "Uploading {0}%" msgstr "Envoi {0} %" -#: src/components/TextEditor.tsx:526 +#: src/components/TextEditor.tsx:555 msgid "Uploading image…" msgstr "Envoi de l'image…" @@ -1403,17 +1481,17 @@ msgstr "Envoi…" msgid "Upvote" msgstr "Voter" -#: src/pages/UserUpvoted.tsx:154 +#: src/pages/UserUpvoted.tsx:161 msgid "Upvoted" msgstr "Voté" #. placeholder {0}: votes.items.length #. placeholder {1}: votes.hasMore ? "+" : "" -#: src/pages/UserPublicProfile.tsx:971 +#: src/pages/UserPublicProfile.tsx:1023 msgid "Upvoted ({0}{1})" msgstr "Votés ({0}{1})" -#: src/pages/DumpEdit.tsx:410 +#: src/pages/DumpEdit.tsx:430 msgid "URL" msgstr "URL" @@ -1425,11 +1503,11 @@ msgstr "L'URL est obligatoire." msgid "URL slug" msgstr "Identifiant d’URL" -#: src/pages/UserPublicProfile.tsx:1633 +#: src/pages/UserPublicProfile.tsx:1695 msgid "User" msgstr "Utilisateur" -#: src/pages/UserPublicProfile.tsx:1352 +#: src/pages/UserPublicProfile.tsx:1408 msgid "User management" msgstr "Gestion utilisateur" @@ -1442,15 +1520,14 @@ msgstr "Menu utilisateur" msgid "Username" msgstr "Nom d'utilisateur" -#: src/pages/Search.tsx:176 +#: src/pages/Search.tsx:190 msgid "Users" msgstr "Utilisateurs" -#: src/pages/UserPublicProfile.tsx:1041 -#: src/pages/UserPublicProfile.tsx:1084 -#: src/pages/UserPublicProfile.tsx:1126 -#: src/pages/UserPublicProfile.tsx:1445 -#: src/pages/UserPublicProfile.tsx:1576 +#: src/pages/UserPublicProfile.tsx:1093 +#: src/pages/UserPublicProfile.tsx:1182 +#: src/pages/UserPublicProfile.tsx:1501 +#: src/pages/UserPublicProfile.tsx:1638 msgid "View all →" msgstr "Tout voir →" @@ -1458,13 +1535,17 @@ msgstr "Tout voir →" msgid "View dump →" msgstr "Voir la reco →" +#: src/components/MediaPlayer.tsx:516 +msgid "Volume" +msgstr "Volume" + #: src/components/DumpCreateModal.tsx:912 -#: src/pages/DumpEdit.tsx:459 +#: src/pages/DumpEdit.tsx:479 msgid "What makes it worth it?" msgstr "Pourquoi on en voudrait ?" -#: src/pages/UserPublicProfile.tsx:925 -#: src/pages/UserPublicProfile.tsx:1819 +#: src/pages/UserPublicProfile.tsx:977 +#: src/pages/UserPublicProfile.tsx:1882 msgid "Who am I?" msgstr "Qui suis-je ?" @@ -1477,16 +1558,16 @@ msgid "Why & where" msgstr "Pourquoi & où" #: src/components/DumpCreateModal.tsx:911 -#: src/pages/DumpEdit.tsx:458 +#: src/pages/DumpEdit.tsx:478 msgid "Why?" msgstr "Pourquoi ?" -#: src/components/CommentThread.tsx:418 +#: src/components/CommentThread.tsx:427 msgid "Write a reply…" msgstr "Écrire une réponse…" #: src/components/ChatModal.tsx:150 -#: src/pages/Notifications.tsx:400 +#: src/pages/Notifications.tsx:421 msgid "Yesterday" msgstr "Hier" @@ -1494,7 +1575,7 @@ msgstr "Hier" msgid "You can post it anyway." msgstr "Vous pouvez la publier quand même." -#: src/pages/Notifications.tsx:385 +#: src/pages/Notifications.tsx:406 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." @@ -1502,19 +1583,19 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo msgid "You're offline — not sent" msgstr "Hors ligne — message non envoyé" -#: src/pages/index/FollowedFeed.tsx:116 +#: src/pages/index/FollowedFeed.tsx:126 #: src/pages/index/HotFeed.tsx:69 #: src/pages/index/JournalFeed.tsx:78 #: src/pages/index/NewFeed.tsx:69 -#: src/pages/Search.tsx:249 +#: src/pages/Search.tsx:263 #: src/pages/UserDumps.tsx:126 -#: src/pages/UserPlaylists.tsx:425 -#: src/pages/UserPlaylists.tsx:460 -#: src/pages/UserUpvoted.tsx:195 +#: src/pages/UserPlaylists.tsx:440 +#: src/pages/UserPlaylists.tsx:475 +#: src/pages/UserUpvoted.tsx:202 msgid "You've reached the end." msgstr "Vous avez tout lu, tout vu, tout bu." -#: src/pages/UserLogin.tsx:138 +#: src/pages/UserLogin.tsx:142 msgid "Your email address" msgstr "Votre adresse e-mail" diff --git a/src/model.ts b/src/model.ts index cdfa3d00..3ed2d920 100644 --- a/src/model.ts +++ b/src/model.ts @@ -730,7 +730,8 @@ export interface CreateUrlDumpRequest { export interface UpdateDumpRequest { url?: string; title?: string; - comment?: string; + /** `null` removes the note; leaving it out keeps it. */ + comment?: string | null; isPrivate?: boolean; categoryIds?: string[]; } diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index cf71de56..89dbf404 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -302,6 +302,12 @@ export function Dump() { .map((id) => byId.get(id)) .filter((c) => c !== undefined); + const startTitleEdit = () => { + setTitleDraft(dump.title); + setTitleError(null); + setTitleEditing(true); + }; + const handleTitleSave = async () => { const trimmed = titleDraft.trim(); if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return; @@ -322,13 +328,14 @@ export function Dump() { } const updatedDump = deserializeDump(apiResponse.data); setTitleEditing(false); + // The page stays mounted across the slug change below, so it has to be + // told about the new title itself. + setDumpState({ status: "loaded", dump: updatedDump }); if (updatedDump.slug !== dump.slug) { navigate(dumpUrl(updatedDump), { replace: true, state: { dump: updatedDump }, }); - } else { - setDumpState({ status: "loaded", dump: updatedDump }); } } catch (err) { setTitleError(friendlyFetchError(err)); @@ -411,11 +418,17 @@ export function Dump() { className={`dump-title-view${ canEdit ? " dump-title-view--editable" : "" }`} - onClick={canEdit - ? () => { - setTitleDraft(dump.title); - setTitleError(null); - setTitleEditing(true); + onClick={canEdit ? startTitleEdit : undefined} + // Reachable from the keyboard too; not a button, so the + // title inside stays a heading. + tabIndex={canEdit ? 0 : undefined} + title={canEdit ? t`Edit title` : undefined} + onKeyDown={canEdit + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + startTitleEdit(); + } } : undefined} > diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 0ec5fb3a..7937bc23 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -12,6 +12,7 @@ import { formatBytes } from "../utils/format.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; +import { ErrorCard } from "../components/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { ConfirmModal } from "../components/ConfirmModal.tsx"; import RichContentCard from "../components/RichContentCard.tsx"; @@ -55,6 +56,8 @@ export function DumpEdit() { const [refreshing, setRefreshing] = useState(false); const [refreshError, setRefreshError] = useState(null); const [thumbUploading, setThumbUploading] = useState(false); + const [thumbError, setThumbError] = useState(null); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { if (!selectedDump) return; @@ -105,54 +108,49 @@ export function DumpEdit() { } }; - const handleThumbnailChange = async (file: File) => { + const changeThumbnail = async (init: RequestInit) => { if (state.status !== "loaded") return; setThumbUploading(true); + setThumbError(null); try { - const formData = new FormData(); - formData.append("file", file); - const res = await authFetch( - `${API_URL}/api/dumps/${state.dump.id}/thumbnail`, - { method: "PUT", body: formData }, + const updated = await expectOk( + await authFetch( + `${API_URL}/api/dumps/${state.dump.id}/thumbnail`, + init, + ), ); - const apiResponse = parseAPIResponse(await res.json()); - if (apiResponse.success) { - setState({ status: "loaded", dump: deserializeDump(apiResponse.data) }); - } + setState({ status: "loaded", dump: deserializeDump(updated) }); + } catch (err) { + setThumbError(friendlyFetchError(err)); } finally { setThumbUploading(false); } }; - const handleThumbnailReset = async () => { - if (state.status !== "loaded") return; - setThumbUploading(true); - try { - const res = await authFetch( - `${API_URL}/api/dumps/${state.dump.id}/thumbnail`, - { method: "DELETE" }, - ); - const apiResponse = parseAPIResponse(await res.json()); - if (apiResponse.success) { - setState({ status: "loaded", dump: deserializeDump(apiResponse.data) }); - } - } finally { - setThumbUploading(false); - } + const handleThumbnailChange = (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return changeThumbnail({ method: "PUT", body: formData }); }; + const handleThumbnailReset = () => changeThumbnail({ method: "DELETE" }); + const handleDelete = async () => { if (state.status !== "loaded") return; - - const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, { - method: "DELETE", - }); - - if (!res.ok) { - setState({ status: "error", error: `Delete failed (${res.status})` }); + setConfirmDelete(false); + setDeleteError(null); + try { + const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, { + method: "DELETE", + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.error?.message ?? t`Delete failed (${res.status})`); + } + } catch (err) { + setDeleteError(friendlyFetchError(err)); return; } - navigate("/", { state: { deletedDumpId: state.dump.id } }); }; @@ -252,6 +250,8 @@ export function DumpEdit() { } currentThumbnailSrc={currentThumbnailSrc} thumbUploading={thumbUploading} + thumbError={thumbError} + deleteError={deleteError} onThumbnailChange={handleThumbnailChange} onThumbnailReset={handleThumbnailReset} onRequestDelete={() => setConfirmDelete(true)} @@ -276,6 +276,8 @@ interface DumpEditFormProps { preview: React.ReactNode; currentThumbnailSrc: string | null; thumbUploading: boolean; + thumbError: string | null; + deleteError: string | null; onThumbnailChange: (file: File) => void; onThumbnailReset: () => void; onRequestDelete: () => void; @@ -295,6 +297,8 @@ function DumpEditForm({ preview, currentThumbnailSrc, thumbUploading, + thumbError, + deleteError, onThumbnailChange, onThumbnailReset, onRequestDelete, @@ -343,18 +347,29 @@ function DumpEditForm({ method: "PUT", body: formData, }); + // Replacing the file doesn't touch visibility, so a change made in + // the same save goes separately, once the file is in. + if (isPrivate !== dump.isPrivate) { + await expectOk(res); + res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { + method: "PUT", + body: JSON.stringify({ isPrivate } satisfies UpdateDumpRequest), + }); + } } else { + // An emptied note is sent as null: left out, the server keeps the old + // one. const body: UpdateDumpRequest = dump.kind === "url" ? { url: url.trim() || undefined, title: trimmedTitle, - comment: comment.trim() || undefined, + comment: comment.trim() || null, isPrivate, categoryIds, } : { title: trimmedTitle, - comment: comment.trim() || undefined, + comment: comment.trim() || null, isPrivate, categoryIds, }; @@ -400,6 +415,11 @@ function DumpEditForm({ )}
+ {thumbError && ( + + {thumbError} + + )}
{dump.kind === "url" @@ -470,6 +490,9 @@ function DumpEditForm({
+ {deleteError && ( + + )}
+ {actionError && ( + + )} + {visibleDumps.length === 0 ? (

@@ -819,10 +854,17 @@ function PlaylistEditForm( if (image) { const fd = new FormData(); fd.append("file", image); - await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, { - method: "POST", - body: fd, - }); + // The upload answers with the playlist as it now stands (new cover, + // new updatedAt), which is what the page should show. + onSaved(deserializePlaylist( + await expectOk( + await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, { + method: "POST", + body: fd, + }), + ), + )); + return; } onSaved(updated); @@ -840,9 +882,7 @@ function PlaylistEditForm( render={({ field }) => ( null); + throw new Error(body?.error?.message ?? `HTTP ${res.status}`); + } const body = await res.json() as { success: true; @@ -133,7 +137,17 @@ export function Search() { } } catch (err) { if ((err as Error).name === "AbortError") return; - setState({ status: "error", error: (err as Error).message }); + // A later page that fails leaves the results already shown; scrolling + // back to the end tries it again. + if (page > 1) { + setState((prev) => + prev.status === "loaded" + ? { ...prev, dumps: { ...prev.dumps, loadingMore: false } } + : prev + ); + return; + } + setState({ status: "error", error: friendlyFetchError(err) }); } }, [token]); diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index 8d86df47..2e49bc8f 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -107,11 +107,15 @@ function ResetRequestPanel() { const form = useApiForm({ defaultValues: { email: "" } }); const onSubmit = form.submit(async ({ email }) => { - await fetch(`${API_URL}/api/users/request-password-reset`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email.trim() }), - }); + // The answer is the same whether or not the address is known; only a + // server that failed to take the request is worth reporting. + await expectOk( + await fetch(`${API_URL}/api/users/request-password-reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim() }), + }), + ); setSent(true); }); diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index c8a7c2f6..a9b355da 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -36,6 +36,8 @@ import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx"; import { ConfirmModal } from "../components/ConfirmModal.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; +import { ErrorCard } from "../components/ErrorCard.tsx"; +import { expectOk } from "../components/form/index.ts"; interface PlaylistFeed { items: Playlist[]; @@ -321,10 +323,19 @@ export function UserPlaylists() { } }, [state.status, cachedCreated]); + const [deleteError, setDeleteError] = useState(null); + const handleDelete = async (playlistId: string) => { - await authFetch(`${API_URL}/api/playlists/${playlistId}`, { - method: "DELETE", - }); + setDeleteError(null); + try { + const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, { + method: "DELETE", + }); + if (!res.ok) await expectOk(res); // throws the server's message + } catch (err) { + setDeleteError(friendlyFetchError(err)); + return; + } setState((s) => s.status === "loaded" ? { @@ -385,6 +396,10 @@ export function UserPlaylists() { )} /> + {deleteError && ( + + )} +

diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 9c441c10..f0dcbcb2 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -701,6 +701,35 @@ export function UserPublicProfile() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, state.status, username]); + // There's no page of its own for who someone follows, so the list grows + // here. + const loadMoreFollowedUsers = () => { + if ( + followedState?.status !== "loaded" || followedState.users.loadingMore + ) return; + const nextPage = followedState.users.page + 1; + const setUsers = ( + fn: (u: PaginatedList) => PaginatedList, + ) => + setFollowedState((s) => + s?.status === "loaded" ? { ...s, users: fn(s.users) } : s + ); + setUsers((u) => ({ ...u, loadingMore: true })); + fetch( + `${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, + ) + .then(expectOk>) + .then(({ items, hasMore }) => + setUsers((u) => ({ + items: [...u.items, ...items.map(deserializePublicUser)], + hasMore, + page: nextPage, + loadingMore: false, + })) + ) + .catch(() => setUsers((u) => ({ ...u, loadingMore: false }))); + }; + // Restore scroll position after cache restoration const scrollRestored = useRef(false); useLayoutEffect(() => { @@ -863,7 +892,15 @@ export function UserPublicProfile() {

setEmailEditing(true)} - title="Edit email" + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setEmailEditing(true); + } + }} + tabIndex={0} + role="button" + title={t`Edit email`} > {me?.email ?? t`Add email…`} @@ -913,6 +950,21 @@ export function UserPublicProfile() { onClick={isOwnProfile ? () => setDescEditing(true) : undefined} + // Reachable from the keyboard too; not a button, so the + // markdown inside keeps its own structure. + tabIndex={isOwnProfile ? 0 : undefined} + title={isOwnProfile ? t`Edit description` : undefined} + onKeyDown={isOwnProfile + ? (e) => { + if ( + (e.key === "Enter" || e.key === " ") && + e.target === e.currentTarget + ) { + e.preventDefault(); + setDescEditing(true); + } + } + : undefined} > {profileUser.description ? ( @@ -1077,12 +1129,16 @@ export function UserPublicProfile() { ))} {followedState.users.hasMore && ( - - View all → - + {followedState.users.loadingMore + ? Loading… + : Load more} + )} )} @@ -1496,13 +1552,19 @@ function UpvotedDumpList( useEffect(() => { if (!profileUserId || !isOwnProfile) return; if (prevMyVotesRef.current === null) { - // setVotedIds + prevMyVotesRef must be co-located to stay consistent. - setVotedIds(new Set(wsMyVotes)); + // Only a baseline: the list as loaded stands. myVotes may still be the + // empty set from before the socket's welcome, and taking it as the + // truth would hide every dump. prevMyVotesRef.current = new Set(wsMyVotes); return; } const prev = prevMyVotesRef.current; - setVotedIds(new Set(wsMyVotes)); + setVotedIds((ids) => { + const next = new Set(ids); + for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id); + for (const id of wsMyVotes) next.add(id); + return next; + }); for (const id of prev) if (!wsMyVotes.has(id)) startFading(id); for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id); prevMyVotesRef.current = new Set(wsMyVotes); @@ -1787,7 +1849,8 @@ function DescriptionEditor( const submitting = form.formState.isSubmitting; const onSubmit = form.submit(async ({ description }) => { - const trimmed = description.trim() || undefined; + const trimmed = description.trim(); + // Sent even when empty: left out, the server would keep the old one. await expectOk( await authFetch(`${API_URL}/api/users/me`, { method: "PATCH", @@ -1797,7 +1860,7 @@ function DescriptionEditor( ), }), ); - onSaved(trimmed); + onSaved(trimmed || undefined); }); return ( diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index a7249620..5bd6e891 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -72,13 +72,20 @@ export function UserUpvoted() { useEffect(() => { if (!profileUserId || me?.id !== profileUserId) return; if (prevMyVotesRef.current === null) { - // setVotedIds + prevMyVotesRef must be co-located to stay consistent. - setVotedIds(new Set(myVotes)); + // Only a baseline: the list as loaded stands. myVotes may still be the + // empty set from before the socket's welcome (or the socket may never + // connect), and taking it as the truth here would hide every dump. prevMyVotesRef.current = new Set(myVotes); return; } const prev = prevMyVotesRef.current; - setVotedIds(new Set(myVotes)); + // Votes cast or removed since — from this tab or another — show or fade. + setVotedIds((ids) => { + const next = new Set(ids); + for (const id of prev) if (!myVotes.has(id)) next.delete(id); + for (const id of myVotes) next.add(id); + return next; + }); for (const id of prev) if (!myVotes.has(id)) startFading(id); for (const id of myVotes) if (!prev.has(id)) cancelFading(id); prevMyVotesRef.current = new Set(myVotes); diff --git a/src/pages/index/FollowedFeed.tsx b/src/pages/index/FollowedFeed.tsx index e2e5f593..bc15999a 100644 --- a/src/pages/index/FollowedFeed.tsx +++ b/src/pages/index/FollowedFeed.tsx @@ -31,6 +31,16 @@ type FeedState = loadingMore: boolean; }; +/** Page of a followed feed. Throws the server's own message on an error + * envelope, so it isn't mistaken for a lost connection. */ +async function readPage(res: Response): Promise> { + const body = await res.json(); + if (!body.success) { + throw new Error(body.error?.message ?? `HTTP ${res.status}`); + } + return body.data as PaginatedData; +} + type FollowedSection = "users" | "playlists"; interface FollowedFeedProps { @@ -203,9 +213,8 @@ export function FollowedFeed({ `${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData; + .then(readPage) + .then(({ items, hasMore }) => { setUsersState({ status: "loaded", dumps: items.map(deserializeDump), @@ -223,9 +232,8 @@ export function FollowedFeed({ `${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData; + .then(readPage) + .then(({ items, hasMore }) => { setPlaylistsState({ status: "loaded", dumps: items.map(deserializeDump), @@ -278,9 +286,8 @@ export function FollowedFeed({ `${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData; + .then(readPage) + .then(({ items, hasMore }) => { setUsersState((s) => s.status === "loaded" ? { @@ -313,9 +320,8 @@ export function FollowedFeed({ `${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`, { headers: { Authorization: `Bearer ${token}` } }, ) - .then((r) => r.json()) - .then((body) => { - const { items, hasMore } = body.data as PaginatedData; + .then(readPage) + .then(({ items, hasMore }) => { setPlaylistsState((s) => s.status === "loaded" ? { diff --git a/src/test/components/ChatModal.test.tsx b/src/test/components/ChatModal.test.tsx new file mode 100644 index 00000000..94f2a908 --- /dev/null +++ b/src/test/components/ChatModal.test.tsx @@ -0,0 +1,304 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ChatModal } from "../../components/ChatModal.tsx"; +import type { AuthResponse, ChatMessage } from "../../model.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { makeChatMessage, makeSession, wire } from "../fixtures.ts"; +import { renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const me = makeSession({ id: "me", username: "me" }); + +async function setup( + history: ChatMessage[] = [], + { session = me, welcome = {} as Record } = {}, +) { + route("GET /api/chat", () => wire(history)); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderApp(, { session }); + const socket = FakeWebSocket.last; + socket.welcome(welcome); + await screen.findByRole("dialog", { name: "Chat" }); + await waitFor(() => + expect(screen.queryByText("Loading messages…")).not.toBeInTheDocument() + ); + const composer = screen.getByPlaceholderText("Type a message…"); + return { user, socket, onClose, composer }; +} + +const sent = (socket: FakeWebSocket, type: string) => + socket.messages.filter((m) => m.type === type); + +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) }), + ]); + 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(calls("GET /api/chat")[0].url.searchParams.get("limit")).toBe("50"); + }); + + test("empty and failed states", async () => { + route("GET /api/chat", () => fail("down", 500), { once: true }); + route("GET /api/chat", () => []); + const user = userEvent.setup(); + renderApp( {}} />, { session: me }); + await screen.findByText("Couldn't load the chat."); + await user.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByText("No messages yet. Say hello!"); + }); + + 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) }) + ); + 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 screen.findByText("the oldest"); + 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(); + }); + + 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) }), + ], { welcome: { chatReadAt: readAt.toISOString() } }); + const divider = document.querySelector(".chat-new-sep")!; + expect(divider).toHaveTextContent("New"); + expect(divider.nextElementSibling).toHaveTextContent("unseen"); + }); + + test("reading at the bottom moves the read marker", async () => { + const { socket } = await setup([makeChatMessage({ body: "hi" })]); + await waitFor(() => expect(sent(socket, "chat_read")).toHaveLength(1)); + }); + + 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 }); + }); +}); + +describe("ChatModal: sending", () => { + test("Enter sends; the message shows as sending until its echo", async () => { + const { user, socket, composer } = await setup(); + await user.type(composer, "hello there{Enter}"); + expect(composer).toHaveValue(""); + const [send] = sent(socket, "chat_send"); + expect(send).toMatchObject({ body: "hello there" }); + expect(screen.getByText("Sending…")).toBeInTheDocument(); + socket.receive({ + type: "chat_message", + nonce: send.nonce, + 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(); + }); + + test("Shift+Enter is a new line; blank and over-long messages don't send", async () => { + const { user, socket, composer } = await setup(); + await user.type(composer, "line one{Shift>}{Enter}{/Shift}line two"); + expect(composer).toHaveValue("line one\nline two"); + await user.clear(composer); + await user.type(composer, " {Enter}"); + expect(sent(socket, "chat_send")).toHaveLength(0); + await user.clear(composer); + await user.click(composer); + await user.paste("x".repeat(2001)); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + expect(screen.getByText("2001 / 2000")).toBeInTheDocument(); + }); + + test("a failed send can be retried, taken back or dropped", async () => { + 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; + 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" }); + await user.type(composer, "draft"); + await user.click(screen.getByRole("button", { name: "Edit" })); + expect(composer).toHaveValue("draft\n\noops"); + expect(screen.queryByText("Still no")).not.toBeInTheDocument(); + }); + + test("typing is announced, and its end too", async () => { + 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 }]); + act(() => vi.advanceTimersByTime(3_600)); + 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?" })]); + 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"); + }); +}); + +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) }); + const mine = () => + 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; + 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"); + 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" }); + }); + + 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(); + expect(within(m).getByRole("button", { name: "Edit" })).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(); + }); + + test("Up in an empty composer edits your last message; Enter saves", async () => { + const { user, socket, composer } = await setup([theirs(), mine()]); + await user.click(composer); + await user.keyboard("{ArrowUp}"); + 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(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; + 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(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; + await user.click(within(m).getByRole("button", { name: "Delete" })); + 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" }]); + socket.receive({ type: "chat_message_deleted", id: "m1" }); + expect(screen.queryByText("my words")).not.toBeInTheDocument(); + }); + + 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" }), + ]); + socket.receive({ type: "chat_message_deleted", id: "t1" }); + expect(screen.getByText("deleted message").closest("button")).toBeDisabled(); + }); +}); + +describe("ChatModal: live", () => { + test("new messages arrive, and a mention of me stands out", async () => { + const { socket } = await setup(); + socket.receive({ + type: "chat_message", + message: wire(makeChatMessage({ id: "x", body: "hey @me look", createdAt: new Date() })), + }); + 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("🎉🎉"); + }); + + test("who's typing", async () => { + const { socket } = await setup(); + 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", + users: [ + { userId: "ann", username: "ann", hasAvatar: false }, + { userId: "bob", username: "bob", hasAvatar: false }, + ], + }); + expect(screen.getByText("ann and bob are typing…")).toBeInTheDocument(); + socket.receive({ type: "chat_typing_update", users: [{ userId: "me", username: "me", hasAvatar: false }] }); + expect(screen.queryByText(/typing…/)).not.toBeInTheDocument(); + }); + + test("a failed edit or delete is shown until dismissed", async () => { + const { user, socket } = await setup(); + socket.receive({ type: "error", scope: "chat", message: "Not allowed" }); + expect(screen.getByRole("alert")).toHaveTextContent("Not allowed"); + await user.click(screen.getByRole("button", { name: "Dismiss" })); + expect(screen.queryByText("Not allowed")).not.toBeInTheDocument(); + }); + + 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 { socket } = await setup([old, edited]); + route("GET /api/chat", () => wire([old, { ...edited, body: "after" }])); + socket.drop(); + 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()); + }); +}); diff --git a/src/test/components/CommentThread.test.tsx b/src/test/components/CommentThread.test.tsx new file mode 100644 index 00000000..cb170409 --- /dev/null +++ b/src/test/components/CommentThread.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { CommentThread } from "../../components/CommentThread.tsx"; +import type { AuthResponse, Comment } from "../../model.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { makeComment, makeSession, wire } from "../fixtures.ts"; +import { renderWithContexts } from "../render.tsx"; + +const me = makeSession({ id: "me", username: "me" }); +const mod = makeSession({ id: "mod", username: "mod", role: "moderator" }); + +function setup(initial: Comment[], session: AuthResponse | null = me, ws = {}) { + const user = userEvent.setup(); + function Harness() { + const [comments, setComments] = useState(initial); + return ( + setComments((prev) => [...prev, c])} + onCommentDeleted={(id) => + 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))} + /> + ); + } + renderWithContexts(, { session, 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 })); + +describe("CommentThread", () => { + test("counts visible comments and nests replies", () => { + setup([ + makeComment({ id: "a", body: "top" }), + makeComment({ id: "b", parentId: "a", body: "reply" }), + makeComment({ id: "c", deleted: true, body: "" }), + ]); + 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(); + }); + + 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.getByRole("button", { name: "Like" })).toBeDisabled(); + }); + + test("posting a comment", async () => { + route("POST /api/dumps/:id/comments", created({ id: "new" })); + const user = setup([]); + const box = screen.getByPlaceholderText("Add a comment…"); + expect(screen.getByRole("button", { name: "Post comment" })).toBeDisabled(); + await user.type(box, "First!"); + 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(); + }); + + test("Ctrl+Enter posts too; blank text doesn't", async () => { + route("POST /api/dumps/:id/comments", created({})); + const user = setup([]); + const box = screen.getByPlaceholderText("Add a comment…"); + await user.type(box, " "); + await user.keyboard("{Control>}{Enter}{/Control}"); + 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)); + }); + + test("a failed post keeps the text and says why", async () => { + route("POST /api/dumps/:id/comments", () => fail("Comment too long", 400)); + const user = setup([]); + const box = screen.getByPlaceholderText("Add a comment…"); + await user.type(box, "text"); + await user.click(screen.getByRole("button", { name: "Post comment" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Failed to post comment"); + expect(alert).toHaveTextContent("Comment too long"); + expect(box).toHaveValue("text"); + }); + + test("the top-level Cancel only shows with text, and clears it", async () => { + const user = setup([]); + 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(""); + }); + + test("replying", async () => { + route("POST /api/dumps/:id/comments", created({ id: "r", parentId: "a" })); + const user = setup([makeComment({ id: "a", body: "parent" })]); + await user.click(screen.getByRole("button", { name: "Reply" })); + const box = screen.getByPlaceholderText("Write a reply…"); + 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" }); + const parent = screen.getByText("parent").closest("li")!; + expect(within(parent).getByText("child")).toBeInTheDocument(); + }); + + 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(); + }); + + 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" })]); + await user.click(screen.getByRole("button", { name: "Edit" })); + const box = screen.getByDisplayValue("old"); + await user.clear(box); + await user.type(box, "new"); + await user.click(screen.getByRole("button", { name: "Save" })); + await screen.findByText("new"); + expect(screen.getByText(/^edited/)).toBeInTheDocument(); + }); + + test("saving an edit unchanged doesn't stamp it edited", async () => { + 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()); + expect(calls("PATCH /api/comments/:id")).toHaveLength(0); + }); + + test("only the author or a moderator can edit and delete", () => { + const comments = [ + makeComment({ id: "mine", userId: "me", body: "mine" }), + makeComment({ id: "theirs", userId: "them", body: "theirs" }), + ]; + 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(); + const mine = screen.getByText("mine").closest("li")!; + expect(within(mine).getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); + + test("a moderator can delete anyone's", () => { + setup([makeComment({ userId: "them", body: "theirs" })], mod); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + }); + + test("deleting asks first", async () => { + 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"); + expect(dialog).toHaveTextContent("Delete this comment?"); + await user.click(within(dialog).getByRole("button", { name: "Delete" })); + await screen.findByText("[deleted]"); + }); + + test("a failed delete says so", async () => { + 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); + expect(screen.getByText("stays")).toBeInTheDocument(); + }); + + test("likes come from the live counts", async () => { + const castCommentLike = vi.fn(); + const user = setup([makeComment({ id: "a", likeCount: 1 })], me, { + likeCounts: { a: 5 }, + castCommentLike, + }); + expect(screen.getByText("5")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Like" })); + expect(castCommentLike).toHaveBeenCalledWith("a", 5); + }); + + test("the timestamp links to the comment", () => { + setup([makeComment({ id: "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 new file mode 100644 index 00000000..13ea84b7 --- /dev/null +++ b/src/test/components/DumpCreateModal.test.tsx @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +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 { currentPath, renderWithContexts } from "../render.tsx"; + +const session = makeSession({ username: "me" }); +const categories = [makeCategory({ id: "c1", name: "Music" })]; + +function setup(props: Partial> = {}) { + const onClose = vi.fn(); + const injectDump = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(, { + session, + categories, + ws: { injectDump }, + }); + return { user, onClose, injectDump }; +} + +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") }, + })); + route("GET /api/dumps/by-url", () => dupes); +} + +const nextBtn = () => screen.getByRole("button", { name: "Next →" }); +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(nextBtn()).toBeDisabled(); + await user.type(linkBox(), "not a url at all"); + expect(nextBtn()).toBeDisabled(); + }); + + test("previews the page and suggests its title", async () => { + previewRoutes(); + const { user } = setup(); + await user.type(linkBox(), "example.com/a"); + expect(await screen.findByText("Fetching preview…")).toBeInTheDocument(); + // The suggestion is written in an effect just after the field appears. + const title = await screen.findByLabelText("Title"); + await waitFor(() => expect(title).toHaveValue("Page title")); + expect(title).toBeDisabled(); + const [req] = calls("GET /api/preview"); + expect(req.url.searchParams.get("url")).toBe("https://example.com/a"); + expect(req.headers.get("Authorization")).toMatch(/^Bearer /); + }); + + test("an unreachable page says so, but can still be posted", async () => { + previewRoutes({ reached: false }); + const { user } = setup(); + await user.type(linkBox(), "https://down.test/"); + 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 }, + ], + }); + 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"); + await user.click(screen.getByRole("link", { name: "Old one" })); + expect(currentPath()).toBe("/dumps/old-one"); + expect(onClose).toHaveBeenCalled(); + }); + + 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 }); + 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("POST /api/playlists/:pid/dumps/:did", () => ({})); + const { user, injectDump, onClose } = setup(); + + await user.type(linkBox(), "example.com/a"); + await screen.findByLabelText("Title"); + await user.click(nextBtn()); + + expect(screen.getByText(/example\.com\/a|Page title/)).toBeInTheDocument(); + const why = screen.getByLabelText("Why?"); + await waitFor(() => expect(why).toHaveFocus()); + await user.type(why, " Because "); + await user.click(screen.getByRole("button", { name: "Music" })); + await user.click(screen.getByRole("button", { name: "Private" })); + await user.click(screen.getByRole("button", { name: "Dump it" })); + + await screen.findByRole("dialog", { name: "Add to playlist" }); + expect(calls("POST /api/dumps")[0].body).toEqual({ + url: "https://example.com/a", + comment: "Because", + isPrivate: true, + categoryIds: ["c1"], + }); + expect(injectDump).toHaveBeenCalledWith(expect.objectContaining({ id: "new" })); + expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); + + const row = await screen.findByRole("checkbox", { name: /Mix/ }); + expect(row).toHaveAttribute("aria-checked", "false"); + 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"); + + await user.click(screen.getByRole("button", { name: "Done" })); + expect(onClose).toHaveBeenCalled(); + }); + + 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("POST /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403)); + const { user } = setup(); + await user.type(linkBox(), "https://a.test/{Enter}"); + await user.click(await screen.findByRole("button", { name: "Dump it" })); + const row = await screen.findByRole("checkbox", { name: /Mix/ }); + await user.click(row); + await act(async () => {}); + expect(row).toHaveAttribute("aria-checked", "false"); + }); + + test("a title typed by the poster is sent; a suggested one isn't", async () => { + previewRoutes(); + route("POST /api/dumps", () => wire(makeDump())); + 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 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" })); + expect(title).toHaveValue("Page title"); + await user.click(screen.getByRole("button", { name: "Edit title" })); + await user.clear(title); + await user.type(title, "Mine after all"); + 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" }); + }); + + test("Enter in the link field goes to the next step rather than posting", async () => { + previewRoutes(); + const { user } = setup(); + await user.type(linkBox(), "https://a.test/{Enter}"); + expect(screen.getByLabelText("Why?")).toBeInTheDocument(); + expect(calls("POST /api/dumps")).toHaveLength(0); + await user.click(screen.getByRole("button", { name: "← Back" })); + expect(linkBox()).toHaveValue("https://a.test/"); + }); + + test("a failed post shows the server's message and stays put", async () => { + previewRoutes(); + route("POST /api/dumps", () => fail("That URL isn't allowed", 400)); + const { user } = setup(); + await user.type(linkBox(), "https://a.test/{Enter}"); + await user.click(screen.getByRole("button", { name: "Dump it" })); + 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(); + }); + + test("starts from a shared URL", async () => { + previewRoutes(); + setup({ initialUrl: "https://shared.test/x" }); + expect(linkBox()).toHaveValue("https://shared.test/x"); + await screen.findByLabelText("Title"); + }); + + test("a link pasted anywhere in the modal lands in the link field", async () => { + previewRoutes(); + setup(); + act(() => { + const e = new Event("paste", { bubbles: true }) as ClipboardEvent; + Object.defineProperty(e, "clipboardData", { + value: { files: [], getData: () => "pasted.test/page" }, + }); + screen.getByRole("dialog").dispatchEvent(e); + }); + expect(linkBox()).toHaveValue("https://pasted.test/page"); + }); +}); + +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"], + })); + previewRoutes(); + const { user } = setup(); + 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"); + await user.click(screen.getByRole("button", { name: "← Back" })); + await user.click(screen.getByRole("button", { name: "Start fresh" })); + expect(linkBox()).toHaveValue(""); + expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); + }); + + test("what's typed is kept as it's typed", async () => { + previewRoutes(); + const { user } = setup(); + await user.type(linkBox(), "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" })); + previewRoutes(); + setup({ initialUrl: "https://new.test/" }); + expect(screen.queryByText("Picked up where you left off.")).not.toBeInTheDocument(); + expect(linkBox()).toHaveValue("https://new.test/"); + }); +}); + +describe("DumpCreateModal: a file", () => { + class FakeXHR { + static last: FakeXHR; + status = 0; + statusText = ""; + responseText = ""; + 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) }; + constructor() { + FakeXHR.last = this; + } + open() {} + setRequestHeader() {} + addEventListener(t: string, f: (e: unknown) => void) { + (this.l[t] ??= []).push(f); + } + send(body: FormData) { + this.body = body; + } + progress(f: number) { + this.u.progress?.forEach((fn) => fn({ loaded: f * 100, total: 100, lengthComputable: true })); + } + respond(status: number, data: unknown) { + this.status = status; + this.responseText = JSON.stringify(data); + this.l.load?.forEach((fn) => fn({})); + } + } + afterEach(() => vi.unstubAllGlobals()); + + 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); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + route("GET /api/playlists/by-dump/:id/memberships", () => []); + const { user } = setup(); + fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { + dataTransfer: { files: [song()] }, + }); + 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(); + 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 screen.findByRole("dialog", { name: "Add to playlist" }); + }); + + test("closing with a file attached asks first", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); + const { user, onClose } = setup(); + fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { + dataTransfer: { files: [song()] }, + }); + 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(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.click(screen.getByRole("button", { name: "Close anyway" })); + expect(onClose).toHaveBeenCalled(); + }); + + test("without a file, closing just closes", async () => { + const { user, onClose } = setup(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); + + test("removing the file offers the link field again", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:x"); + const { user } = setup(); + fireEvent.drop(screen.getByRole("dialog").querySelector(".dump-create")!, { + dataTransfer: { files: [song()] }, + }); + await user.click(screen.getByRole("button", { name: "Remove file" })); + expect(linkBox()).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/Modal.test.tsx b/src/test/components/Modal.test.tsx new file mode 100644 index 00000000..f83c89d1 --- /dev/null +++ b/src/test/components/Modal.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, test, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { useState } from "react"; +import { Modal } from "../../components/Modal.tsx"; +import { ConfirmModal } from "../../components/ConfirmModal.tsx"; + +const wrap = (ui: React.ReactNode) => + render({ui}); + +describe("Modal", () => { + test("is a labelled dialog that locks the page's scroll", () => { + wrap( {}}>body); + expect(screen.getByRole("dialog", { name: "Settings" })).toHaveTextContent( + "body", + ); + expect(document.documentElement.style.overflowY).toBe("hidden"); + }); + + test("closes on Escape, the backdrop and the ✕ — not a click inside", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap(); + await user.click(screen.getByText("inside")); + expect(onClose).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Close" })); + await user.click(document.querySelector(".modal-backdrop")!); + expect(onClose).toHaveBeenCalledTimes(3); + }); + + test("onBeforeClose can refuse", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap( false}>x); + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "Close" })); + expect(onClose).not.toHaveBeenCalled(); + }); + + test("an Escape something inside already handled is left alone", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + wrap( + + e.key === "Escape" && e.preventDefault()} /> + , + ); + await user.click(screen.getByRole("textbox")); + await user.keyboard("{Escape}"); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe("ConfirmModal", () => { + test("asks, confirms and cancels", async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const user = userEvent.setup(); + wrap( + , + ); + const dialog = screen.getByRole("alertdialog"); + expect(dialog).toHaveAccessibleDescription("Really?"); + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(onConfirm).toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await user.keyboard("{Escape}"); + await user.click(document.querySelector(".modal-backdrop")!); + expect(onCancel).toHaveBeenCalledTimes(3); + }); + + test("takes a custom label", () => { + wrap( + {}} onCancel={() => {}} />, + ); + expect(screen.getByRole("button", { name: "Discard" })).toBeInTheDocument(); + }); + + test("Escape on a confirm over a modal closes only the confirm", async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + function Stacked() { + const [confirming, setConfirming] = useState(true); + return ( + + {confirming && ( + {}} + onCancel={() => setConfirming(false)} + /> + )} + + ); + } + wrap(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Chat" })).toBeInTheDocument(); + }); +}); diff --git a/src/test/components/TextEditor.test.tsx b/src/test/components/TextEditor.test.tsx new file mode 100644 index 00000000..07f51048 --- /dev/null +++ b/src/test/components/TextEditor.test.tsx @@ -0,0 +1,295 @@ +import { describe, expect, test, vi } from "vitest"; +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 { calls, deferred, fail, route } from "../fetch.ts"; +import { makeSession } from "../fixtures.ts"; +import { renderWithContexts } from "../render.tsx"; +import { serveEmojibase } from "../emojibase.ts"; + +type Props = Partial>; + +function setup(props: Props = {}, initial = "") { + const onChange = vi.fn(); + function Harness() { + const [value, setValue] = useState(initial); + return ( + { + setValue(v); + onChange(v); + }} + {...props} + /> + ); + } + const user = userEvent.setup(); + renderWithContexts(, { session: makeSession() }); + const box = screen.getByRole("textbox") as HTMLTextAreaElement; + return { user, box, onChange }; +} + +function imageFile(name = "a.png", type = "image/png") { + return new File([new Uint8Array([1, 2, 3])], name, { type }); +} + +describe("TextEditor basics", () => { + test("types through to onChange", async () => { + const { user, box, onChange } = setup({ placeholder: "Say something" }); + expect(box).toHaveAttribute("placeholder", "Say something"); + await user.type(box, "hello"); + expect(onChange).toHaveBeenLastCalledWith("hello"); + expect(box).toHaveValue("hello"); + }); + + test("Ctrl/Cmd+Enter submits; plain Enter goes to onKeyDown", async () => { + const onSubmit = vi.fn(); + const onKeyDown = vi.fn(); + const { user, box } = setup({ onSubmit, onKeyDown }); + await user.type(box, "x"); + await user.keyboard("{Control>}{Enter}{/Control}"); + expect(onSubmit).toHaveBeenCalledTimes(1); + await user.keyboard("{Meta>}{Enter}{/Meta}"); + expect(onSubmit).toHaveBeenCalledTimes(2); + await user.keyboard("{Enter}"); + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + }); + + test("an Enter confirming an IME candidate does nothing else", () => { + const onSubmit = vi.fn(); + const onKeyDown = vi.fn(); + const { box } = setup({ onSubmit, onKeyDown }); + fireEvent.keyDown(box, { key: "Enter", ctrlKey: true, isComposing: true }); + fireEvent.keyDown(box, { key: "Enter", keyCode: 229 }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onKeyDown).not.toHaveBeenCalled(); + }); + + test("Escape cancels when there's nothing of its own to close", async () => { + const onCancel = vi.fn(); + const { user, box } = setup({ onCancel }); + await user.click(box); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalled(); + }); + + test("the counter shows from 80% of the limit and flags going over", async () => { + const { user, box } = setup({ maxLength: 10 }, "1234567"); + expect(screen.queryByText(/\/ 10/)).not.toBeInTheDocument(); + 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(box).toHaveAttribute("aria-invalid", "true"); + }); + + test("countFrom overrides when the counter appears", () => { + setup({ maxLength: 100, countFrom: 0 }); + expect(screen.getByText("0 / 100")).toBeInTheDocument(); + }); + + test("the handle focuses at the end", () => { + const ref = createRef(); + renderWithContexts( {}} />); + act(() => ref.current!.focus({ atEnd: true })); + const box = screen.getByRole("textbox") as HTMLTextAreaElement; + expect(box).toHaveFocus(); + expect(box.selectionStart).toBe(3); + }); + + test("disabled", () => { + setup({ disabled: true }); + expect(screen.getByRole("textbox")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Insert emoji" })).toBeDisabled(); + }); +}); + +describe("mentions", () => { + const users = [ + { id: "1", username: "alice", avatarMime: null }, + { id: "2", username: "alex", avatarMime: null }, + ]; + + test("@ opens a listbox; Enter picks and adds a space", async () => { + route("GET /api/users/search", () => users); + const { user, box } = setup(); + await user.type(box, "hi @al"); + const list = await screen.findByRole("listbox"); + expect(within(list).getAllByRole("option")).toHaveLength(2); + expect(box).toHaveAttribute("aria-activedescendant"); + await user.keyboard("{ArrowDown}{Enter}"); + expect(box).toHaveValue("hi @alex "); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + + test("clicking a suggestion picks it", async () => { + route("GET /api/users/search", () => users); + const { user, box } = setup(); + await user.type(box, "@ali"); + await user.click(await screen.findByRole("option", { name: /alice/ })); + expect(box).toHaveValue("@alice "); + }); + + test("Escape closes the list, and only a second Escape cancels", async () => { + route("GET /api/users/search", () => users); + const onCancel = vi.fn(); + const { user, box } = setup({ onCancel }); + await user.type(box, "@al"); + await screen.findByRole("listbox"); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(onCancel).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalled(); + }); + + test("Enter with the list open picks rather than submitting", async () => { + route("GET /api/users/search", () => users); + const onKeyDown = vi.fn(); + const { user, box } = setup({ onKeyDown }); + await user.type(box, "@al"); + await screen.findByRole("listbox"); + await user.keyboard("{Enter}"); + expect(onKeyDown).not.toHaveBeenCalledWith(expect.objectContaining({ key: "Enter" })); + expect(box).toHaveValue("@alice "); + }); + + test("leaving the field closes the list", async () => { + route("GET /api/users/search", () => users); + const { user, box } = setup(); + await user.type(box, "@al"); + await screen.findByRole("listbox"); + fireEvent.blur(box); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); +}); + +describe("image upload", () => { + test("a pasted image is uploaded and inserted as markdown", async () => { + route("POST /api/attachments", () => ({ id: "att1" })); + const { user, box } = setup({}, "look: "); + await user.click(box); + box.setSelectionRange(6, 6); + fireEvent.paste(box, { clipboardData: { types: ["Files"], files: [imageFile()] } }); + await waitFor(() => + expect(box).toHaveValue("look: ![](http://localhost:8000/api/attachments/att1)") + ); + const [req] = calls("POST /api/attachments"); + expect(req.body).toBeInstanceOf(FormData); + expect(req.headers.get("Authorization")).toMatch(/^Bearer /); + }); + + test("a paste carrying text pastes the text", () => { + const { box } = setup(); + const event = fireEvent.paste(box, { + clipboardData: { types: ["text/plain", "Files"], files: [imageFile()] }, + }); + expect(event).toBe(true); // not prevented + expect(calls("POST /api/attachments")).toHaveLength(0); + }); + + test("while uploading: read-only, says so, and holds Enter", async () => { + const pending = deferred(); + 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…"); + 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" } }))) + ); + await waitFor(() => expect(box).not.toHaveAttribute("readonly")); + }); + + 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."); + 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 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(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)}` })); + const { box } = setup(); + 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")] }, + }); + await waitFor(() => expect(box.value).toContain("attachments/n1")); + expect(box.value.split("\n")).toHaveLength(2); + }); +}); + +describe("emoji", () => { + test("':' and two letters opens the picker with that query; Escape closes only it", async () => { + serveEmojibase(); + const onCancel = vi.fn(); + const { user, box } = setup({ onCancel }); + await user.type(box, "hi :astro"); + 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(onCancel).not.toHaveBeenCalled(); + expect(box).toHaveValue("hi :astro"); + }); + + test("Enter takes the best match and replaces the :query", async () => { + serveEmojibase(); + const { user, box } = setup(); + await user.type(box, "go :astronaut"); + await screen.findByRole("gridcell", { name: "Astronaut" }); + await user.keyboard("{Enter}"); + expect(box).toHaveValue("go 🧑‍🚀 "); + }); + + test("emoticons and times don't open it", async () => { + serveEmojibase(); + const { user, box } = setup(); + await user.type(box, "ok :D at 12:30"); + expect(screen.queryByRole("searchbox", { name: "Search emoji" })).not.toBeInTheDocument(); + }); + + test("the button browses and inserts at the caret", async () => { + serveEmojibase(); + const { user, box } = setup({}, "ab"); + 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" }); + await waitFor(() => expect(search).toHaveFocus()); + await user.type(search, "astronaut"); + await user.click(await screen.findByRole("gridcell", { name: "Astronaut" })); + expect(box).toHaveValue("a🧑‍🚀b"); + }); + + test("a press outside puts the picker away", async () => { + serveEmojibase(); + const { user } = setup(); + 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(); + }); +}); diff --git a/src/test/components/buttons.test.tsx b/src/test/components/buttons.test.tsx new file mode 100644 index 00000000..362f0c3f --- /dev/null +++ b/src/test/components/buttons.test.tsx @@ -0,0 +1,292 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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 { UserListPopover } from "../../components/UserListPopover.tsx"; +import { NotificationBell } from "../../components/NotificationBell.tsx"; +import { ChatButton } from "../../components/ChatButton.tsx"; +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"; + +describe("Avatar", () => { + test("an image when the user has one, versioned", () => { + renderWithContexts(); + expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute( + "src", + "http://localhost:8000/api/avatars/u1?v=7", + ); + }); + + test("initials otherwise, and after the image fails", () => { + renderWithContexts(); + fireEvent.error(screen.getByRole("img")); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByTitle("bob")).toHaveTextContent("B"); + }); + + 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")); + }); +}); + +describe("VoteButton / LikeButton", () => { + test("upvote and remove, passing the count on screen", async () => { + const onCast = vi.fn(); + 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(); + await user.click(screen.getByRole("button", { name: "Remove vote" })); + expect(onRemove).toHaveBeenCalledWith("d", 3); + }); + + test("disabled for guests, with a hint", () => { + renderWithContexts( + {}} onRemove={() => {}} />, + ); + const btn = screen.getByRole("button", { name: "Upvote" }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute("title", "Log in to vote"); + }); + + test("the count opens who voted, without voting", async () => { + const onCast = vi.fn(); + 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", + ); + }); + + test("likes work the same way", async () => { + const onLike = vi.fn(); + const onUnlike = vi.fn(); + 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(); + await user.click(screen.getByRole("button", { name: "Remove like" })); + expect(onUnlike).toHaveBeenCalledWith("c", 2); + }); +}); + +describe("UserListPopover", () => { + function Harness({ onClose = () => {} }: { onClose?: () => void }) { + const ref = useRef(null); + return ( + <> + 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)); + 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 /); + }); + + test("shows the server's error, and a network one", async () => { + route("GET /api/x/voters", () => fail("Dump not found", 404), { once: true }); + const first = renderWithContexts(); + await screen.findByText("Dump not found"); + first.unmount(); + route("GET /api/x/voters", () => new TypeError("offline")); + renderWithContexts(); + await screen.findByText("Could not load."); + }); + + 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", () => new TypeError("offline"), { once: true }); + route("GET /api/x/voters", () => page([makeUser({ username: "b" })], false)); + const user = userEvent.setup(); + renderWithContexts(); + await screen.findByText("a"); + await user.click(screen.getByRole("button", { name: "Load more" })); + const retry = await screen.findByRole("button", { name: "Load more" }); + expect(retry).toBeEnabled(); + await user.click(retry); + await screen.findByText("b"); + }); + + test("closes on Escape, an outside press and a scroll — not on the anchor", async () => { + route("GET /api/x/voters", () => page([])); + const onClose = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + await screen.findByText("No one yet."); + await user.click(screen.getByText("anchor")); + await user.click(screen.getByText("No one yet.")); + expect(onClose).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await user.click(screen.getByText("outside")); + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + expect(onClose).toHaveBeenCalledTimes(3); + }); +}); + +describe("FollowButton", () => { + const me = makeSession({ id: "me" }); + + test("hidden for guests and on yourself", () => { + const { unmount } = renderWithContexts( + , + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + unmount(); + renderWithContexts(, { session: me }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("follow and unfollow a user", async () => { + const followUser = vi.fn(); + const unfollowUser = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + { session: me, follows: { followUser, unfollowUser } }, + ); + const btn = screen.getByRole("button", { name: "Follow ann" }); + expect(btn).toHaveTextContent("Follow"); + await user.click(btn); + expect(followUser).toHaveBeenCalledWith("u"); + expect(unfollowUser).not.toHaveBeenCalled(); + }); + + test("shows Following, and unfollows", async () => { + const unfollowUser = vi.fn(); + const user = userEvent.setup(); + 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); + expect(unfollowUser).toHaveBeenCalledWith("u"); + }); + + test("disabled until follows have loaded", () => { + renderWithContexts(, { + session: me, + follows: { isLoaded: false }, + }); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + test("playlists: only public ones can be followed", async () => { + const followPlaylist = vi.fn(); + const unfollowPlaylist = vi.fn(); + const user = userEvent.setup(); + const priv = renderWithContexts( + , + { session: me }, + ); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + priv.unmount(); + renderWithContexts(, { + session: me, + follows: { followPlaylist, unfollowPlaylist, followedPlaylistIds: new Set(["p"]) }, + }); + await user.click(screen.getByRole("button", { name: "Unfollow playlist" })); + expect(unfollowPlaylist).toHaveBeenCalledWith("p"); + }); +}); + +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)" }); + expect(bell).toHaveTextContent("99+"); + await user.click(bell); + expect(currentPath()).toBe("/notifications"); + }); + + test("the bell rings for a new notification, not for one already there", () => { + vi.useFakeTimers(); + try { + const n1 = makeNotification({ id: "n1" }); + const withLast = (n: ReturnType) => ( + + + + ); + const { rerender } = renderWithContexts(withLast(n1)); + const bell = () => screen.getByRole("button", { name: "Notifications" }); + act(() => vi.advanceTimersByTime(10)); + expect(bell()).not.toHaveClass("notification-bell--ringing"); + rerender(withLast(makeNotification({ id: "n2" }))); + act(() => vi.advanceTimersByTime(10)); + expect(bell()).toHaveClass("notification-bell--ringing"); + act(() => vi.advanceTimersByTime(700)); + expect(bell()).not.toHaveClass("notification-bell--ringing"); + } finally { + vi.useRealTimers(); + } + }); + + test("the chat button opens the chat and shows unread", async () => { + const openChat = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + {} }}> + + , + { ws: { unreadChatCount: 4 } }, + ); + await user.click(screen.getByRole("button", { name: "Chat (4 unread)" })); + expect(openChat).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByText("4")).toBeInTheDocument()); + }); +}); diff --git a/src/test/components/content.test.tsx b/src/test/components/content.test.tsx new file mode 100644 index 00000000..d44b801f --- /dev/null +++ b/src/test/components/content.test.tsx @@ -0,0 +1,307 @@ +import { describe, expect, test, vi } from "vitest"; +import { fireEvent, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Markdown } from "../../components/Markdown.tsx"; +import Thumbnail from "../../components/Thumbnail.tsx"; +import ThumbnailPlaceholder from "../../components/ThumbnailPlaceholder.tsx"; +import RichContentCard from "../../components/RichContentCard.tsx"; +import { DumpCard } from "../../components/DumpCard.tsx"; +import { hueFromString } from "../../utils/thumbnailTint.ts"; +import { markDumpVisited } from "../../utils/visited.ts"; +import { makeCategory, makeDump, makeSession } from "../fixtures.ts"; +import { currentPath, renderWithContexts } from "../render.tsx"; + +describe("Markdown", () => { + const md = (text: string, inline = false) => + renderWithContexts({text}); + + test("renders GFM", () => { + 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("checkbox")).toBeChecked(); + }); + + test("@mentions link to the profile, in-app", async () => { + md("hi @ann and bob@mail.com"); + const link = screen.getByRole("link", { name: "@ann" }); + expect(link).toHaveAttribute("href", "/users/ann"); + expect(link).not.toHaveAttribute("target"); + // GFM autolinks the address itself; it isn't a mention of "mail". + expect(screen.getByRole("link", { name: "bob@mail.com" })).toHaveAttribute( + "href", + "mailto:bob@mail.com", + ); + expect(document.querySelector('a[href="/users/mail"]')).toBeNull(); + await userEvent.click(link); + expect(currentPath()).toBe("/users/ann"); + }); + + test("mentions in code and inside URLs are left alone", () => { + md("`@ann` and https://mastodon.social/@bob"); + expect(screen.getByText("@ann").tagName).toBe("CODE"); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + "https://mastodon.social/@bob", + ); + }); + + test("external links open in a new tab; internal and same-origin ones don't", () => { + 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"); + }); + + test("a single newline is a line break", () => { + const { container } = md("one\ntwo\nthree"); + expect(container.querySelectorAll("br")).toHaveLength(2); + }); + + test("code blocks keep their newlines", () => { + const { container } = md("```\na\nb\n```"); + expect(container.querySelector("pre")!.textContent).toBe("a\nb\n"); + expect(container.querySelectorAll("br")).toHaveLength(0); + }); + + 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"); + full.unmount(); + md("![cat](https://x.test/cat.png) text", true); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("text")).toBeInTheDocument(); + }); + + test("raw HTML is not rendered", () => { + const { container } = md("x"); + expect(container.querySelector("script")).toBeNull(); + expect(container.querySelector("b")).toBeNull(); + }); + + test("javascript: links are neutralised", () => { + md("[click](javascript:alert(1))"); + const link = screen.getByText("click").closest("a"); + expect(link?.getAttribute("href") ?? "").not.toMatch(/^javascript:/i); + }); +}); + +describe("Thumbnail", () => { + const placeholder = { url: "https://site.test/page", siteName: "Site" }; + + 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=", + ); + fireEvent.error(screen.getByRole("img", { name: "art" })); + expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument(); + expect(screen.getByText("S")).toBeInTheDocument(); + }); + + 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(); + expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument(); + }); +}); + +describe("ThumbnailPlaceholder", () => { + test("tints by the page colour, or by hostname", () => { + const { container, rerender } = renderWithContexts( + , + ); + 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")); + }); + + 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); + expect(container.querySelector("img")).toBeNull(); + expect(screen.getByText("S")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("🎬")).toBeInTheDocument(); + }); +}); + +describe("RichContentCard", () => { + const yt = { + type: "youtube", + url: "https://youtube.com/watch?v=x", + title: "A video", + siteName: "YouTube", + description: "About it", + thumbnailUrl: "https://i.ytimg.com/x.jpg", + embedUrl: "https://www.youtube.com/embed/x", + }; + + 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); + expect(screen.getByText("About it")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith(expect.objectContaining({ + kind: "embed", + embedUrl: yt.embedUrl, + dumpHref: "/dumps/d", + })); + }); + + test("compact: shows Pause while this card plays", () => { + renderWithContexts(, { + player: { + playing: true, + current: { kind: "embed", embedUrl: yt.embedUrl, type: "youtube" }, + }, + }); + 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", + "https://blog.test/p", + ); + }); + + test("a dump thumbnail overrides the provider's", () => { + renderWithContexts( + , + ); + expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute( + "src", + "http://localhost:8000/api/thumbnails/d", + ); + }); + + 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" }; + renderWithContexts(, { + player: { + togglePlay, + play, + playing: true, + current: { + kind: "stream", + type: "bandcamp", + streamUrl: "s", + resolveUrl: bc.url, + resolveIndex: 0, + resolvedAt: 0, + }, + }, + }); + await userEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(togglePlay).toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + }); +}); + +describe("DumpCard", () => { + const props = { + voteCount: 3, + voted: false, + canVote: true, + castVote: vi.fn(), + removeVote: vi.fn(), + }; + + test("title, teaser, date, comment count and categories", () => { + const dump = makeDump({ + id: "d1", + slug: "nice", + title: "Nice find", + comment: "Look **here**", + 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"); + 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"); + }); + + test("an unread dot until visited, never on your own", () => { + const dump = makeDump({ id: "d2" }); + const first = renderWithContexts(
); + expect(first.container.querySelector(".unread-dot")).not.toBeNull(); + first.unmount(); + const own = renderWithContexts(
); + expect(own.container.querySelector(".unread-dot")).toBeNull(); + own.unmount(); + markDumpVisited("d2"); + const seen = renderWithContexts(
); + expect(seen.container.querySelector(".unread-dot")).toBeNull(); + }); + + test("the private badge shows to the owner", () => { + renderWithContexts( +
, + ); + expect(screen.getByText("private")).toBeInTheDocument(); + }); + + test("clicking the card opens the dump; voting doesn't", async () => { + const castVote = vi.fn(); + const dump = makeDump({ id: "d3" }); + renderWithContexts( +
, + { session: makeSession() }, + ); + await userEvent.click(screen.getByRole("button", { name: "Upvote" })); + expect(castVote).toHaveBeenCalledWith("d3", 3); + expect(currentPath()).toBe("/"); + await userEvent.click(screen.getByText("1 minute ago")); + expect(currentPath()).toBe("/dumps/d3"); + }); + + test("file dumps get a file preview; bare URLs a placeholder", () => { + const { container } = renderWithContexts( +
    + + +
, + ); + 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 new file mode 100644 index 00000000..6b89f891 --- /dev/null +++ b/src/test/components/header.test.tsx @@ -0,0 +1,430 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { BrowserRouter, Route, Routes, useLocation } from "react-router"; +import { useState } from "react"; +import { SearchBar } from "../../components/SearchBar.tsx"; +import { UserMenu } from "../../components/UserMenu.tsx"; +import { PresenceRow } from "../../components/PresenceRow.tsx"; +import { CategorySwitcher } from "../../components/CategorySwitcher.tsx"; +import { CategorySelect } from "../../components/CategorySelect.tsx"; +import { TabBar } from "../../components/TabBar.tsx"; +import { FeedTabBar } from "../../components/FeedTabBar.tsx"; +import { AppHeader } from "../../components/AppHeader.tsx"; +import { ChatFab } from "../../components/ChatFab.tsx"; +import { DumpFab } from "../../components/DumpFab.tsx"; +import { Tooltip } from "../../components/Tooltip.tsx"; +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 type { AuthResponse, Category } from "../../model.ts"; +import { makeCategory, makeSession, makeUser } from "../fixtures.ts"; +import { currentPath, defaultOf, renderWithContexts } from "../render.tsx"; + +const categories: Category[] = [ + makeCategory({ id: "c1", slug: "music", name: "Music" }), + makeCategory({ id: "c2", slug: "cinema", name: "Cinema" }), +]; + +describe("SearchBar", () => { + function Harness(props: Partial[0]>) { + const [value, setValue] = useState(""); + return ( + {}} + onCancel={() => {}} + {...props} + /> + ); + } + + test("submits the trimmed query; ignores a blank one", async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + const input = screen.getByRole("searchbox", { name: "Search" }); + await user.type(input, " {Enter}"); + expect(onSubmit).not.toHaveBeenCalled(); + await user.type(input, " cats {Enter}"); + expect(onSubmit).toHaveBeenCalledWith("cats"); + expect(input).not.toHaveFocus(); + }); + + test("Escape cancels instead of clearing", async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + renderWithContexts(); + await user.type(screen.getByRole("searchbox"), "dogs{Escape}"); + expect(onCancel).toHaveBeenCalled(); + expect(screen.getByRole("searchbox")).toHaveValue("dogs"); + }); + + test("takes focus when it opens, not when it mounts open", () => { + const props = { value: "", onValueChange: () => {}, onSubmit: () => {}, onCancel: () => {} }; + const { rerender } = renderWithContexts(); + expect(screen.getByRole("searchbox")).not.toHaveFocus(); + rerender(); + expect(screen.getByRole("searchbox")).toHaveAttribute("tabindex", "-1"); + rerender(); + expect(screen.getByRole("searchbox")).toHaveFocus(); + rerender(); + expect(screen.getByRole("searchbox")).not.toHaveFocus(); + }); +}); + +describe("UserMenu", () => { + test("opens, links to profile and playlists, closes on Escape and outside", async () => { + const user = userEvent.setup(); + renderWithContexts( + <> + +

outside

+ , + ); + 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: "Playlists" })).toHaveAttribute( + "href", + "/users/ann/playlists", + ); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + await user.click(trigger); + await user.click(screen.getByText("outside")); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + await user.click(trigger); + await user.click(screen.getByRole("menuitem", { name: "@ann" })); + expect(currentPath()).toBe("/users/ann"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); +}); + +describe("PresenceRow", () => { + const online = (n: number) => + Array.from({ length: n }, (_, i) => ({ + userId: `u${i}`, + username: `user${i}`, + hasAvatar: false, + })); + + test("nothing when nobody's online", () => { + renderWithContexts(); + expect(document.querySelector(".index-presence")).toBeNull(); + }); + + test("up to six seats, the rest folded into a counter", () => { + renderWithContexts(, { ws: { onlineUsers: online(9) } }); + expect(screen.getAllByRole("link")).toHaveLength(5); + const more = screen.getByRole("img", { name: /4 more online/ }); + expect(more).toHaveTextContent("+4"); + expect(more).toHaveAttribute("title", "user5, user6, user7, user8"); + }); + + test("six or fewer all show", () => { + renderWithContexts(, { ws: { onlineUsers: online(6) } }); + expect(screen.getAllByRole("link")).toHaveLength(6); + expect(screen.queryByText(/^\+/)).not.toBeInTheDocument(); + }); + + 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); + renderWithContexts( +
+
a
+ +
, + { ws: { onlineUsers: online(9) } }, + ); + expect(screen.getAllByRole("link")).toHaveLength(3); + width.mockReturnValue(0); + act(() => { + globalThis.dispatchEvent(new Event("resize")); + }); + width.mockRestore(); + }); +}); + +describe("CategorySwitcher", () => { + test("hidden with no categories", () => { + renderWithContexts(, { route: "/~/hot", path: "/:x/:feedTab" }); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("switches scope, keeping the tab", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + categories, + route: "/music/new", + path: "/:categorySlug/:feedTab", + }); + const trigger = screen.getByRole("button", { name: "Music" }); + await user.click(trigger); + 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(); + }); + + test("All is the ~ scope; an unknown slug shows as itself", async () => { + const user = userEvent.setup(); + renderWithContexts(, { + categories, + route: "/gone/hot", + path: "/:categorySlug/:feedTab", + }); + await user.click(screen.getByRole("button", { name: "gone" })); + await user.click(screen.getByRole("option", { name: "All" })); + expect(currentPath()).toBe("/~/hot"); + }); + + test("Escape closes the menu", async () => { + const user = userEvent.setup(); + renderWithContexts(, { categories, route: "/~/hot", path: "/~/:feedTab" }); + await user.click(screen.getByRole("button", { name: "All" })); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); +}); + +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"); + await user.click(screen.getByRole("button", { name: "Music" })); + expect(onToggle).toHaveBeenCalledWith("c1"); + }); + + test("nothing without categories", () => { + renderWithContexts( {}} />); + expect(screen.queryByText("Categories")).not.toBeInTheDocument(); + }); +}); + +describe("TabBar / FeedTabBar", () => { + test("TabBar marks the active tab and reports clicks", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + renderWithContexts( + , + ); + expect(screen.getByRole("button", { name: "B" })).toHaveClass("active"); + await user.click(screen.getByRole("button", { name: "A" })); + expect(onChange).toHaveBeenCalledWith("a"); + }); + + 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(); + expect(screen.getByRole("button", { name: "Hot" })).toHaveClass("active"); + await user.click(screen.getByRole("button", { name: "Journal" })); + expect(currentPath()).toBe("/music/journal"); + }); + + test("signed in, Followed is there", () => { + renderWithContexts(, { + session: makeSession(), + route: "/~/followed", + path: "/~/:feedTab", + }); + expect(screen.getByRole("button", { name: "Followed" })).toHaveClass("active"); + }); +}); + +describe("AppHeader", () => { + test("guests get a login button", async () => { + const user = userEvent.setup(); + renderWithContexts(); + await user.click(screen.getByRole("button", { name: "Log in" })); + expect(currentPath()).toBe("/login"); + }); + + 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"); + expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument(); + const dump = screen.getByRole("button", { name: /Dump/ }); + expect(dump).toBeDisabled(); + expect(dump).toHaveAttribute("title", "Server unreachable"); + }); + + test("shows the live-updates banner when disconnected", () => { + renderWithContexts(, { + ws: { wsStatus: "disconnected", wsErrorMessage: "Trying to reconnect…" }, + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "Live updates unavailable. Trying to reconnect…", + ); + }); + + test("the search toggle opens and closes, giving focus back", async () => { + const user = userEvent.setup(); + renderWithContexts(); + const toggle = screen.getByRole("button", { name: "Search" }); + await user.click(toggle); + 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}"); + expect(screen.getByRole("button", { name: "Search" })).toHaveFocus(); + expect(screen.getAllByRole("searchbox")[0]).toHaveValue(""); + }); + + // Leaving search needs the real history stack (React Router keeps its + // index in history.state), so these use a BrowserRouter. + function Browser({ session }: { session?: AuthResponse }) { + const [authResponse, setAuthResponse] = useState(session ?? null); + function Loc() { + const l = useLocation(); + return {l.pathname + l.search}; + } + return ( + + + + + + } /> + + + + + + + ); + } + const loc = () => screen.getByTestId("loc").textContent; + afterEach(() => window.history.replaceState(null, "", "/")); + + test("several searches, then ✕, return to where search began", async () => { + window.history.replaceState(null, "", "/start"); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Search" })); + await user.type(screen.getAllByRole("searchbox")[0], "cats{Enter}"); + expect(loc()).toBe("/search?q=cats"); + const bar = () => screen.getAllByRole("searchbox")[0]; + expect(bar()).toHaveValue("cats"); + await user.clear(bar()); + await user.type(bar(), "dogs{Enter}"); + expect(loc()).toBe("/search?q=dogs"); + await user.click(screen.getByRole("button", { name: "Cancel search" })); + await waitFor(() => expect(loc()).toBe("/start")); + }); + + test("Escape on the results page reverts the query instead of leaving", async () => { + window.history.replaceState(null, "", "/search?q=cats"); + const user = userEvent.setup(); + render(); + const bar = screen.getAllByRole("searchbox")[0]; + expect(bar).toHaveValue("cats"); + await user.type(bar, "zzz{Escape}"); + expect(bar).toHaveValue("cats"); + expect(loc()).toBe("/search?q=cats"); + }); + + test("✕ on results that opened the session goes home", async () => { + window.history.replaceState(null, "", "/search?q=cats"); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Cancel search" })); + expect(loc()).toBe("/"); + }); +}); + +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(); + }); + + test("they show once the header scrolls away", () => { + const openChat = vi.fn(); + renderWithContexts( + withChat(openChat)( + <> +
+ + + , + ), + { session: makeSession(), ws: { unreadChatCount: 2 } }, + ); + const header = document.querySelector(".app-header")!; + // jsdom lays nothing out: put the header on screen first. + header.getBoundingClientRect = () => ({ bottom: 60 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + const chat = document.querySelector(".chat-fab")!; + expect(chat).toHaveAttribute("aria-label", "Chat (2 unread)"); + expect(chat).toHaveAttribute("aria-hidden", "true"); + expect(chat).toHaveAttribute("tabindex", "-1"); + expect(document.body).toHaveClass("has-fab"); + + header.getBoundingClientRect = () => ({ bottom: -10 }) as DOMRect; + act(() => { + globalThis.dispatchEvent(new Event("scroll")); + }); + expect(chat).toHaveAttribute("aria-hidden", "false"); + expect(chat).toHaveClass("chat-fab--visible"); + act(() => chat.click()); + expect(openChat).toHaveBeenCalled(); + 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); + await user.hover(screen.getByText("hover me")); + expect(screen.getByText("Explained")).toBeInTheDocument(); + await user.unhover(screen.getByText("hover me")); + expect(screen.queryByText("Explained")).not.toBeInTheDocument(); + }); + + test("CountedInput counts toward its limit", () => { + renderWithContexts( {}} />); + const count = screen.getByText("90 / 100"); + expect(count).toHaveClass("text-editor-count--warn"); + expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100"); + }); + + test("PageError wraps an alert in the page shell", () => { + renderWithContexts(); + expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong"); + expect(screen.getByRole("alert")).toHaveTextContent("Gone"); + expect(document.querySelector(".app-header")).not.toBeNull(); + }); +}); diff --git a/src/test/components/media.test.tsx b/src/test/components/media.test.tsx new file mode 100644 index 00000000..772888ea --- /dev/null +++ b/src/test/components/media.test.tsx @@ -0,0 +1,225 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MediaPlayer } from "../../components/MediaPlayer.tsx"; +import FilePreview from "../../components/FilePreview.tsx"; +import { makeDump, makeSession } from "../fixtures.ts"; +import { renderWithContexts } from "../render.tsx"; + +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 }); + el.currentTime = currentTime; + fireEvent(el, new Event("durationchange")); + fireEvent(el, new Event("timeupdate")); +} + +describe("MediaPlayer", () => { + test("play and pause through the button, reporting state", async () => { + const onPlayStateChange = vi.fn(); + 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(); + expect(onPlayStateChange).toHaveBeenLastCalledWith(true); + await userEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(pause).toHaveBeenCalled(); + expect(onPlayStateChange).toHaveBeenLastCalledWith(false); + }); + + 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(); + await act(async () => {}); + expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); + }); + + 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")); + expect(screen.getByRole("button", { name: "Play" })).toBeInTheDocument(); + expect(onPlayStateChange).toHaveBeenLastCalledWith(false); + fireEvent(media(container), new Event("play")); + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + expect(onPlayStateChange).toHaveBeenLastCalledWith(true); + }); + + test("time, duration, seeking and the end", async () => { + const onTimeUpdate = vi.fn(); + const onEnded = vi.fn(); + const { container } = renderWithContexts( + , + ); + const el = media(container); + setTime(el, 65, 200); + 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" } }); + expect(el.currentTime).toBe(120); + fireEvent(el, new Event("ended")); + expect(onEnded).toHaveBeenCalled(); + }); + + test("while the seek bar is held, playback doesn't move the thumb — by touch too", () => { + const { container } = renderWithContexts( + , + ); + const el = media(container); + setTime(el, 10, 100); + const seek = screen.getByRole("slider", { name: "Seek" }); + fireEvent.pointerDown(seek, { pointerType: "touch" }); + fireEvent.change(seek, { target: { value: "50" } }); + setTime(el, 11, 100); + expect(screen.getByText("0:50")).toBeInTheDocument(); + fireEvent.pointerUp(seek, { pointerType: "touch" }); + setTime(el, 51, 100); + expect(screen.getByText("0:51")).toBeInTheDocument(); + }); + + test("an error is reported for re-resolving", () => { + const onError = vi.fn(); + const { container } = renderWithContexts( + , + ); + fireEvent(media(container), new Event("error")); + expect(onError).toHaveBeenCalled(); + }); + + test("volume and mute are remembered", async () => { + const { container } = renderWithContexts( + , + ); + 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(screen.getByRole("slider", { name: "Volume" })).toHaveValue("0"); + // Raising the volume unmutes. + fireEvent.change(screen.getByRole("slider", { name: "Volume" }), { target: { value: "0.7" } }); + expect(media(container).muted).toBe(false); + }); + + test("registers seek and toggle handles for the global player", async () => { + 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); + await act(async () => toggleRef.current!()); + expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument(); + unmount(); + expect(seekRef.current).toBeNull(); + }); + + 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); + }); + + test("video has a fullscreen button", () => { + renderWithContexts(); + 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 }); + + test("images show inline, with the media token", () => { + renderWithContexts(, { session }); + expect(screen.getByRole("img", { name: "thing" })).toHaveAttribute( + "src", + `http://localhost:8000/api/files/f1?v=2048&token=${session.mediaToken}`, + ); + }); + + test("other files are a download link", () => { + renderWithContexts(); + const link = screen.getByRole("link", { name: /Download thing/ }); + expect(link).toHaveAttribute("download", "thing"); + expect(link).toHaveTextContent("(2.0 KB)"); + }); + + test("compact audio plays in the global player", async () => { + const play = vi.fn(); + renderWithContexts(, { player: { play } }); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith(expect.objectContaining({ + kind: "file", + mimeType: "audio/mpeg", + title: "Dump f1", + subtitle: "thing", + dumpHref: "/dumps/f1", + artworkUrl: undefined, + })); + }); + + test("compact video claims its generated still as artwork", async () => { + const play = vi.fn(); + 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"); + }); + + test("the global video button toggles when it's the one playing", async () => { + const togglePlay = vi.fn(); + const d = file("video/mp4"); + renderWithContexts(, { + player: { + togglePlay, + playing: true, + current: { kind: "file", fileUrl: "http://localhost:8000/api/files/f1?v=2048", mimeType: "video/mp4" }, + }, + }); + await userEvent.click(screen.getByRole("button", { name: "Pause" })); + expect(togglePlay).toHaveBeenCalled(); + }); + + 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 } }); + await userEvent.click(screen.getByRole("button", { name: "Play" })); + expect(play).toHaveBeenCalledWith(expect.objectContaining({ kind: "file", mimeType: "audio/ogg" })); + }); + + test("compact PDFs and text get an icon", () => { + const { container } = renderWithContexts(); + expect(container).toHaveTextContent("📄"); + }); +}); + +import { route } from "../fetch.ts"; diff --git a/src/test/components/player.test.tsx b/src/test/components/player.test.tsx new file mode 100644 index 00000000..88abed3d --- /dev/null +++ b/src/test/components/player.test.tsx @@ -0,0 +1,242 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useContext } from "react"; +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 { makeDump, makePlaylist } from "../fixtures.ts"; +import { currentPath, renderWithContexts } from "../render.tsx"; +import { markPlaylistVisited } from "../../utils/visited.ts"; + +let player!: React.ContextType; +function Probe() { + player = useContext(PlayerContext); + return null; +} +function renderPlayer() { + return renderWithContexts( + + + + , + ); +} + +const track = (i: number): PlayerItem => ({ + kind: "stream", + type: "bandcamp", + streamUrl: `https://s/${i}`, + resolveUrl: "https://a.bandcamp.com/album/x", + resolveIndex: i, + resolvedAt: Date.now(), + title: `Song ${i}`, + trackNum: i + 1, + duration: 61, + subtitle: "Band", + dumpHref: "/dumps/album", +}); + +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" })); + expect(document.querySelector(".global-player--soundcloud")).not.toBeNull(); + expect(document.body).toHaveClass("has-player"); + act(() => player.stop()); + expect(document.querySelector(".global-player")).toBeNull(); + expect(document.body).not.toHaveClass("has-player"); + }); + + 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, + })); + 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" })); + 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" })); + 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" })); + 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" })); + const src = new URL(document.querySelector("iframe")!.src); + expect(src.searchParams.get("enablejsapi")).toBe("1"); + expect(src.searchParams.get("list")).toBe("PL"); + }); + + test("a queue: title links to the dump, tracklist, transport", async () => { + 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.getByText("Band")).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous track" })).toBeDisabled(); + + const list = screen.getByRole("list"); + expect(within(list).getAllByRole("button")).toHaveLength(3); + expect(within(list).getAllByText("1:01")).toHaveLength(3); + + await user.click(screen.getByRole("button", { name: "Next track" })); + expect(screen.getByText("2 / 3")).toBeInTheDocument(); + await user.click(within(list).getByRole("button", { name: /Song 2/ })); + expect(screen.getByText("3 / 3")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Next track" })).toBeDisabled(); + expect(list.querySelectorAll(".is-current")).toHaveLength(1); + }); + + test("collapse hides the tracklist; a new item opens it again", async () => { + const user = userEvent.setup(); + renderPlayer(); + 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(); + act(() => player.playQueue([track(2), track(3)])); + expect(screen.getByRole("list")).toBeInTheDocument(); + }); + + test("the close button stops playback", async () => { + const user = userEvent.setup(); + renderPlayer(); + act(() => player.play(track(0))); + await user.click(screen.getByRole("button", { name: "Close player" })); + expect(player.current).toBeNull(); + }); + + 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(); + expect(screen.getByText("Clip")).toBeInTheDocument(); + }); + + test("the resolving notice", async () => { + renderPlayer(); + act(() => player.play(track(0))); + // Force a re-resolve that never lands. + const { route, deferred } = await import("../fetch.ts"); + const pending = deferred(); + route("GET /api/bandcamp/tracks", () => pending.promise); + act(() => player.onError()); + expect(screen.getByText("Refreshing stream…")).toBeInTheDocument(); + }); +}); + +describe("JournalCard", () => { + 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[1]).toHaveClass("journal-card--quote"); + expect(within(cards[1] as HTMLElement).getByText("A thought")).toBeInTheDocument(); + expect(cards[2]).toHaveClass("journal-card--text"); + expect(cards[2]).toHaveTextContent("🔗"); + unmount(); + }); + + test("a playable image card plays; an unplayable one navigates", async () => { + const play = vi.fn(); + const user = userEvent.setup(); + const yt = makeDump({ + id: "y", + richContent: { + type: "youtube", + url: "https://youtu.be/y", + thumbnailUrl: "https://i.test/y.png", + embedUrl: "https://www.youtube.com/embed/y", + }, + }); + const pic = makeDump({ id: "p", kind: "file", fileMime: "image/png", fileName: "p.png", url: undefined }); + const { container } = renderWithContexts( +
    + + +
, + { player: { play } }, + ); + 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(currentPath()).toBe("/"); + await user.click(cards[1].querySelector(".journal-card-image")!); + expect(currentPath()).toBe("/dumps/p"); + }); + + test("a video file shows its generated still", () => { + const { container } = renderWithContexts( +
    + +
, + ); + 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"); + expect(screen.getByText("private")).toBeInTheDocument(); + 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")!); + expect(currentPath()).toBe("/playlists/mix"); + }); + + test("the owner doesn't see their own name, or an unread dot", () => { + const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() }); + 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(
); + expect(first.container.querySelector(".unread-dot")).not.toBeNull(); + first.unmount(); + markPlaylistVisited("fresh"); + 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(
); + 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 new file mode 100644 index 00000000..6066115f --- /dev/null +++ b/src/test/contexts/PlayerProvider.test.tsx @@ -0,0 +1,370 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +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 type { AuthResponse } from "../../model.ts"; +import { STREAM_STALE_MS } from "../../utils/streamSources.ts"; +import { calls, deferred, fail, route } from "../fetch.ts"; +import { makeSession } from "../fixtures.ts"; + +const embed = (n: number): PlayerItem => ({ + kind: "embed", + embedUrl: `https://www.youtube.com/embed/v${n}`, + type: "youtube", + title: `Video ${n}`, +}); +const file = (n: number): PlayerItem => ({ + kind: "file", + fileUrl: `http://localhost:8000/api/files/f${n}?token=secret`, + mimeType: "audio/mpeg", +}); +const stream = (i: number, over: Partial = {}) => + ({ + kind: "stream", + type: "bandcamp", + streamUrl: `https://s/${i}`, + resolveUrl: "https://a.bandcamp.com/album/x", + resolveIndex: i, + resolvedAt: Date.now(), + title: `Track ${i}`, + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + ...over, + }) as Extract; + +const albumResponse = (indexes: number[]) => ({ + sourceUrl: "https://a.bandcamp.com/album/x", + itemType: "album", + artist: "Band", + resolvedAt: Date.now(), + tracks: indexes.map((i) => ({ + index: i, + title: `Track ${i}`, + streamUrl: `https://fresh/${i}`, + streamable: true, + capped: false, + })), +}); + +let setSession: (s: AuthResponse | null) => void = () => {}; +function wrapperFor(initial: AuthResponse | null) { + return function Wrapper({ children }: { children: ReactNode }) { + const [authResponse, setAuthResponse] = useState(initial); + setSession = setAuthResponse; + return ( + + {children} + + ); + }; +} + +function setup(session: AuthResponse | null = null) { + const hook = renderHook(() => useContext(PlayerContext), { + wrapper: wrapperFor(session), + }); + return { hook, p: () => hook.result.current }; +} + +const stored = () => JSON.parse(localStorage.getItem("player") ?? "null"); + +afterEach(() => vi.useRealTimers()); + +describe("queue", () => { + test("play() makes a one-item queue that autoplays", () => { + const { p } = setup(); + act(() => p().play(embed(1))); + expect(p().current).toEqual(embed(1)); + expect(p().queue).toHaveLength(1); + expect(p().autoplay).toBe(true); + expect(p().hasNext).toBe(false); + }); + + test("playQueue clamps the start; an empty queue is ignored", () => { + const { p } = setup(); + act(() => p().playQueue([embed(1), embed(2)], 9)); + expect(p().queueIndex).toBe(1); + act(() => p().playQueue([], 0)); + expect(p().queue).toHaveLength(2); + act(() => p().playQueue([embed(1), embed(2)], -3)); + expect(p().queueIndex).toBe(0); + }); + + test("next / previous / playAt", () => { + const { p } = setup(); + act(() => p().playQueue([embed(1), embed(2), embed(3)])); + act(() => p().next()); + expect(p().queueIndex).toBe(1); + expect(p().hasPrevious).toBe(true); + act(() => p().playAt(2)); + expect(p().queueIndex).toBe(2); + act(() => p().next()); + expect(p().queueIndex).toBe(2); + act(() => p().playAt(7)); + expect(p().queueIndex).toBe(2); + act(() => p().previous()); + expect(p().queueIndex).toBe(1); + }); + + test("previous restarts the track past three seconds", () => { + const { p } = setup(); + const seek = vi.fn(); + act(() => p().playQueue([embed(1), embed(2)], 1)); + p().seekRef.current = seek; + act(() => p().onPlayStateChange(true)); + act(() => p().onTimeUpdate(10, 100)); + act(() => p().previous()); + expect(seek).toHaveBeenCalledWith(0); + expect(p().queueIndex).toBe(1); + expect(p().currentTime).toBe(0); + }); + + test("previous on the first track restarts it", () => { + const { p } = setup(); + const seek = vi.fn(); + act(() => p().playQueue([embed(1), embed(2)])); + p().seekRef.current = seek; + act(() => p().previous()); + expect(seek).toHaveBeenCalledWith(0); + }); + + test("picking the playing row restarts it, and resumes it if paused", () => { + const { p } = setup(); + const seek = vi.fn(); + const toggle = vi.fn(); + act(() => p().playQueue([embed(1), embed(2)])); + p().seekRef.current = seek; + p().toggleRef.current = toggle; + act(() => p().onPlayStateChange(true)); + act(() => p().playAt(0)); + expect(seek).toHaveBeenCalledWith(0); + expect(toggle).not.toHaveBeenCalled(); + act(() => p().onPlayStateChange(false)); + act(() => p().playAt(0)); + expect(toggle).toHaveBeenCalledTimes(1); + }); + + test("the end of a track advances; the end of the queue stops", () => { + const { p } = setup(); + act(() => p().playQueue([embed(1), embed(2)])); + act(() => p().onPlayStateChange(true)); + act(() => p().onEnded()); + expect(p().queueIndex).toBe(1); + act(() => p().onPlayStateChange(true)); + act(() => p().onEnded()); + expect(p().queueIndex).toBe(1); + expect(p().playing).toBe(false); + }); + + test("stop empties the player", () => { + const { p } = setup(); + act(() => p().play(embed(1))); + act(() => p().stop()); + expect(p().current).toBeNull(); + expect(stored()).toBeNull(); + }); + + test("stale time updates from the old media are ignored until the new one plays", () => { + const { p } = setup(); + act(() => p().play(embed(1))); + act(() => p().onTimeUpdate(42, 100)); + expect(p().currentTime).toBe(0); + act(() => p().onPlayStateChange(true)); + act(() => p().onTimeUpdate(5, 100)); + expect(p().currentTime).toBe(5); + expect(p().duration).toBe(100); + }); + + test("seekTo and togglePlay reach the registered media", () => { + const { p } = setup(); + const seek = vi.fn(); + const toggle = vi.fn(); + p().seekRef.current = seek; + p().toggleRef.current = toggle; + act(() => p().seekTo(12)); + act(() => p().togglePlay()); + expect(seek).toHaveBeenCalledWith(12); + expect(toggle).toHaveBeenCalled(); + expect(p().currentTime).toBe(12); + }); +}); + +describe("persistence", () => { + test("the queue is saved with its owner, and the time on pagehide", () => { + const session = makeSession({ id: "me" }); + const { p } = setup(session); + act(() => p().playQueue([file(1), file(2)], 1)); + expect(stored()).toMatchObject({ v: 2, index: 1, owner: "me", time: 0 }); + act(() => p().onPlayStateChange(true)); + act(() => p().onTimeUpdate(33, 100)); + globalThis.dispatchEvent(new Event("pagehide")); + expect(stored().time).toBe(33); + }); + + 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 }), + ); + const { p } = setup(); + expect(p().queueIndex).toBe(1); + expect(p().startTime).toBe(17); + expect(p().autoplay).toBe(false); + }); + + test("a v1 single-item session is still read", () => { + localStorage.setItem("player", JSON.stringify({ item: embed(3), time: 4 })); + const { p } = setup(); + expect(p().current).toEqual(embed(3)); + expect(p().startTime).toBe(4); + }); + + 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" }), + ); + const { p } = setup(makeSession({ id: "me" })); + expect(p().current).toBeNull(); + }); + + 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 })); + expect(setup().p().current).toEqual(embed(1)); + }); + + test("junk in storage is ignored", () => { + localStorage.setItem("player", "{nope"); + expect(setup().p().current).toBeNull(); + localStorage.setItem( + "player", + JSON.stringify({ v: 2, queue: [{ kind: "weird" }], index: 0, owner: null }), + ); + expect(setup().p().current).toBeNull(); + }); + + test("signing out stops a signed-in listener's queue", () => { + const { p } = setup(makeSession({ id: "me" })); + act(() => p().play(file(1))); + act(() => setSession(null)); + expect(p().current).toBeNull(); + }); + + test("signing in keeps a guest's queue", () => { + const { p } = setup(null); + act(() => p().play(embed(1))); + act(() => setSession(makeSession({ id: "me" }))); + expect(p().current).toEqual(embed(1)); + expect(stored().owner).toBe("me"); + }); +}); + +describe("expiring streams", () => { + test("a stream error re-resolves and resumes on the same track", async () => { + route("GET /api/bandcamp/tracks", () => albumResponse([0, 1, 2])); + const { p } = setup(); + act(() => p().playQueue([stream(0), stream(1), stream(2)], 1)); + act(() => p().onPlayStateChange(true)); + act(() => p().onTimeUpdate(40, 200)); + act(() => p().onError()); + expect(p().resolving).toBe(true); + await waitFor(() => expect(p().resolving).toBe(false)); + expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" }); + expect(p().startTime).toBe(40); + expect(p().autoplay).toBe(true); + }); + + test("a track gone from the release restarts from the first one", async () => { + route("GET /api/bandcamp/tracks", () => albumResponse([0, 2])); + const { p } = setup(); + act(() => p().playQueue([stream(0), stream(1)], 1)); + act(() => p().onPlayStateChange(true)); + act(() => p().onTimeUpdate(40, 200)); + act(() => p().onError()); + await waitFor(() => expect(p().resolving).toBe(false)); + expect(p().queueIndex).toBe(0); + expect(p().startTime).toBe(0); + }); + + test("a resolve that fails falls back to the embed", async () => { + route("GET /api/bandcamp/tracks", () => fail("down", 502)); + const { p } = setup(); + act(() => p().play(stream(0))); + act(() => p().onError()); + await waitFor(() => expect(p().current?.kind).toBe("embed")); + expect(p().current).toMatchObject({ + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + title: "Track 0", + }); + }); + + test("no embed to fall back to: the player stops", async () => { + route("GET /api/bandcamp/tracks", () => fail("down", 502)); + const { p } = setup(); + act(() => p().play(stream(0, { embedUrl: undefined }))); + act(() => p().onError()); + await waitFor(() => expect(p().current).toBeNull()); + }); + + test("a track that dies again right after a re-resolve is skipped", async () => { + route("GET /api/bandcamp/tracks", () => albumResponse([0, 1])); + const { p } = setup(); + act(() => p().playQueue([stream(0), stream(1)], 0)); + act(() => p().onError()); + await waitFor(() => expect(p().resolving).toBe(false)); + expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1); + act(() => p().onError()); + expect(p().queueIndex).toBe(1); + expect(calls("GET /api/bandcamp/tracks")).toHaveLength(1); + }); + + test("a resolve that lands after the user moved on is dropped", async () => { + const pending = deferred(); + route("GET /api/bandcamp/tracks", () => pending.promise); + const { p } = setup(); + act(() => p().play(stream(0))); + act(() => p().onError()); + act(() => p().play(embed(9))); + await act(async () => + pending.resolve( + new Response(JSON.stringify({ success: true, data: albumResponse([0]) })), + ) + ); + expect(p().current).toEqual(embed(9)); + expect(p().resolving).toBe(false); + }); + + test("errors on non-stream items are ignored", () => { + const { p } = setup(); + act(() => p().play(embed(1))); + act(() => p().onError()); + expect(calls("GET /api/bandcamp/tracks")).toHaveLength(0); + }); + + test("a stale restored stream is refreshed up front, without autoplay", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10 * STREAM_STALE_MS); + route("GET /api/bandcamp/tracks", () => albumResponse([0, 1])); + localStorage.setItem( + "player", + JSON.stringify({ + v: 2, + queue: [stream(0, { resolvedAt: 0 }), stream(1, { resolvedAt: 0 })], + index: 1, + time: 25, + owner: null, + }), + ); + const { p } = setup(); + await waitFor(() => expect(p().current).toMatchObject({ streamUrl: "https://fresh/1" })); + expect(p().autoplay).toBe(false); + expect(p().startTime).toBe(25); + }); +}); + +beforeEach(() => { + setSession = () => {}; +}); diff --git a/src/test/contexts/WSProvider.test.tsx b/src/test/contexts/WSProvider.test.tsx new file mode 100644 index 00000000..e9b48b45 --- /dev/null +++ b/src/test/contexts/WSProvider.test.tsx @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { useContext } from "react"; +import { AuthProvider } from "../../contexts/AuthProvider.tsx"; +import { WSProvider } from "../../contexts/WSProvider.tsx"; +import { AvatarOverrideContext } from "../../contexts/AvatarOverrideContext.ts"; +import { useWS } from "../../hooks/useWS.ts"; +import { useAuth } from "../../hooks/useAuth.ts"; +import { FakeWebSocket } from "../ws.ts"; +import { defaultRoutes } from "../render.tsx"; +import { + makeChatMessage, + makeComment, + makeDump, + makeNotification, + makePlaylist, + makeSession, + makeUser, + storeSession, + wire, +} from "../fixtures.ts"; + +const me = makeSession({ id: "user-me", username: "me" }); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function setup({ signedIn = true } = {}) { + if (signedIn) storeSession(me); + defaultRoutes({ session: signedIn ? me : null }); + const hook = renderHook( + () => ({ + ws: useWS(), + auth: useAuth(), + avatars: useContext(AvatarOverrideContext), + }), + { wrapper }, + ); + const socket = FakeWebSocket.last; + return { hook, socket, ws: () => hook.result.current.ws }; +} + +beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] })); +afterEach(() => vi.useRealTimers()); + +describe("connection", () => { + test("connects with the session token, and without one for guests", () => { + const { socket } = setup(); + expect(socket.url).toBe( + `ws://localhost:8000/ws?token=${encodeURIComponent(me.token)}`, + ); + FakeWebSocket.reset(); + localStorage.clear(); + const guest = setup({ signedIn: false }); + expect(guest.socket.url).toBe("ws://localhost:8000/ws"); + }); + + test("connecting → connected on open", () => { + const { socket, ws } = setup(); + expect(ws().wsStatus).toBe("connecting"); + socket.open(); + expect(ws().wsStatus).toBe("connected"); + expect(ws().wsErrorMessage).toBeNull(); + }); + + test("a socket stuck connecting times out with a message", () => { + const { ws } = setup(); + act(() => vi.advanceTimersByTime(2_500)); + expect(ws().wsStatus).toBe("disconnected"); + expect(ws().wsErrorMessage).toMatch(/Can't connect/); + }); + + test("reconnects with exponential backoff after a drop", () => { + const { socket, ws } = setup(); + socket.welcome(); + socket.drop(); + expect(ws().wsStatus).toBe("disconnected"); + expect(ws().wsErrorMessage).toMatch(/temporarily disconnected/); + expect(FakeWebSocket.instances).toHaveLength(1); + act(() => vi.advanceTimersByTime(500)); + expect(FakeWebSocket.instances).toHaveLength(2); + FakeWebSocket.last.drop(); + act(() => vi.advanceTimersByTime(999)); + expect(FakeWebSocket.instances).toHaveLength(2); + act(() => vi.advanceTimersByTime(1)); + expect(FakeWebSocket.instances).toHaveLength(3); + }); + + test("answers pings", () => { + const { socket } = setup(); + socket.open(); + socket.receive({ type: "ping" }); + expect(socket.messages).toEqual([{ type: "pong" }]); + }); + + test("ignores garbage frames", () => { + const { socket, ws } = setup(); + socket.open(); + socket.receiveRaw("not json"); + socket.receiveRaw(JSON.stringify({ nope: 1 })); + socket.receiveRaw("null"); + expect(ws().wsStatus).toBe("connected"); + }); + + test("welcome loads presence, votes, likes and unread counts", () => { + const { socket, ws } = setup(); + socket.welcome({ + users: [{ userId: "u1", username: "a", hasAvatar: false }], + myVotes: ["d1"], + myCommentLikes: ["c1"], + unreadNotificationCount: 4, + unreadChatCount: 2, + chatReadAt: "2026-01-01T00:00:00.000Z", + }); + expect(ws().onlineUsers).toHaveLength(1); + expect(ws().myVotes.has("d1")).toBe(true); + expect(ws().myLikes.has("c1")).toBe(true); + expect(ws().unreadNotificationCount).toBe(4); + expect(ws().unreadChatCount).toBe(2); + expect(ws().chatReadAt).toEqual(new Date("2026-01-01T00:00:00.000Z")); + expect(document.title).toMatch(/^\(6\)/); + }); + + test("a reconnect bumps the epoch; the first connect doesn't", () => { + const { socket, ws } = setup(); + socket.welcome(); + expect(ws().connectionEpoch).toBe(0); + socket.drop(); + act(() => vi.advanceTimersByTime(500)); + FakeWebSocket.last.welcome(); + expect(ws().connectionEpoch).toBe(1); + }); + + test("force_logout signs out", () => { + const { socket, hook } = setup(); + socket.welcome(); + socket.receive({ type: "force_logout" }); + expect(hook.result.current.auth.user).toBeNull(); + }); + + test("signing out resets live state and reconnects as a guest", () => { + const { socket, hook, ws } = setup(); + socket.welcome({ myVotes: ["d1"], unreadNotificationCount: 3 }); + socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "x" })) }); + act(() => hook.result.current.auth.logout()); + expect(ws().myVotes.size).toBe(0); + expect(ws().recentDumps).toEqual([]); + expect(ws().unreadNotificationCount).toBe(0); + expect(socket.readyState).toBe(FakeWebSocket.CLOSED); + expect(FakeWebSocket.last.url).toBe("ws://localhost:8000/ws"); + }); + + test("unmounting closes the socket for good", () => { + const { socket, hook } = setup(); + socket.welcome(); + hook.unmount(); + expect(socket.readyState).toBe(FakeWebSocket.CLOSED); + act(() => vi.advanceTimersByTime(60_000)); + expect(FakeWebSocket.instances).toHaveLength(1); + }); +}); + +describe("votes", () => { + test("cast is optimistic, sent, and confirmed by the ack", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castVote("d1", 4)); + 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 }); + expect(ws().voteCounts.d1).toBe(7); + act(() => vi.advanceTimersByTime(10_000)); + expect(ws().myVotes.has("d1")).toBe(true); + }); + + test("an unconfirmed vote rolls back after the ack timeout", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castVote("d1", 2)); + act(() => vi.advanceTimersByTime(5_000)); + expect(ws().myVotes.has("d1")).toBe(false); + expect(ws().voteCounts.d1).toBe(2); + }); + + test("a server error naming the dump rolls back at once", () => { + const { socket, ws } = setup(); + 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" }); + expect(ws().myVotes.has("d1")).toBe(true); + expect(ws().voteCounts.d1).toBe(3); + }); + + test("casting twice or removing an absent vote does nothing", () => { + const { socket, ws } = setup(); + socket.welcome({ myVotes: ["d1"] }); + act(() => ws().castVote("d1", 1)); + act(() => ws().removeVote("d2", 1)); + expect(socket.messages).toEqual([]); + }); + + test("remove never goes below zero", () => { + const { socket, ws } = setup(); + socket.welcome({ myVotes: ["d1"] }); + act(() => ws().removeVote("d1", 0)); + expect(ws().voteCounts.d1).toBe(0); + }); + + test("offline, the vote is shown then rolled back", () => { + const { ws } = setup(); + act(() => ws().castVote("d1", 0)); + expect(ws().voteCounts.d1).toBe(1); + act(() => vi.advanceTimersByTime(5_000)); + expect(ws().voteCounts.d1).toBe(0); + }); + + 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" }); + 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().voteCounts.d9).toBe(2); + socket.receive({ type: "votes_update", dumpId: "d9", voteCount: 1, voterId: "user-me", action: "remove" }); + expect(ws().myVotes.has("d9")).toBe(false); + }); + + test("a welcome supersedes pending rollbacks", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castVote("d1", 0)); + socket.receive({ + type: "welcome", users: [], myVotes: ["d1"], myCommentLikes: [], + unreadNotificationCount: 0, unreadChatCount: 0, + }); + act(() => vi.advanceTimersByTime(10_000)); + expect(ws().myVotes.has("d1")).toBe(true); + }); +}); + +describe("comment likes", () => { + test("like, ack, unlike, error rollback", () => { + const { socket, ws } = setup(); + 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 }); + act(() => ws().removeCommentLike("c1")); + expect(ws().likeCounts.c1).toBe(0); + 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); + }); + + test("times out like votes do", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().castCommentLike("c1", 5)); + act(() => vi.advanceTimersByTime(5_000)); + expect(ws().likeCounts.c1).toBe(5); + expect(ws().myLikes.has("c1")).toBe(false); + }); + + 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" }); + expect(ws().myLikes.has("c2")).toBe(true); + expect(ws().likeCounts.c2).toBe(3); + expect(ws().lastLikeEvent?.commentId).toBe("c2"); + }); +}); + +describe("dumps, playlists, comments, users", () => { + test("created dumps go on top, deduplicated, and undo a deletion", () => { + const { socket, ws } = setup(); + socket.welcome(); + socket.receive({ type: "dump_deleted", dumpId: "a" }); + 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" })) }); + expect(ws().recentDumps.map((d) => d.id)).toEqual(["a", "b"]); + expect(ws().recentDumps[0].title).toBe("again"); + expect(ws().recentDumps[0].createdAt).toBeInstanceOf(Date); + expect(ws().deletedDumpIds.has("a")).toBe(false); + }); + + test("the live buffer is capped at 100", () => { + const { socket, ws } = setup(); + socket.welcome(); + for (let i = 0; i < 105; i++) { + socket.receive({ type: "dump_created", dump: wire(makeDump({ id: `d${i}` })) }); + } + expect(ws().recentDumps).toHaveLength(100); + expect(ws().recentDumps[0].id).toBe("d104"); + }); + + 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"]]); + expect(ws().lastDumpEvent?.id).toBe("old"); + }); + + test("deletion removes from the live buffer", () => { + const { socket, ws } = setup(); + socket.welcome(); + socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "a" })) }); + socket.receive({ type: "dump_deleted", dumpId: "a" }); + expect(ws().recentDumps).toEqual([]); + }); + + test("injectDump adds my own new dump once", () => { + const { ws } = setup(); + const d = makeDump({ id: "mine" }); + act(() => ws().injectDump(d)); + act(() => ws().injectDump(d)); + expect(ws().recentDumps).toEqual([d]); + }); + + test("playlist events", () => { + const { socket, ws } = setup(); + socket.welcome(); + const p = makePlaylist({ id: "p" }); + socket.receive({ type: "playlist_created", playlist: wire(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"] }); + expect(ws().lastPlaylistEvent).toEqual({ type: "dumps_updated", playlistId: "p", dumpIds: ["x"] }); + 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" }); + }); + + test("comment events", () => { + const { socket, ws } = setup(); + 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?.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" }); + }); + + 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) }); + socket.receive({ type: "user_updated", user: wire(u) }); + 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 }] }); + expect(ws().onlineUsers.map((u) => u.userId)).toEqual(["x"]); + }); +}); + +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" })) }); + expect(ws().unreadNotificationCount).toBe(2); + expect(ws().lastNotification?.id).toBe("n"); + act(() => ws().clearUnreadNotifications()); + expect(ws().unreadNotificationCount).toBe(0); + }); +}); + +describe("chat", () => { + test("messages are buffered once, and count as unread unless mine", () => { + const { socket, ws } = setup(); + socket.welcome(); + 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" })) }); + expect(ws().chatMessages.map((x) => x.id)).toEqual(["m1", "m2"]); + expect(ws().unreadChatCount).toBe(1); + }); + + test("the buffer is capped at 200", () => { + 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" })) }); + } + expect(ws().chatMessages).toHaveLength(200); + expect(ws().chatMessages[0].id).toBe("m5"); + }); + + test("with the chat open and visible, arrivals aren't unread", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().setChatOpen(true)); + expect(socket.messages).toContainEqual({ type: "chat_focus", open: true }); + socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); + expect(ws().unreadChatCount).toBe(0); + }); + + test("an open chat in a hidden tab still counts unread", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().setChatOpen(true)); + vi.spyOn(document, "hidden", "get").mockReturnValue(true); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(socket.messages.at(-1)).toEqual({ type: "chat_focus", open: false }); + socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); + expect(ws().unreadChatCount).toBe(1); + }); + + 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" })); + const [pending] = ws().pendingChatMessages; + expect(pending).toMatchObject({ body: "hello", status: "sending" }); + expect(socket.messages).toContainEqual({ + type: "chat_send", + body: "hello", + nonce: pending.nonce, + replyToId: "r", + }); + socket.receive({ + type: "chat_message", + message: wire(makeChatMessage({ userId: "user-me", body: "hello" })), + nonce: pending.nonce, + }); + expect(ws().pendingChatMessages).toEqual([]); + act(() => vi.advanceTimersByTime(20_000)); + expect(ws().pendingChatMessages).toEqual([]); + }); + + test("blank messages aren't sent", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().sendChatMessage(" ")); + expect(ws().pendingChatMessages).toEqual([]); + expect(socket.messages).toEqual([]); + }); + + test("offline sends fail at once", () => { + const { ws } = setup(); + act(() => ws().sendChatMessage("hi")); + expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" }); + expect(ws().pendingChatMessages[0].error).toMatch(/offline/); + }); + + test("no echo in time marks the send failed; retry uses a fresh nonce", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().sendChatMessage("hi")); + const first = ws().pendingChatMessages[0].nonce; + act(() => vi.advanceTimersByTime(10_000)); + expect(ws().pendingChatMessages[0]).toMatchObject({ status: "failed" }); + act(() => ws().retryChatMessage(first)); + 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); + // The first attempt's late echo doesn't retire the retry. + socket.receive({ + type: "chat_message", + message: wire(makeChatMessage({ userId: "user-me", body: "hi" })), + nonce: first, + }); + expect(ws().pendingChatMessages).toHaveLength(1); + }); + + test("a server rejection fails the pending send; discard drops it", () => { + const { socket, ws } = setup(); + 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" }); + act(() => ws().discardChatMessage(nonce)); + expect(ws().pendingChatMessages).toEqual([]); + }); + + test("a chat error without a nonce is shown until dismissed", () => { + const { socket, ws } = setup(); + socket.welcome(); + socket.receive({ type: "error", scope: "chat" }); + expect(ws().chatError?.message).toBe("Something went wrong"); + socket.receive({ type: "error", scope: "chat", message: "Not yours" }); + expect(ws().chatError?.message).toBe("Not yours"); + act(() => ws().dismissChatError()); + expect(ws().chatError).toBeNull(); + }); + + test("a dropped socket fails in-flight sends", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().sendChatMessage("hi")); + socket.drop(); + expect(ws().pendingChatMessages[0].error).toMatch(/Connection lost/); + }); + + test("edits and deletes", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().editChatMessage("m1", " new ")); + act(() => ws().editChatMessage("m1", " ")); + act(() => ws().deleteChatMessage("m1")); + expect(socket.messages).toEqual([ + { type: "chat_edit", id: "m1", body: "new" }, + { type: "chat_delete", id: "m1" }, + ]); + 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" }) }); + expect(ws().chatMessages[0].body).toBe("new"); + 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"]); + expect(ws().deletedChatIds.has("m1")).toBe(true); + }); + + test("typing: sent and received", () => { + const { socket, ws } = setup(); + 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 }] }); + expect(ws().typingUsers).toHaveLength(1); + }); + + test("the read marker clears the badge at once and is throttled to the server", () => { + const { socket, ws } = setup(); + socket.welcome({ unreadChatCount: 3 }); + 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); + 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); + act(() => vi.advanceTimersByTime(1)); + const reads = socket.messages.filter((m) => m.type === "chat_read"); + expect(reads).toHaveLength(2); + expect(reads[1].at).toBe(new Date(3_000_000).toISOString()); + // 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); + }); + + test("a marker set offline goes out with the next welcome", () => { + const { socket, ws } = setup(); + act(() => ws().markChatRead(new Date(5_000_000))); + act(() => vi.advanceTimersByTime(2_000)); + socket.welcome(); + expect(socket.messages).toContainEqual({ + type: "chat_read", + at: new Date(5_000_000).toISOString(), + }); + }); + + 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 }); + expect(ws().unreadChatCount).toBe(1); + expect(ws().chatReadAt).toEqual(new Date("2026-05-01T00:00:00.000Z")); + }); + + test("reconcile replaces stale copies, drops deleted ones, retires landed sends", () => { + const { socket, ws } = setup(); + socket.welcome(); + 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 gone = makeChatMessage({ id: "gone", createdAt: at(-40) }); + for (const m of [early, edited, gone]) { + socket.receive({ type: "chat_message", message: wire(m) }); + } + act(() => ws().sendChatMessage("did it land?")); + socket.drop(); + expect(ws().pendingChatMessages[0].status).toBe("failed"); + + act(() => + ws().reconcileChat( + [ + { ...edited, body: "new" }, + makeChatMessage({ id: "echo", userId: "user-me", body: "did it land?", createdAt: at(1) }), + ], + at(-60), + at(60), + ) + ); + expect(ws().chatMessages.map((m) => [m.id, m.body])).toEqual([ + ["early", early.body], + ["edited", "new"], + ]); + expect(ws().pendingChatMessages).toEqual([]); + }); + + test("a reconnect with the chat closed drops the stale buffer", () => { + const { socket, ws } = setup(); + socket.welcome(); + socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); + socket.drop(); + act(() => vi.advanceTimersByTime(500)); + FakeWebSocket.last.welcome(); + expect(ws().chatMessages).toEqual([]); + }); + + test("a reconnect with the chat open keeps it, and re-announces focus", () => { + const { socket, ws } = setup(); + socket.welcome(); + act(() => ws().setChatOpen(true)); + socket.receive({ type: "chat_message", message: wire(makeChatMessage()) }); + socket.drop(); + act(() => vi.advanceTimersByTime(500)); + FakeWebSocket.last.welcome(); + expect(ws().chatMessages).toHaveLength(1); + 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 new file mode 100644 index 00000000..da1e77b0 --- /dev/null +++ b/src/test/contexts/providers.test.tsx @@ -0,0 +1,313 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; +import { AuthProvider } from "../../contexts/AuthProvider.tsx"; +import { FollowProvider } from "../../contexts/FollowProvider.tsx"; +import { CategoriesProvider } from "../../contexts/CategoriesProvider.tsx"; +import { ThemeProvider } from "../../contexts/ThemeProvider.tsx"; +import { ChatProvider } from "../../contexts/ChatProvider.tsx"; +import { useAuth } from "../../hooks/useAuth.ts"; +import { useFollows } from "../../hooks/useFollows.ts"; +import { useCategories } from "../../hooks/useCategories.ts"; +import { useTheme } from "../../hooks/useTheme.ts"; +import { useChat } from "../../hooks/useChat.ts"; +import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; +import { calls, deferred, fail, ok, route } from "../fetch.ts"; +import { + makeCategory, + makeSession, + makeToken, + storeSession, + wire, +} from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; + +const authWrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("AuthProvider", () => { + test("starts from the stored session", () => { + const session = makeSession({ username: "ann" }); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + expect(result.current.user?.username).toBe("ann"); + }); + + test("a token the server rejects is dropped", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => new Response(null, { status: 401 })); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.user).toBeNull()); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + expect(calls("GET /api/users/me")[0].headers.get("Authorization")).toBe( + `Bearer ${session.token}`, + ); + }); + + 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" })); + 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( + "moderator", + ); + }); + + test("offline doesn't sign anyone out", async () => { + storeSession(makeSession()); + route("GET /api/users/me", () => new TypeError("offline")); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); + expect(result.current.user).not.toBeNull(); + }); + + test("revalidates when the tab regains focus", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(1)); + act(() => { + globalThis.dispatchEvent(new Event("focus")); + }); + await waitFor(() => expect(calls("GET /api/users/me")).toHaveLength(2)); + }); + + test("a session without a media token fetches one", async () => { + const session = { ...makeSession(), mediaToken: undefined }; + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/users/me/media-token", () => ({ mediaToken: "m-1" })); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + await waitFor(() => expect(result.current.mediaToken).toBe("m-1")); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).mediaToken).toBe( + "m-1", + ); + }); + + test("follows sign-in and sign-out from other tabs", async () => { + route("GET /api/users/me", () => ok(null)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + expect(result.current.user).toBeNull(); + + const session = makeSession({ username: "elsewhere" }); + storeSession(session); + act(() => { + 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" })); + }); + expect(result.current.user).not.toBeNull(); + act(() => { + globalThis.dispatchEvent(new StorageEvent("storage", { key: null })); + }); + expect(result.current.user).toBeNull(); + }); + + test("a 401 for an old token doesn't sign out a newer session", async () => { + const first = makeSession(); + storeSession(first); + const pending = deferred(); + route("GET /api/users/me", () => pending.promise, { once: true }); + route("GET /api/users/me", () => wire(first.user)); + const { result } = renderHook(useAuth, { wrapper: authWrapper }); + const second = { ...first, token: makeToken() }; + act(() => result.current.login(second)); + await act(async () => pending.resolve(new Response(null, { status: 401 }))); + expect(result.current.token).toBe(second.token); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe( + second.token, + ); + }); +}); + +describe("FollowProvider", () => { + function setup(status = { followedUserIds: ["u1"], followedPlaylistIds: ["p1"] }) { + const session = makeSession(); + storeSession(session); + route("GET /api/users/me", () => wire(session.user)); + route("GET /api/follows/status", () => status); + return renderHook(useFollows, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + } + + test("loads what the user follows", async () => { + const { result } = setup(); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.followedUserIds.has("u1")).toBe(true); + expect(result.current.followedPlaylistIds.has("p1")).toBe(true); + }); + + test("guests follow nobody and never ask", async () => { + const { result } = renderHook(useFollows, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + await act(async () => {}); + expect(result.current.isLoaded).toBe(false); + expect(calls("GET /api/follows/status")).toHaveLength(0); + }); + + test("follow and unfollow are optimistic and roll back on failure", async () => { + const { result } = setup({ followedUserIds: [], followedPlaylistIds: [] }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + + route("POST /api/follows/users/:id", () => ({})); + await act(() => result.current.followUser("u2")); + expect(result.current.followedUserIds.has("u2")).toBe(true); + + route("DELETE /api/follows/users/:id", () => fail("no", 500)); + await act(() => result.current.unfollowUser("u2")); + expect(result.current.followedUserIds.has("u2")).toBe(true); + + route("POST /api/follows/playlists/:id", () => new TypeError("offline")); + await act(() => result.current.followPlaylist("p2")); + expect(result.current.followedPlaylistIds.has("p2")).toBe(false); + + route("POST /api/follows/playlists/:id", () => ({})); + await act(() => result.current.followPlaylist("p2")); + route("DELETE /api/follows/playlists/:id", () => ({})); + await act(() => result.current.unfollowPlaylist("p2")); + expect(result.current.followedPlaylistIds.has("p2")).toBe(false); + expect(calls("DELETE /api/follows/playlists/:id")[0].path).toBe( + "/api/follows/playlists/p2", + ); + }); +}); + +describe("CategoriesProvider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + test("loads and indexes categories", async () => { + const music = makeCategory({ id: "c1", slug: "music" }); + route("GET /api/categories", () => wire([music])); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.byId.get("c1")?.slug).toBe("music"); + expect(result.current.categories[0].createdAt).toBeInstanceOf(Date); + }); + + test("refresh refetches", async () => { + let list = [makeCategory({ id: "a" })]; + route("GET /api/categories", () => wire(list)); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.categories).toHaveLength(1)); + list = [...list, makeCategory({ id: "b" })]; + await act(() => result.current.refresh()); + expect(result.current.categories).toHaveLength(2); + }); + + test("a failure still ends loading", async () => { + route("GET /api/categories", () => fail("x", 500)); + const { result } = renderHook(useCategories, { wrapper }); + await waitFor(() => expect(result.current.isLoaded).toBe(true)); + expect(result.current.categories).toEqual([]); + }); +}); + +describe("ThemeProvider", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const root = document.documentElement; + + test("smooth and auto by default, with no attributes", () => { + const { result } = renderHook(useTheme, { wrapper }); + expect(result.current.style).toBe("smooth"); + expect(result.current.colorScheme).toBe("auto"); + expect(root.hasAttribute("data-style")).toBe(false); + expect(root.hasAttribute("data-color-scheme")).toBe(false); + }); + + test("applies and persists a choice", () => { + const { result } = renderHook(useTheme, { wrapper }); + act(() => result.current.setStyle("brutalist")); + act(() => result.current.setColorScheme("dark")); + expect(root.getAttribute("data-style")).toBe("brutalist"); + expect(root.getAttribute("data-color-scheme")).toBe("dark"); + expect(localStorage.getItem("style")).toBe("brutalist"); + expect(localStorage.getItem("color-scheme")).toBe("dark"); + act(() => result.current.setStyle("smooth")); + act(() => result.current.setColorScheme("auto")); + expect(root.hasAttribute("data-style")).toBe(false); + expect(root.hasAttribute("data-color-scheme")).toBe(false); + }); + + test("restores the stored theme, migrating the legacy name", () => { + localStorage.setItem("style", "nyt"); + localStorage.setItem("color-scheme", "light"); + const { result } = renderHook(useTheme, { wrapper }); + 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"); + }); +}); + +describe("ChatProvider", () => { + test("opens only for signed-in users", async () => { + const guest = renderHook(useChat, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + act(() => guest.result.current.openChat()); + expect(guest.result.current.isChatOpen).toBe(false); + }); + + test("?chat=open deep-links into it, and closing strips the param", async () => { + const session = makeSession(); + route("GET /api/chat", () => ({ items: [], hasMore: false })); + let chat!: ReturnType; + function Probe() { + chat = useChat(); + return null; + } + renderApp(, { session, route: "/somewhere?chat=open&x=1" }); + expect(chat.isChatOpen).toBe(true); + await screen.findByRole("dialog", { name: "Chat" }); + act(() => chat.closeChat()); + expect(chat.isChatOpen).toBe(false); + expect(currentPath()).toBe("/somewhere?x=1"); + }); + + test("openChat / closeChat", () => { + const session = makeSession(); + route("GET /api/chat", () => ({ items: [], hasMore: false })); + let chat!: ReturnType; + function Probe() { + chat = useChat(); + return null; + } + renderApp(, { session }); + act(() => chat.openChat()); + expect(chat.isChatOpen).toBe(true); + act(() => chat.closeChat()); + expect(chat.isChatOpen).toBe(false); + }); +}); + diff --git a/src/test/emojibase.ts b/src/test/emojibase.ts new file mode 100644 index 00000000..5dd3d252 --- /dev/null +++ b/src/test/emojibase.ts @@ -0,0 +1,11 @@ +import { route } from "./fetch.ts"; +import enData from "emojibase-data/en/data.json"; +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" } })); +} diff --git a/src/test/fetch.ts b/src/test/fetch.ts new file mode 100644 index 00000000..039517ba --- /dev/null +++ b/src/test/fetch.ts @@ -0,0 +1,159 @@ +import { vi } from "vitest"; + +/** + * A routing fetch stub. Tests register handlers by method and path pattern + * (`:param` segments match one segment); every request is recorded, and one no + * handler matches is answered 404 so a missing route shows up as a failure in + * the UI rather than a hang. + */ + +export interface RecordedRequest { + method: string; + url: URL; + path: string; + headers: Headers; + body: unknown; + params: Record; +} + +type Handler = ( + req: RecordedRequest, +) => Response | Promise | unknown; + +interface Route { + method: string; + pattern: RegExp; + keys: string[]; + handler: Handler; + once: boolean; +} + +let routes: Route[] = []; +export let requests: RecordedRequest[] = []; + +function compile(path: string) { + const keys: string[] = []; + const src = path.replace(/:(\w+)/g, (_, k) => { + keys.push(k); + return "([^/]+)"; + }); + return { pattern: new RegExp(`^${src}$`), keys }; +} + +/** A success envelope, as every API route returns. */ +export function ok(data: unknown, status = 200): Response { + return new Response(JSON.stringify({ success: true, data }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** An error envelope. */ +export function fail(message: string, status = 400): Response { + return new Response( + JSON.stringify({ success: false, error: { message } }), + { status, headers: { "Content-Type": "application/json" } }, + ); +} + +/** + * Registers a handler. `spec` is "METHOD /path/:param". A handler may return a + * Response, or plain data to be wrapped in a success envelope. + * + * Standing routes match newest first, so a test can override a default. + * `once` routes answer a single request each, in the order they were added, + * ahead of any standing route — a queue of responses for a sequence of calls. + */ +export function route( + spec: string, + handler: Handler, + { once = false, fallback = false } = {}, +) { + const [method, path] = spec.split(" "); + const { pattern, keys } = compile(path); + const r = { method, pattern, keys, handler, once }; + // A fallback answers only when nothing else does, whenever it was added — + // for defaults that a test may have overridden before they were installed. + if (fallback) { + routes.push(r); + return; + } + // Both kinds go in just before the first standing route: after the queued + // once-routes, ahead of older standing ones. + const at = routes.findIndex((x) => !x.once); + routes.splice(at === -1 ? routes.length : at, 0, r); +} + +export function resetFetch() { + routes = []; + requests = []; + vi.stubGlobal("fetch", fetchStub); +} + +/** Requests made to `spec` ("METHOD /path"), pattern-matched like routes. */ +export function calls(spec: string): RecordedRequest[] { + const [method, path] = spec.split(" "); + const { pattern } = compile(path); + return requests.filter((r) => r.method === method && pattern.test(r.path)); +} + +async function readBody(body: BodyInit | null | undefined): Promise { + if (body == null) return undefined; + if (typeof body === "string") { + try { + return JSON.parse(body); + } catch { + return body; + } + } + return body; // FormData and friends, as passed +} + +async function fetchStub( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + const href = typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const url = new URL(href, location.href); + const method = (init.method ?? "GET").toUpperCase(); + if (init.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + const req: RecordedRequest = { + method, + url, + path: url.pathname, + headers: new Headers(init.headers), + body: await readBody(init.body), + params: {}, + }; + requests.push(req); + + for (const r of routes) { + if (r.method !== method) continue; + const m = r.pattern.exec(url.pathname); + if (!m) continue; + r.keys.forEach((k, i) => (req.params[k] = decodeURIComponent(m[i + 1]))); + if (r.once) routes = routes.filter((x) => x !== r); + const result = await r.handler(req); + if (result instanceof Response) return result; + if (result instanceof Error) throw result; + return ok(result); + } + return fail(`No test route for ${method} ${url.pathname}`, 404); +} + +/** A promise with its resolver exposed, for holding a response back. */ +export function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 00000000..5f54a82d --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,144 @@ +import type { + AuthResponse, + Category, + ChatMessage, + Comment, + Dump, + Notification, + Playlist, + PublicUser, +} from "../model.ts"; +import { AUTH_STORAGE_KEY } from "../utils/authStorage.ts"; + +let seq = 0; +const nextId = (prefix: string) => `${prefix}-${++seq}`; + +/** An unsigned JWT-shaped token; the client only ever reads `exp`. */ +export function makeToken( + { expiresIn = 3600, sub = "u" }: { expiresIn?: number; sub?: string } = {}, +): string { + const b64 = (o: unknown) => + btoa(JSON.stringify(o)).replace(/=+$/, "").replace(/\+/g, "-").replace( + /\//g, + "_", + ); + const exp = Math.floor(Date.now() / 1000) + expiresIn; + return `${b64({ alg: "HS256" })}.${b64({ sub, exp, n: ++seq })}.sig`; +} + +export function makeUser(over: Partial = {}): PublicUser { + const id = over.id ?? nextId("user"); + return { + id, + username: over.username ?? `user${seq}`, + role: "user", + createdAt: new Date("2026-01-01T00:00:00Z"), + ...over, + }; +} + +export function makeSession(over: Partial = {}): AuthResponse { + const user = makeUser(over); + return { + token: makeToken({ sub: user.id }), + mediaToken: `media-${user.id}`, + user, + }; +} + +/** Puts a session in storage, where AuthProvider picks it up on mount. */ +export function storeSession(session: AuthResponse) { + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session)); +} + +export function makeDump(over: Partial = {}): Dump { + const id = over.id ?? nextId("dump"); + return { + id, + kind: "url", + title: `Dump ${id}`, + userId: "user-owner", + createdAt: new Date(Date.now() - 60_000), + url: `https://example.com/${id}`, + voteCount: 0, + commentCount: 0, + isPrivate: false, + ...over, + }; +} + +export function makePlaylist(over: Partial = {}): Playlist { + const id = over.id ?? nextId("playlist"); + return { + id, + userId: "user-owner", + title: `Playlist ${id}`, + isPublic: true, + createdAt: new Date("2026-02-01T00:00:00Z"), + dumpCount: 0, + ...over, + }; +} + +export function makeComment(over: Partial = {}): Comment { + const id = over.id ?? nextId("comment"); + return { + id, + dumpId: "dump-x", + userId: "user-owner", + body: `Comment ${id}`, + createdAt: new Date(Date.now() - 30_000), + deleted: false, + likeCount: 0, + authorUsername: "owner", + ...over, + }; +} + +export function makeChatMessage(over: Partial = {}): ChatMessage { + const id = over.id ?? nextId("msg"); + return { + id, + userId: "user-other", + body: `Message ${id}`, + createdAt: new Date(Date.now() - 10_000), + authorUsername: "other", + ...over, + }; +} + +export function makeCategory(over: Partial = {}): Category { + const id = over.id ?? nextId("cat"); + return { + id, + slug: over.slug ?? `cat${seq}`, + name: over.name ?? `Category ${seq}`, + position: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + ...over, + }; +} + +export function makeNotification( + over: Partial = {}, +): Notification { + return { + id: over.id ?? nextId("notif"), + userId: "user-me", + type: "user_followed", + data: { followerId: "user-f", followerUsername: "fan" }, + read: false, + createdAt: new Date(Date.now() - 5_000), + ...over, + }; +} + +/** A value as it crosses the wire: dates become ISO strings. */ +export function wire(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +/** A paginated envelope payload. */ +export function page(items: T[], hasMore = false) { + return { items: wire(items), total: items.length, hasMore }; +} diff --git a/src/test/hooks/data.test.tsx b/src/test/hooks/data.test.tsx new file mode 100644 index 00000000..db77d826 --- /dev/null +++ b/src/test/hooks/data.test.tsx @@ -0,0 +1,321 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { type ReactNode, useRef, useState } from "react"; +import { MemoryRouter } from "react-router"; +import { AuthContext } from "../../contexts/AuthContext.ts"; +import { useUserDumpFeed } from "../../hooks/useUserDumpFeed.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts"; +import { FakeIntersectionObserver } from "../setup.ts"; + +afterEach(() => { + vi.unstubAllGlobals(); + document.head.querySelectorAll("script").forEach((s) => s.remove()); +}); + +describe("usePlayRichContent", () => { + // The Bandcamp mode is read from a tag once, at module load. + async function load(mode: "embed" | "native") { + document.head.innerHTML = ``; + vi.resetModules(); + const hookMod = await import("../../hooks/usePlayRichContent.ts"); + const ctxMod = await import("../../contexts/PlayerContext.ts"); + const play = vi.fn(); + const playQueue = vi.fn(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const hook = renderHook(hookMod.usePlayRichContent, { wrapper }); + return { hook, play, playQueue, canPlay: hookMod.canPlayRichContent }; + } + afterEach(() => { + document.head.innerHTML = ""; + }); + + const yt = { + type: "youtube", + url: "https://youtu.be/x", + title: "Vid", + embedUrl: "https://www.youtube.com/embed/x", + thumbnailUrl: "https://i.ytimg.com/x.jpg", + siteName: "YouTube", + }; + const bc = { + type: "bandcamp", + url: "https://a.bandcamp.com/album/x", + title: "Album", + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + }; + + test("embeds play in the iframe", async () => { + const { hook, play } = await load("embed"); + let started!: boolean; + await act(async () => { + started = await hook.result.current.playRichContent(yt, "/dumps/1"); + }); + expect(started).toBe(true); + expect(play).toHaveBeenCalledWith({ + kind: "embed", + embedUrl: yt.embedUrl, + title: "Vid", + type: "youtube", + dumpHref: "/dumps/1", + artworkUrl: yt.thumbnailUrl, + subtitle: "YouTube", + }); + }); + + test("nothing to embed: not started", async () => { + const { hook, play, canPlay } = await load("embed"); + const bare = { type: "link", url: "https://x" }; + expect(canPlay(bare)).toBe(false); + let started = true; + await act(async () => { + started = await hook.result.current.playRichContent(bare); + }); + expect(started).toBe(false); + expect(play).not.toHaveBeenCalled(); + }); + + test("native Bandcamp plays a resolved queue", async () => { + route("GET /api/bandcamp/tracks", () => ({ + sourceUrl: bc.url, + itemType: "album", + resolvedAt: 1, + 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); + await act(() => hook.result.current.playRichContent(bc)); + expect(playQueue).toHaveBeenCalledWith( + [expect.objectContaining({ kind: "stream", streamUrl: "https://s/0" })], + 0, + ); + expect(hook.result.current.pending).toBe(false); + }); + + test("native Bandcamp that can't resolve falls back to the embed", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + route("GET /api/bandcamp/tracks", () => fail("x", 500)); + 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" })); + }); + + test("an unknown mode reads as embed", async () => { + const { canPlay } = await load("bogus" as "embed"); + expect(canPlay({ type: "bandcamp", url: bc.url })).toBe(false); + }); +}); + +describe("useUserDumpFeed", () => { + const ann = makeUser({ username: "ann" }); + function setup(opts: { session?: ReturnType | null } = {}) { + const session = opts.session ?? null; + function Wrapper({ children }: { children: ReactNode }) { + const [authResponse, setAuthResponse] = useState(session); + return ( + + + {children} + + + ); + } + return renderHook( + ({ username }: { username?: string }) => + useUserDumpFeed(username, "dumps", "cache-key"), + { + initialProps: { username: "ann" as string | undefined }, + wrapper: Wrapper, + }, + ); + } + + test("loads the profile and the first page", async () => { + route("GET /api/users/:u", () => wire(ann)); + route("GET /api/users/:u/dumps", () => page([makeDump({ id: "a" })], true)); + const session = makeSession(); + const { result } = setup({ session }); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + const s = result.current.state as Extract; + expect(s.profileUser.username).toBe("ann"); + expect(s.items[0].createdAt).toBeInstanceOf(Date); + expect(s.hasMore).toBe(true); + const [req] = calls("GET /api/users/:u/dumps"); + expect(req.url.searchParams.get("page")).toBe("1"); + expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`); + }); + + test("an unknown user is an error", async () => { + route("GET /api/users/:u", () => fail("User not found", 404)); + route("GET /api/users/:u/dumps", () => page([])); + const { result } = setup(); + await waitFor(() => expect(result.current.state.status).toBe("error")); + }); + + test("an offline load says so", async () => { + 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/), + })); + }); + + 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)); + 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)); + const obs = FakeIntersectionObserver.instances.at(-1)!; + act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never)); + await waitFor(() => { + const s = result.current.state; + expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual(["a", "b"]); + }); + const s = result.current.state as Extract; + 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/dumps", () => page([])); + const { result, rerender } = setup(); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + rerender({ username: "bob" }); + expect(result.current.state.status).toBe("loading"); + await waitFor(() => expect(result.current.state.status).toBe("loaded")); + }); + + 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" })])); + 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; + expect(s.items.map((d) => d.id)).toEqual(["b"]); + }); +}); + +describe("useYouTubePlaylist", () => { + type Events = { onReady?: () => void; onStateChange?: (e: { data: number }) => void }; + function installYT(ids = ["a", "b", "c"]) { + const player = { + events: {} as Events, + index: 0, + playVideo: vi.fn(), + playVideoAt: vi.fn(), + nextVideo: vi.fn(), + previousVideo: vi.fn(), + getPlaylist: () => ids, + getPlaylistIndex() { + return this.index; + }, + destroy: vi.fn(), + }; + (globalThis as unknown as { YT: unknown }).YT = { + Player: class { + constructor(_el: unknown, opts: { events: Events }) { + player.events = opts.events; + return 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"); + return renderHook(({ url }) => { + const ref = useRef(document.createElement("iframe")); + return useYouTubePlaylist(url, ref); + }, { initialProps: { url: embedUrl } }); + } + + test("titles from the server, order and position from the player", async () => { + // The API script loads once per document; later tests find YT ready. + const player = installYT(); + route("GET /api/youtube/playlist", () => ({ + tracks: [ + { videoId: "a", title: "A", duration: 10 }, + { videoId: "c", title: "C", duration: 30 }, + ], + })); + 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()); + player.index = 1; + act(() => player.events.onReady!()); + expect(result.current.ready).toBe(true); + expect(result.current.index).toBe(1); + // The player's list wins, matched to the titles by id. + expect(result.current.rows).toEqual([ + { videoId: "a", title: "A", duration: 10 }, + { videoId: "b", title: undefined, duration: undefined }, + { videoId: "c", title: "C", duration: 30 }, + ]); + act(() => result.current.playAt(2)); + act(() => result.current.next()); + act(() => result.current.previous()); + expect(player.playVideoAt).toHaveBeenCalledWith(2); + expect(player.nextVideo).toHaveBeenCalled(); + expect(player.previousVideo).toHaveBeenCalled(); + }); + + 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" }] })); + 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 })); + expect(player.playVideo).toHaveBeenCalledTimes(3); + hidden.mockReturnValue(false); + document.dispatchEvent(new Event("visibilitychange")); + act(() => player.events.onStateChange!({ data: 2 })); + expect(player.playVideo).toHaveBeenCalledTimes(3); + }); + + test("no list in the URL: no rows, no requests", async () => { + const { result } = await setup("https://www.youtube.com/embed/x"); + expect(result.current.rows).toEqual([]); + expect(calls("GET /api/youtube/playlist")).toHaveLength(0); + }); + + 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"); + await waitFor(() => expect(result.current.rows).toHaveLength(1)); + await waitFor(() => expect(player.events.onReady).toBeDefined()); + route("GET /api/youtube/playlist", () => fail("x", 500)); + rerender({ url: "https://www.youtube.com/embed/y?list=PL4" }); + expect(result.current.rows).toEqual([]); + expect(result.current.index).toBe(-1); + unmount(); + expect(player.destroy).toHaveBeenCalled(); + }); +}); diff --git a/src/test/hooks/editor.test.tsx b/src/test/hooks/editor.test.tsx new file mode 100644 index 00000000..bda9cda2 --- /dev/null +++ b/src/test/hooks/editor.test.tsx @@ -0,0 +1,271 @@ +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 { calls, deferred, route } from "../fetch.ts"; + +function key(k: string, mods: Partial = {}) { + return { + key: k, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...mods, + } as unknown as KeyboardEvent; +} + +describe("useEmojiTrigger", () => { + function setup() { + const replace = vi.fn(); + const hook = renderHook(() => useEmojiTrigger(replace)); + const type = (value: string, cursor = value.length) => + act(() => hook.result.current.syncEmojiTrigger(value, cursor, true)); + return { replace, hook, type }; + } + + test("opens inline on ':' and two letters", () => { + const { hook, type } = setup(); + type("hello :s"); + expect(hook.result.current.emojiMode).toBe("closed"); + type("hello :sm"); + expect(hook.result.current.emojiMode).toBe("inline"); + expect(hook.result.current.emojiQuery).toBe("sm"); + }); + + test("emoticons, times and labels don't trigger", () => { + const { hook, type } = setup(); + for (const text of [":D", ":P", "12:30", "note:ab", "a::bc", "http://ab"]) { + type(text); + expect(hook.result.current.emojiMode, text).toBe("closed"); + } + }); + + test("Unicode names work", () => { + const { hook, type } = setup(); + type(":cœur"); + expect(hook.result.current.emojiQuery).toBe("cœur"); + }); + + test("not inside code", () => { + const { hook, type } = setup(); + type("`:smile"); + expect(hook.result.current.emojiMode).toBe("closed"); + }); + + test("closes when the caret leaves the query", () => { + const { hook, type } = setup(); + type(":smile"); + act(() => hook.result.current.syncEmojiTrigger(":smile more", 11, false)); + expect(hook.result.current.emojiMode).toBe("closed"); + }); + + test("a caret move alone never opens it", () => { + const { hook } = setup(); + act(() => hook.result.current.syncEmojiTrigger(":smile", 6, false)); + expect(hook.result.current.emojiMode).toBe("closed"); + }); + + test("an inline pick replaces the query and adds a space", () => { + const { hook, type, replace } = setup(); + type("hi :smi"); + act(() => hook.result.current.selectEmoji("😄")); + expect(replace).toHaveBeenCalledWith(3, 7, "😄", true); + expect(hook.result.current.emojiMode).toBe("closed"); + }); + + test("browse mode inserts at the caret and ignores typing", () => { + const { hook, type, replace } = setup(); + act(() => hook.result.current.openEmojiBrowser(2, 4)); + type("ab"); + expect(hook.result.current.emojiMode).toBe("browse"); + act(() => hook.result.current.setEmojiQuery("cat")); + expect(hook.result.current.emojiQuery).toBe("cat"); + act(() => hook.result.current.selectEmoji("🐱")); + expect(replace).toHaveBeenCalledWith(2, 4, "🐱", false); + }); + + test("selecting or querying while closed does nothing", () => { + const { hook, replace } = setup(); + act(() => hook.result.current.setEmojiQuery("x")); + act(() => hook.result.current.selectEmoji("😀")); + expect(replace).not.toHaveBeenCalled(); + expect(hook.result.current.emojiQuery).toBe(""); + }); + + test("closeEmoji", () => { + const { hook, type } = setup(); + type(":smile"); + act(() => hook.result.current.closeEmoji()); + expect(hook.result.current.emojiMode).toBe("closed"); + }); +}); + +describe("useMentionAutocomplete", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + const users = [ + { id: "1", username: "alice", avatarMime: null }, + { id: "2", username: "alex", avatarMime: null }, + { id: "3", username: "albert", avatarMime: null }, + ]; + + function setup() { + const replace = vi.fn(); + const hook = renderHook(() => useMentionAutocomplete(replace)); + const type = async (value: string, cursor = value.length) => { + act(() => hook.result.current.syncMention(value, cursor, true)); + await act(() => vi.advanceTimersByTimeAsync(200)); + }; + return { replace, hook, type }; + } + + 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")!))); + 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"); + }); + + test("debounces keystrokes into one lookup", async () => { + route("GET /api/users/search", () => users); + const { hook } = setup(); + act(() => hook.result.current.syncMention("@a", 2, true)); + act(() => hook.result.current.syncMention("@al", 3, true)); + act(() => hook.result.current.syncMention("@ali", 4, true)); + await act(() => vi.advanceTimersByTimeAsync(200)); + expect(calls("GET /api/users/search")).toHaveLength(1); + }); + + test("no results keeps it closed", async () => { + route("GET /api/users/search", () => []); + const { hook, type } = setup(); + await type("@zz"); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("an email address or a link label isn't a mention", async () => { + route("GET /api/users/search", () => users); + const { hook, type } = setup(); + await type("me@al"); + await type("[@al"); + await type("`@al"); + expect(calls("GET /api/users/search")).toHaveLength(0); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("a stale lookup can't open over a newer query", async () => { + const slow = deferred(); + route("GET /api/users/search", () => slow.promise, { once: true }); + route("GET /api/users/search", () => []); + const { hook, type } = setup(); + await type("@al"); + await type("@alz"); + await act(async () => slow.resolve(new Response(JSON.stringify({ success: true, data: users })))); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("arrows wrap, Enter picks, Tab picks", async () => { + route("GET /api/users/search", () => users); + const { hook, type, replace } = setup(); + await type("hey @al"); + const down = key("ArrowDown"); + act(() => { + hook.result.current.handleMentionKeyDown(key("ArrowUp")); + }); + expect(hook.result.current.mentionSelectedIndex).toBe(2); + act(() => { + hook.result.current.handleMentionKeyDown(down); + }); + expect(down.preventDefault).toHaveBeenCalled(); + expect(hook.result.current.mentionSelectedIndex).toBe(0); + act(() => { + hook.result.current.handleMentionKeyDown(key("ArrowDown")); + }); + let handled = false; + act(() => { + handled = hook.result.current.handleMentionKeyDown(key("Enter")); + }); + expect(handled).toBe(true); + expect(replace).toHaveBeenCalledWith(4, 7, "@alex", true); + expect(hook.result.current.mentionOpen).toBe(false); + + await type("@al"); + act(() => { + hook.result.current.handleMentionKeyDown(key("Tab")); + }); + expect(replace).toHaveBeenLastCalledWith(0, 3, "@alice", true); + }); + + test("Shift+Enter is left to the editor", async () => { + route("GET /api/users/search", () => users); + const { hook, type } = setup(); + await type("@al"); + let handled = true; + act(() => { + handled = hook.result.current.handleMentionKeyDown( + key("Enter", { shiftKey: true }), + ); + }); + expect(handled).toBe(false); + }); + + test("Escape closes just the list and stops there", async () => { + route("GET /api/users/search", () => users); + const { hook, type } = setup(); + await type("@al"); + const esc = key("Escape"); + act(() => { + hook.result.current.handleMentionKeyDown(esc); + }); + expect(esc.stopPropagation).toHaveBeenCalled(); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("keys pass through while closed", () => { + const { hook } = setup(); + expect(hook.result.current.handleMentionKeyDown(key("ArrowDown"))).toBe(false); + }); + + test("moving the caret off the mention closes it", async () => { + route("GET /api/users/search", () => users); + const { hook, type } = setup(); + await type("@al and more"); + // typed at position 3 + await type("@al and more", 3); + expect(hook.result.current.mentionOpen).toBe(true); + act(() => hook.result.current.syncMention("@al and more", 12, false)); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("a caret move within the same query keeps it open", async () => { + route("GET /api/users/search", () => users); + const { hook, type } = setup(); + await type("@al"); + act(() => hook.result.current.syncMention("@al", 3, false)); + expect(hook.result.current.mentionOpen).toBe(true); + }); + + test("a failed lookup is quiet", async () => { + route("GET /api/users/search", () => new TypeError("offline")); + const { hook, type } = setup(); + await type("@al"); + expect(hook.result.current.mentionOpen).toBe(false); + }); + + test("unmounting cancels a pending lookup", async () => { + route("GET /api/users/search", () => users); + const { hook } = setup(); + act(() => hook.result.current.syncMention("@al", 3, true)); + hook.unmount(); + await vi.advanceTimersByTimeAsync(500); + expect(calls("GET /api/users/search")).toHaveLength(0); + }); +}); diff --git a/src/test/hooks/misc.test.tsx b/src/test/hooks/misc.test.tsx new file mode 100644 index 00000000..2f201cbb --- /dev/null +++ b/src/test/hooks/misc.test.tsx @@ -0,0 +1,452 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +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 { useFading } from "../../hooks/useFading.ts"; +import { useFeedCache } from "../../hooks/useFeedCache.ts"; +import { useTabParam } from "../../hooks/useTabParam.ts"; +import { + setTitleBadge, + SITE_NAME, + useDocumentTitle, +} from "../../hooks/useDocumentTitle.ts"; +import { useInfiniteScroll } from "../../hooks/useInfiniteScroll.ts"; +import { useScrollLock } from "../../hooks/useScrollLock.ts"; +import { useScrollSave } from "../../hooks/useScrollSave.ts"; +import { useDefaultFeedTab } from "../../hooks/useDefaultFeedTab.ts"; +import { useSoundsEnabled } from "../../hooks/useSoundsEnabled.ts"; +import { useDumpCardLink } from "../../hooks/useDumpCardLink.ts"; +import { useAnchoredFloat } from "../../hooks/useAnchoredFloat.ts"; +import { useHeightTransition } from "../../hooks/useHeightTransition.ts"; +import { isDumpVisited } from "../../utils/visited.ts"; +import { FakeIntersectionObserver } from "../setup.ts"; + +afterEach(() => vi.useRealTimers()); + +describe("useFading", () => { + beforeEach(() => vi.useFakeTimers()); + + test("cooldown for 2s, then dismissing for 350ms, then gone", () => { + const { result } = renderHook(useFading); + act(() => result.current.startFading("a")); + expect(result.current.fading).toEqual({ a: "cooldown" }); + act(() => vi.advanceTimersByTime(2000)); + expect(result.current.fading).toEqual({ a: "dismissing" }); + act(() => vi.advanceTimersByTime(350)); + expect(result.current.fading).toEqual({}); + }); + + test("starting twice is one animation", () => { + const { result } = renderHook(useFading); + act(() => result.current.startFading("a")); + act(() => vi.advanceTimersByTime(1000)); + act(() => result.current.startFading("a")); + act(() => vi.advanceTimersByTime(1000)); + expect(result.current.fading).toEqual({ a: "dismissing" }); + }); + + test("cancel during cooldown and during dismissal", () => { + const { result } = renderHook(useFading); + act(() => result.current.startFading("a")); + act(() => result.current.cancelFading("a")); + expect(result.current.fading).toEqual({}); + act(() => vi.advanceTimersByTime(5000)); + expect(result.current.fading).toEqual({}); + + act(() => result.current.startFading("b")); + act(() => vi.advanceTimersByTime(2100)); + act(() => result.current.cancelFading("b")); + expect(result.current.fading).toEqual({}); + // Cancelled means it can start over. + act(() => result.current.startFading("b")); + expect(result.current.fading).toEqual({ b: "cooldown" }); + }); + + test("cancelAll", () => { + const { result } = renderHook(useFading); + act(() => { + result.current.startFading("a"); + result.current.startFading("b"); + }); + act(() => result.current.cancelAll()); + expect(result.current.fading).toEqual({}); + }); +}); + +describe("useFeedCache", () => { + // A MemoryRouter's first entry is a POP, as after Back. + const wrapper = () => ({ children }: { children: ReactNode }) => ( + {children} + ); + + test("restores a saved feed on a back/forward (POP) visit", () => { + const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), { + wrapper: wrapper(), + }); + act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300)); + const second = renderHook( + () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })), + { wrapper: wrapper() }, + ); + expect(second.result.current.cached).toMatchObject({ + items: [{ n: 10 }], + page: 2, + hasMore: true, + scrollY: 300, + }); + }); + + test("an expired entry is ignored", () => { + vi.useFakeTimers(); + const first = renderHook(() => useFeedCache("k", (x) => x), { + wrapper: wrapper(), + }); + act(() => first.result.current.saveState([], 1, false, 0)); + vi.advanceTimersByTime(11 * 60 * 1000); + const second = renderHook(() => useFeedCache("k", (x) => x), { + wrapper: wrapper(), + }); + expect(second.result.current.cached).toBeNull(); + }); + + test("a fresh (PUSH) visit ignores the cache", () => { + sessionStorage.setItem( + "k", + JSON.stringify({ items: [1], page: 1, hasMore: false, scrollY: 0, savedAt: Date.now() }), + ); + function Probe() { + const { cached } = useFeedCache("k", (x) => x); + return

{cached ? "cached" : "fresh"}

; + } + function Home() { + const navigate = useNavigate(); + return ; + } + render( + + + } /> + } /> + + , + ); + fireEvent.click(screen.getByText("go")); + expect(screen.getByText("fresh")).toBeInTheDocument(); + }); + + test("corrupt storage reads as no cache; a full quota doesn't throw", () => { + sessionStorage.setItem("k", "{"); + const { result } = renderHook(() => useFeedCache("k", (x) => x), { + wrapper: wrapper(), + }); + expect(result.current.cached).toBeNull(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + expect(() => result.current.saveState([], 1, false, 0)).not.toThrow(); + }); +}); + +describe("useTabParam", () => { + const TABS = ["dumps", "playlists", "votes"] as const; + function setup(path: string, state?: unknown) { + let loc!: ReturnType; + const hook = renderHook(() => { + loc = useLocation(); + return useTabParam(TABS, "dumps"); + }, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + return { hook, loc: () => loc }; + } + + 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").hook.result.current[0]).toBe("dumps"); + expect(setup("/users/ann/~/bogus").hook.result.current[0]).toBe("dumps"); + }); + + test("the default tab collapses to the base path", () => { + const { hook, loc } = setup("/users/ann/~/votes"); + act(() => hook.result.current[1]("dumps")); + expect(loc().pathname).toBe("/users/ann"); + act(() => hook.result.current[1]("playlists")); + expect(loc().pathname).toBe("/users/ann/~/playlists"); + }); + + test("at the root the default stays explicit", () => { + const { hook, loc } = setup("/~/votes"); + act(() => hook.result.current[1]("dumps")); + expect(loc().pathname).toBe("/~/dumps"); + }); + + test("keeps the query string and the entry's state", () => { + const { hook, loc } = setup("/search?q=cats", { back: "/x" }); + act(() => hook.result.current[1]("votes")); + expect(loc().pathname).toBe("/search/~/votes"); + expect(loc().search).toBe("?q=cats"); + expect(loc().state).toEqual({ back: "/x" }); + }); +}); + +describe("useDocumentTitle", () => { + test("composes page, site name and unread badge", () => { + const { rerender } = renderHook( + ({ title }: { title: string | null | undefined }) => useDocumentTitle(title), + { initialProps: { title: "Cats" as string | null | undefined } }, + ); + expect(document.title).toBe(`Cats · ${SITE_NAME}`); + act(() => setTitleBadge(3)); + expect(document.title).toBe(`(3) Cats · ${SITE_NAME}`); + act(() => setTitleBadge(150)); + expect(document.title).toBe(`(99+) Cats · ${SITE_NAME}`); + rerender({ title: null }); + expect(document.title).toBe(`(99+) ${SITE_NAME}`); + act(() => setTitleBadge(0)); + expect(document.title).toBe(SITE_NAME); + // undefined leaves it be + rerender({ title: undefined }); + expect(document.title).toBe(SITE_NAME); + }); + + test("the site name defaults to gerbeur when unsubstituted", () => { + expect(SITE_NAME).toBe("gerbeur"); + }); +}); + +describe("useInfiniteScroll", () => { + function Feed({ onMore, enabled }: { onMore: () => void; enabled: boolean }) { + const ref = useInfiniteScroll(onMore, enabled); + return
; + } + const intersect = (isIntersecting: boolean) => { + const obs = FakeIntersectionObserver.instances.at(-1)!; + act(() => + obs.callback( + [{ isIntersecting } as IntersectionObserverEntry], + obs as unknown as IntersectionObserver, + ) + ); + }; + + test("loads more when the sentinel shows, only while enabled", () => { + const onMore = vi.fn(); + const { rerender } = render(); + expect(FakeIntersectionObserver.instances.at(-1)!.elements.size).toBe(1); + intersect(false); + expect(onMore).not.toHaveBeenCalled(); + intersect(true); + expect(onMore).toHaveBeenCalledTimes(1); + rerender(); + intersect(true); + expect(onMore).toHaveBeenCalledTimes(1); + }); +}); + +describe("useScrollLock", () => { + function Lock() { + useScrollLock(); + return null; + } + test("counts nested locks", () => { + const root = document.documentElement; + const a = render(); + const b = render(); + expect(root.style.overflowY).toBe("hidden"); + a.unmount(); + expect(root.style.overflowY).toBe("hidden"); + b.unmount(); + expect(root.style.overflowY).toBe(""); + }); +}); + +describe("useScrollSave", () => { + test("debounces scroll positions while enabled", () => { + vi.useFakeTimers(); + const onSave = vi.fn(); + const { rerender, unmount } = renderHook( + ({ on }) => useScrollSave(on, onSave), + { initialProps: { on: true } }, + ); + globalThis.dispatchEvent(new Event("scroll")); + globalThis.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(100); + expect(onSave).toHaveBeenCalledTimes(1); + rerender({ on: false }); + globalThis.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(100); + expect(onSave).toHaveBeenCalledTimes(1); + unmount(); + }); +}); + +describe("stored preferences", () => { + test("useDefaultFeedTab validates and persists", () => { + localStorage.setItem("default-feed-tab", "bogus"); + const { result } = renderHook(useDefaultFeedTab); + expect(result.current[0]).toBe("journal"); + act(() => result.current[1]("new")); + expect(result.current[0]).toBe("new"); + expect(localStorage.getItem("default-feed-tab")).toBe("new"); + expect(renderHook(useDefaultFeedTab).result.current[0]).toBe("new"); + }); + + test("useSoundsEnabled", () => { + const { result } = renderHook(useSoundsEnabled); + expect(result.current[0]).toBe(true); + act(() => result.current[1](false)); + expect(result.current[0]).toBe(false); + expect(localStorage.getItem("sounds")).toBe("off"); + }); +}); + +describe("useDumpCardLink", () => { + function Card() { + const link = useDumpCardLink({ id: "d1", slug: "hello" }); + return ( +
+ inner link + +
+ ); + } + function setup() { + const open = vi.spyOn(globalThis, "open").mockImplementation(() => null); + render( + + + } /> + dump page

} /> +
+
, + ); + return { open }; + } + + test("a click navigates and marks visited", () => { + setup(); + fireEvent.click(screen.getByTestId("card")); + expect(screen.getByText("dump page")).toBeInTheDocument(); + expect(isDumpVisited("d1")).toBe(true); + }); + + test("ctrl/cmd/shift-click opens a new tab", () => { + const { open } = setup(); + fireEvent.click(screen.getByTestId("card"), { ctrlKey: true }); + expect(open).toHaveBeenCalledWith("/dumps/hello", "_blank", "noopener"); + }); + + test("a middle click opens a new tab, except over a link", () => { + const { open } = setup(); + const mouseDown = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true }); + screen.getByTestId("card").dispatchEvent(mouseDown); + expect(mouseDown.defaultPrevented).toBe(true); + fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 1, bubbles: true })); + expect(open).toHaveBeenCalledTimes(1); + fireEvent(screen.getByText("inner link"), new MouseEvent("auxclick", { button: 1, bubbles: true })); + expect(open).toHaveBeenCalledTimes(1); + fireEvent(screen.getByTestId("card"), new MouseEvent("auxclick", { button: 2, bubbles: true })); + expect(open).toHaveBeenCalledTimes(1); + }); + + test("a modified click on the play button opens the dump instead of playing", () => { + const { open } = setup(); + const play = screen.getByText("play"); + fireEvent.click(play, { metaKey: true }); + expect(open).toHaveBeenCalledTimes(1); + expect(play.dataset.played).toBeUndefined(); + fireEvent.click(play); + expect(play.dataset.played).toBe("1"); + }); +}); + +describe("useAnchoredFloat", () => { + function Float({ open, top }: { open: boolean; top: number }) { + const ref = useRef(null); + const style = useAnchoredFloat(ref, open, { + width: 300, + chromeHeight: 40, + minContentHeight: 100, + maxContentHeight: 400, + }); + return ( + <> +
{ + ref.current = el; + if (el) { + el.getBoundingClientRect = () => + ({ top, bottom: top + 20, left: 900, right: 1000, width: 100, height: 20 }) as DOMRect; + } + }} + /> + {JSON.stringify(style)} + + ); + } + const styleOf = () => JSON.parse(screen.getByTestId("style").textContent!); + + beforeEach(() => { + vi.stubGlobal("innerWidth", 1024); + vi.stubGlobal("innerHeight", 768); + }); + + test("null while closed", () => { + render(); + expect(styleOf()).toBeNull(); + }); + + test("opens below an anchor near the top, clamped into the viewport", () => { + render(); + const s = styleOf(); + expect(s.position).toBe("fixed"); + expect(s.top).toBe(124); + expect(s.bottom).toBe("auto"); + expect(s.left).toBe(1024 - 300 - 8); + expect(s["--float-max-h"]).toBe("400px"); + }); + + test("flips above an anchor near the bottom", () => { + render(); + const s = styleOf(); + expect(s.bottom).toBe(768 - 700 + 4); + expect(s.top).toBe("auto"); + }); +}); + +describe("useHeightTransition", () => { + test("animates from the old height to the new one when watch changes", () => { + const animate = vi.spyOn(Element.prototype, "animate"); + let height = 100; + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( + () => ({ height }) as DOMRect, + ); + function Box({ open }: { open: boolean }) { + const ref = useHeightTransition(open); + return
; + } + const { rerender } = render(); + height = 250; + rerender(); + expect(animate).toHaveBeenCalledTimes(1); + const [frames] = animate.mock.calls[0] as unknown as [Keyframe[]]; + expect(frames[0].height).toBe("100px"); + expect(frames[1].height).toBe("250px"); + + // No change in height, no animation. + rerender(); + expect(animate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/test/hooks/sync.test.tsx b/src/test/hooks/sync.test.tsx new file mode 100644 index 00000000..5223b2c7 --- /dev/null +++ b/src/test/hooks/sync.test.tsx @@ -0,0 +1,238 @@ +import { describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode, useState } from "react"; +import { WSContext, type WSContextValue } from "../../contexts/WSContext.ts"; +import type { Dump, Playlist } from "../../model.ts"; +import { useDumpListSync } from "../../hooks/useDumpListSync.ts"; +import { usePlaylistListSync } from "../../hooks/usePlaylistListSync.ts"; +import { usePositionAwareSync } from "../../hooks/usePositionAwareSync.ts"; +import { makeDump, makePlaylist } from "../fixtures.ts"; +import { defaultOf } from "../render.tsx"; + +/** A WS context whose value a test can push new events into. */ +function liveWS() { + let push: (patch: Partial) => void = () => {}; + const base = defaultOf(WSContext); + function Wrapper({ children }: { children: ReactNode }) { + const [value, setValue] = useState(base); + push = (patch) => setValue((v) => ({ ...v, ...patch })); + return {children}; + } + return { Wrapper, emit: (patch: Partial) => act(() => push(patch)) }; +} + +function useList(initial: T[]) { + const [items, setItems] = useState(initial); + return { items, setItems }; +} + +const ids = (xs: { id: string }[]) => xs.map((x) => x.id); + +describe("useDumpListSync", () => { + function setup(initial: Dump[], opts?: Parameters[1]) { + const ws = liveWS(); + const hook = renderHook(() => { + const list = useList(initial); + useDumpListSync(list.setItems, opts); + return list; + }, { wrapper: ws.Wrapper }); + return { ...ws, hook }; + } + + test("drops deleted dumps", () => { + const { hook, emit } = setup([makeDump({ id: "a" }), makeDump({ id: "b" })]); + emit({ deletedDumpIds: new Set(["a"]) }); + expect(ids(hook.result.current.items)).toEqual(["b"]); + }); + + test("updates a dump in place", () => { + const { hook, emit } = setup([makeDump({ id: "a", title: "old" })]); + emit({ lastDumpEvent: makeDump({ id: "a", title: "new" }) }); + expect(hook.result.current.items[0].title).toBe("new"); + }); + + test("a dump gone private leaves others' lists but not the owner's", () => { + const a = makeDump({ id: "a" }); + const viewer = setup([a]); + viewer.emit({ lastDumpEvent: { ...a, isPrivate: true } }); + expect(viewer.hook.result.current.items).toEqual([]); + + const owner = setup([a], { isOwner: true }); + owner.emit({ lastDumpEvent: { ...a, isPrivate: true } }); + expect(owner.hook.result.current.items[0].isPrivate).toBe(true); + }); + + test("re-inserts only the owner's dumps, and only when asked", () => { + const mine = makeDump({ id: "m", userId: "me" }); + const theirs = makeDump({ id: "t", userId: "them" }); + + const none = setup([]); + none.emit({ lastDumpEvent: mine }); + expect(none.hook.result.current.items).toEqual([]); + + const owned = setup([], { ownerId: "me" }); + owned.emit({ lastDumpEvent: theirs }); + expect(owned.hook.result.current.items).toEqual([]); + owned.emit({ lastDumpEvent: mine }); + expect(ids(owned.hook.result.current.items)).toEqual(["m"]); + + const skip = setup([], { ownerId: "me", skipReinsert: true }); + skip.emit({ lastDumpEvent: mine }); + expect(skip.hook.result.current.items).toEqual([]); + + const privateOne = setup([], { ownerId: "me" }); + privateOne.emit({ lastDumpEvent: { ...mine, isPrivate: true } }); + expect(privateOne.hook.result.current.items).toEqual([]); + }); + + test("comment events move the count, never below zero; edits don't", () => { + const { hook, emit } = setup([makeDump({ id: "a", commentCount: 0 })]); + emit({ lastCommentEvent: { type: "created", dumpId: "a" } }); + expect(hook.result.current.items[0].commentCount).toBe(1); + emit({ lastCommentEvent: { type: "updated", dumpId: "a" } }); + expect(hook.result.current.items[0].commentCount).toBe(1); + emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } }); + emit({ lastCommentEvent: { type: "deleted", dumpId: "a" } }); + expect(hook.result.current.items[0].commentCount).toBe(0); + emit({ lastCommentEvent: { type: "created", dumpId: "zzz" } }); + expect(hook.result.current.items).toHaveLength(1); + }); +}); + +describe("usePlaylistListSync", () => { + function setup( + initial: Playlist[], + opts?: Parameters[1], + ) { + const ws = liveWS(); + const hook = renderHook(() => { + const list = useList(initial); + usePlaylistListSync(list.setItems, opts); + return list; + }, { wrapper: ws.Wrapper }); + return { ...ws, hook }; + } + + test("prepends a created playlist once", () => { + const { hook, emit } = setup([makePlaylist({ id: "a" })]); + const p = makePlaylist({ id: "b" }); + emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: p } }); + emit({ lastPlaylistEvent: { type: "created", playlistId: "b", playlist: { ...p } } }); + expect(ids(hook.result.current.items)).toEqual(["b", "a"]); + }); + + test("created: filtered by owner, privacy and noNewEntries", () => { + const p = makePlaylist({ id: "p", userId: "them" }); + const byOwner = setup([], { ownerId: "me" }); + byOwner.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } }); + expect(byOwner.hook.result.current.items).toEqual([]); + + const priv = setup([]); + priv.emit({ + lastPlaylistEvent: { + type: "created", + playlistId: "p", + playlist: { ...p, isPublic: false }, + }, + }); + expect(priv.hook.result.current.items).toEqual([]); + + const followed = setup([], { noNewEntries: true }); + followed.emit({ lastPlaylistEvent: { type: "created", playlistId: "p", playlist: p } }); + expect(followed.hook.result.current.items).toEqual([]); + }); + + test("updated: in place, removed when private, re-added when public", () => { + const p = makePlaylist({ id: "p", title: "one" }); + const { hook, emit } = setup([p]); + emit({ + lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "two" } }, + }); + expect(hook.result.current.items[0].title).toBe("two"); + emit({ + lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, isPublic: false } }, + }); + expect(hook.result.current.items).toEqual([]); + emit({ + lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: { ...p, title: "back" } }, + }); + expect(hook.result.current.items[0].title).toBe("back"); + }); + + test("updated and not in the list: skipReinsert and noNewEntries hold it back", () => { + const p = makePlaylist({ id: "p" }); + for (const opts of [{ skipReinsert: true }, { noNewEntries: true }]) { + const { hook, emit } = setup([], opts); + emit({ lastPlaylistEvent: { type: "updated", playlistId: "p", playlist: p } }); + expect(hook.result.current.items).toEqual([]); + } + }); + + test("deleted and deletedPlaylistIds both remove", () => { + const { hook, emit } = setup([ + makePlaylist({ id: "a" }), + makePlaylist({ id: "b" }), + makePlaylist({ id: "c" }), + ]); + emit({ lastPlaylistEvent: { type: "deleted", playlistId: "a" } }); + emit({ deletedPlaylistIds: new Set(["b"]) }); + expect(ids(hook.result.current.items)).toEqual(["c"]); + }); + + test("dumps_updated sets the count", () => { + const { hook, emit } = setup([makePlaylist({ id: "a", dumpCount: 1 })]); + emit({ + lastPlaylistEvent: { type: "dumps_updated", playlistId: "a", dumpIds: ["x", "y", "z"] }, + }); + expect(hook.result.current.items[0].dumpCount).toBe(3); + }); +}); + +describe("usePositionAwareSync", () => { + test("a dump that goes private and back returns to its old place", () => { + const ws = liveWS(); + const initial = ["a", "b", "c"].map((id) => makeDump({ id, userId: "me" })); + // Wire the pair exactly as UserPublicProfile does. + const both = renderHook(() => { + const { items, setItems } = useList(initial); + const [event, setEvent] = useState(null); + usePositionAwareSync( + items, + setItems, + event, + (d) => d.isPrivate, + (d) => !d.isPrivate, + ); + useDumpListSync(setItems, { ownerId: "me", skipReinsert: true }); + return { items, setEvent }; + }, { wrapper: ws.Wrapper }); + + const b = initial[1]; + act(() => both.result.current.setEvent({ ...b, isPrivate: true })); + ws.emit({ lastDumpEvent: { ...b, isPrivate: true } }); + expect(ids(both.result.current.items)).toEqual(["a", "c"]); + + act(() => both.result.current.setEvent({ ...b, isPrivate: false })); + ws.emit({ lastDumpEvent: { ...b, isPrivate: false } }); + expect(ids(both.result.current.items)).toEqual(["a", "b", "c"]); + }); + + test("a position is only used once, and clamps to the list end", () => { + const initial = ["a", "b", "c"].map((id) => makePlaylist({ id })); + const hook = renderHook(() => { + const { items, setItems } = useList(initial); + const [event, setEvent] = useState(null); + usePositionAwareSync(items, setItems, event, (p) => !p.isPublic, (p) => p.isPublic); + return { items, setItems, setEvent }; + }); + const c = initial[2]; + act(() => hook.result.current.setEvent({ ...c, isPublic: false })); + act(() => hook.result.current.setItems((prev) => prev.slice(0, 1))); + act(() => hook.result.current.setEvent({ ...c, isPublic: true })); + expect(ids(hook.result.current.items)).toEqual(["a", "c"]); + // Nothing saved any more: a second public event is left alone. + act(() => hook.result.current.setItems((prev) => prev.slice(0, 1))); + act(() => hook.result.current.setEvent({ ...c, isPublic: true, title: "x" })); + expect(ids(hook.result.current.items)).toEqual(["a"]); + }); +}); diff --git a/src/test/hooks/useAuth.test.tsx b/src/test/hooks/useAuth.test.tsx new file mode 100644 index 00000000..06ee9566 --- /dev/null +++ b/src/test/hooks/useAuth.test.tsx @@ -0,0 +1,261 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { AuthContext } from "../../contexts/AuthContext.ts"; +import type { AuthResponse } from "../../model.ts"; +import { useAuth, useRequiredAuth } from "../../hooks/useAuth.ts"; +import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; +import { calls, route } from "../fetch.ts"; +import { makeSession, makeToken, storeSession } from "../fixtures.ts"; + +function wrapperFor(initial: AuthResponse | null) { + return function Wrapper({ children }: { children: ReactNode }) { + const [authResponse, setAuthResponse] = useState(initial); + return ( + + {children} + + ); + }; +} + +const stored = () => JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY) ?? "null"); + +describe("useAuth", () => { + test("exposes the session", () => { + const session = makeSession({ username: "ann" }); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + expect(result.current.user?.username).toBe("ann"); + expect(result.current.token).toBe(session.token); + expect(result.current.mediaToken).toBe(session.mediaToken); + }); + + test("mediaToken falls back to the session token on old sessions", () => { + const session = { ...makeSession(), mediaToken: undefined }; + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + expect(result.current.mediaToken).toBe(session.token); + }); + + test("login stores the session; logout clears it", () => { + const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) }); + const session = makeSession(); + act(() => result.current.login(session)); + expect(result.current.user?.id).toBe(session.user.id); + expect(stored().token).toBe(session.token); + act(() => result.current.logout()); + expect(result.current.user).toBeNull(); + expect(stored()).toBeNull(); + }); + + test("a stale logout doesn't sign out a newer session", () => { + const first = makeSession(); + storeSession(first); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(first) }); + const staleLogout = result.current.logout; + const second = { ...first, token: makeToken() }; + act(() => result.current.login(second)); + act(() => staleLogout()); + expect(result.current.token).toBe(second.token); + expect(stored().token).toBe(second.token); + }); + + test("updateSession merges and persists; does nothing signed out", () => { + const session = makeSession(); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + act(() => result.current.updateSession({ mediaToken: "fresh" })); + expect(result.current.mediaToken).toBe("fresh"); + expect(stored().mediaToken).toBe("fresh"); + + const out = renderHook(useAuth, { wrapper: wrapperFor(null) }); + act(() => out.result.current.updateSession({ mediaToken: "x" })); + expect(out.result.current.user).toBeNull(); + }); +}); + +describe("authFetch", () => { + test("sends the bearer token and a JSON content type", async () => { + const session = makeSession(); + route("POST /api/thing", () => ({})); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + await result.current.authFetch("http://localhost:8000/api/thing", { + method: "POST", + body: "{}", + headers: { "X-Extra": "1" }, + }); + const [req] = calls("POST /api/thing"); + expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`); + expect(req.headers.get("Content-Type")).toBe("application/json"); + expect(req.headers.get("X-Extra")).toBe("1"); + }); + + test("leaves Content-Type to the browser for FormData", async () => { + route("POST /api/upload", () => ({})); + const { result } = renderHook(useAuth, { + wrapper: wrapperFor(makeSession()), + }); + await result.current.authFetch("http://localhost:8000/api/upload", { + method: "POST", + body: new FormData(), + }); + expect(calls("POST /api/upload")[0].headers.has("Content-Type")).toBe(false); + }); + + test("no Authorization header when signed out", async () => { + route("GET /api/open", () => ({})); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) }); + await result.current.authFetch("http://localhost:8000/api/open"); + expect(calls("GET /api/open")[0].headers.has("Authorization")).toBe(false); + }); + + test("a 401 signs the session out", async () => { + const session = makeSession(); + storeSession(session); + route("GET /api/secret", () => new Response(null, { status: 401 })); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + const res = await act(() => + result.current.authFetch("http://localhost:8000/api/secret") + ); + expect(res.status).toBe(401); + expect(result.current.user).toBeNull(); + expect(stored()).toBeNull(); + }); + + test("an expired token never leaves the client", async () => { + const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) }; + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + const res = await act(() => + result.current.authFetch("http://localhost:8000/api/x") + ); + expect(res.status).toBe(401); + expect(calls("GET /api/x")).toHaveLength(0); + expect(result.current.user).toBeNull(); + }); +}); + +describe("authUpload", () => { + class FakeXHR { + static last: FakeXHR; + method = ""; + url = ""; + headers: Record = {}; + status = 0; + statusText = ""; + responseText = ""; + body: unknown; + private listeners: Record void>> = {}; + private uploadListeners: Record void>> = {}; + upload = { + addEventListener: (t: string, fn: (e: unknown) => void) => + (this.uploadListeners[t] ??= []).push(fn), + }; + constructor() { + FakeXHR.last = this; + } + open(method: string, url: string) { + this.method = method; + this.url = url; + } + setRequestHeader(k: string, v: string) { + this.headers[k] = v; + } + addEventListener(t: string, fn: (e: unknown) => void) { + (this.listeners[t] ??= []).push(fn); + } + send(body: unknown) { + this.body = body; + } + progress(loaded: number, total: number, lengthComputable = true) { + this.uploadListeners.progress?.forEach((f) => + f({ loaded, total, lengthComputable }) + ); + } + uploaded() { + this.uploadListeners.load?.forEach((f) => f({})); + } + respond(status: number, text: string) { + this.status = status; + this.responseText = text; + this.listeners.load?.forEach((f) => f({})); + } + error() { + this.listeners.error?.forEach((f) => f({})); + } + } + + afterEach(() => vi.unstubAllGlobals()); + + test("reports progress, then null while the server works, then the response", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXHR); + const session = makeSession(); + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + const progress: Array = []; + const body = new FormData(); + const promise = result.current.authUpload( + "http://localhost:8000/api/dumps", + body, + (f) => progress.push(f), + ); + const xhr = FakeXHR.last; + expect(xhr.method).toBe("POST"); + expect(xhr.headers.Authorization).toBe(`Bearer ${session.token}`); + expect(xhr.body).toBe(body); + xhr.progress(50, 100); + xhr.progress(10, 0, false); + xhr.uploaded(); + xhr.respond(201, JSON.stringify({ success: true, data: { id: "d" } })); + const res = await promise; + expect(progress).toEqual([0.5, null, null]); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ success: true, data: { id: "d" } }); + }); + + test("a network failure rejects like fetch does", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXHR); + const { result } = renderHook(useAuth, { + wrapper: wrapperFor(makeSession()), + }); + const promise = result.current.authUpload("u", new FormData(), () => {}, "PUT"); + expect(FakeXHR.last.method).toBe("PUT"); + FakeXHR.last.error(); + await expect(promise).rejects.toBeInstanceOf(TypeError); + }); + + test("a 401 signs out", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXHR); + const { result } = renderHook(useAuth, { + wrapper: wrapperFor(makeSession()), + }); + const promise = result.current.authUpload("u", new FormData(), () => {}); + act(() => FakeXHR.last.respond(401, "")); + await promise; + await waitFor(() => expect(result.current.user).toBeNull()); + }); + + test("an expired token short-circuits", async () => { + vi.stubGlobal("XMLHttpRequest", FakeXHR); + FakeXHR.last = undefined as unknown as FakeXHR; + const session = { ...makeSession(), token: makeToken({ expiresIn: -5 }) }; + const { result } = renderHook(useAuth, { wrapper: wrapperFor(session) }); + const res = await act(() => + result.current.authUpload("u", new FormData(), () => {}) + ); + expect(res.status).toBe(401); + expect(FakeXHR.last).toBeUndefined(); + }); +}); + +describe("useRequiredAuth", () => { + test("throws outside a session", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(useRequiredAuth, { wrapper: wrapperFor(null) })) + .toThrow(/Invariant/); + }); + test("returns the user inside one", () => { + const { result } = renderHook(useRequiredAuth, { + wrapper: wrapperFor(makeSession({ username: "z" })), + }); + expect(result.current.user.username).toBe("z"); + }); +}); + diff --git a/src/test/pages/Dump.test.tsx b/src/test/pages/Dump.test.tsx new file mode 100644 index 00000000..1722c060 --- /dev/null +++ b/src/test/pages/Dump.test.tsx @@ -0,0 +1,338 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route } from "react-router"; +import { Dump } from "../../pages/Dump.tsx"; +import { DumpEdit } from "../../pages/DumpEdit.tsx"; +import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx"; +import { calls, fail, route } from "../fetch.ts"; +import { + makeCategory, + makeComment, + makeDump, + makePlaylist, + makeSession, + makeUser, + wire, +} from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const owner = makeSession({ id: "owner", username: "owner" }); +const other = makeSession({ id: "other", username: "other" }); +const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); + +function dumpRoutes(dump = makeDump({ id: "d1", slug: "a-find", title: "A find", userId: "owner", comment: "Because **reasons**", categoryIds: ["c1"] })) { + route("GET /api/dumps/:id", () => wire(dump)); + route("GET /api/users/by-id/:id", () => wire(makeUser({ id: "owner", username: "owner" }))); + route("GET /api/dumps/:id/comments", () => []); + route("GET /api/dumps/:id/related", () => []); + route("GET /api/dumps/:id/playlists", () => []); + return dump; +} + +function openDump(session: ReturnType | null = null, url = "/dumps/a-find") { + const user = userEvent.setup(); + renderApp(, { + route: url, + path: "/dumps/:selectedDump", + session, + categories: [music], + extraRoutes: edit page

} />, + }); + const socket = FakeWebSocket.last; + socket.welcome(); + return { user, socket }; +} + +describe("Dump page", () => { + test("shows the dump, its author, categories and note", async () => { + dumpRoutes(); + openDump(); + expect(await screen.findByRole("heading", { name: "A find" })).toBeInTheDocument(); + expect(await screen.findByRole("link", { name: "owner" })).toHaveAttribute("href", "/users/owner"); + expect(screen.getByRole("link", { name: "Music" })).toHaveAttribute("href", "/music"); + expect(screen.getByText("reasons").tagName).toBe("STRONG"); + await waitFor(() => expect(document.title).toMatch(/^A find · /)); + expect(calls("GET /api/dumps/:id")[0].params.id).toBe("a-find"); + }); + + test("a missing dump is an error page", async () => { + route("GET /api/dumps/:id", () => fail("Dump not found", 404)); + route("GET /api/dumps/:id/comments", () => []); + route("GET /api/dumps/:id/related", () => []); + route("GET /api/dumps/:id/playlists", () => []); + openDump(); + expect(await screen.findByRole("alert")).toHaveTextContent("Dump not found"); + expect(screen.getByRole("button", { name: "← Back to all dumps" })).toBeInTheDocument(); + }); + + test("the owner can rename it in place, following the new slug", async () => { + dumpRoutes(); + route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "a-better-find", title: "A better find", userId: "owner" }))); + const { user } = openDump(owner); + await user.click(await screen.findByRole("heading", { name: "A find" })); + const input = screen.getByDisplayValue("A find"); + await user.clear(input); + await user.type(input, "A better find{Enter}"); + expect(await screen.findByRole("heading", { name: "A better find" })).toBeInTheDocument(); + await waitFor(() => expect(currentPath()).toBe("/dumps/a-better-find")); + expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ title: "A better find" }); + }); + + test("a refused rename says why; Escape backs out", async () => { + dumpRoutes(); + route("PUT /api/dumps/:id", () => fail("Title taken", 400)); + const { user } = openDump(owner); + await user.click(await screen.findByRole("heading", { name: "A find" })); + await user.type(screen.getByDisplayValue("A find"), "!{Enter}"); + expect(await screen.findByText("Title taken")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument(); + }); + + test("others can't rename or edit it", async () => { + dumpRoutes(); + const { user } = openDump(other); + await user.click(await screen.findByRole("heading", { name: "A find" })); + expect(screen.queryByDisplayValue("A find")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Edit" })).not.toBeInTheDocument(); + }); + + test("the owner gets an Edit link", async () => { + dumpRoutes(); + const { user } = openDump(owner); + await user.click(await screen.findByRole("link", { name: "Edit" })); + expect(currentPath()).toBe("/dumps/a-find/edit"); + }); + + test("the title can be edited from the keyboard", async () => { + dumpRoutes(); + const { user } = openDump(owner); + await screen.findByRole("heading", { name: "A find" }); + screen.getByTitle("Edit title").focus(); + await user.keyboard("{Enter}"); + expect(screen.getByDisplayValue("A find")).toBeInTheDocument(); + }); + + test("votes go through the socket", async () => { + dumpRoutes(makeDump({ id: "d1", slug: "a-find", title: "A find", voteCount: 2 })); + const { user, socket } = openDump(other); + await user.click(await screen.findByRole("button", { name: "Upvote" })); + expect(socket.messages).toContainEqual({ type: "vote_cast", dumpId: "d1" }); + expect(screen.getByRole("button", { name: "Remove vote" })).toHaveTextContent("3"); + }); + + test("comments load, and live ones arrive", async () => { + dumpRoutes(); + route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c1", dumpId: "d1", body: "first" })])); + const { socket } = openDump(other); + await screen.findByText("first"); + socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c2", dumpId: "d1", body: "second" })) }); + expect(screen.getByText("second")).toBeInTheDocument(); + socket.receive({ type: "comment_deleted", commentId: "c1", dumpId: "d1" }); + expect(screen.getByText("[deleted]")).toBeInTheDocument(); + // Another dump's comment isn't shown here. + socket.receive({ type: "comment_created", comment: wire(makeComment({ id: "c3", dumpId: "zzz", body: "elsewhere" })) }); + expect(screen.queryByText("elsewhere")).not.toBeInTheDocument(); + }); + + test("related dumps and the playlists it's in", async () => { + dumpRoutes(); + route("GET /api/dumps/:id/related", () => wire([makeDump({ title: "Links here" })])); + route("GET /api/dumps/:id/playlists", () => wire([makePlaylist({ title: "Best of" })])); + openDump(); + expect(await screen.findByRole("heading", { name: "Related" })).toBeInTheDocument(); + expect(screen.getByText("Links here")).toBeInTheDocument(); + expect(await screen.findByText("Best of")).toBeInTheDocument(); + }); + + test("a live update of this dump is applied", async () => { + const d = dumpRoutes(); + const { socket } = openDump(); + await screen.findByRole("heading", { name: "A find" }); + socket.receive({ type: "dump_updated", dump: wire({ ...d, title: "Retitled" }) }); + expect(screen.getByRole("heading", { name: "Retitled" })).toBeInTheDocument(); + }); + + test("adding to a playlist", async () => { + dumpRoutes(); + route("GET /api/playlists/by-dump/:id/memberships", () => [ + { playlist: wire(makePlaylist({ id: "p", title: "Mine" })), hasDump: true }, + ]); + route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 })); + const { user } = openDump(owner); + await user.click(await screen.findByRole("button", { name: "+ Playlist" })); + const row = await screen.findByRole("checkbox", { name: /Mine/ }); + expect(row).toHaveAttribute("aria-checked", "true"); + await user.click(row); + await waitFor(() => expect(row).toHaveAttribute("aria-checked", "false")); + }); + + test("a new playlist can be made from there, with the dump in it", async () => { + dumpRoutes(); + route("GET /api/playlists/by-dump/:id/memberships", () => []); + route("POST /api/playlists", (req) => wire(makePlaylist({ id: "np", title: (req.body as { title: string }).title }))); + route("POST /api/playlists/:pid/dumps/:did", () => ({})); + const { user } = openDump(owner); + await user.click(await screen.findByRole("button", { name: "+ Playlist" })); + await user.click(await screen.findByRole("button", { name: "+ New playlist" })); + await user.type(screen.getByPlaceholderText("Title"), "Fresh{Enter}"); + const row = await screen.findByRole("checkbox", { name: /Fresh/ }); + expect(row).toHaveAttribute("aria-checked", "true"); + expect(calls("POST /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/np/dumps/d1"); + }); + + test("scrolls to a linked comment", async () => { + dumpRoutes(); + route("GET /api/dumps/:id/comments", () => wire([makeComment({ id: "c9", dumpId: "d1", body: "linked" })])); + const scroll = vi.spyOn(Element.prototype, "scrollIntoView"); + openDump(null, "/dumps/a-find#comment-c9"); + await screen.findByText("linked"); + await waitFor(() => expect(document.getElementById("comment-c9")).toHaveClass("comment-node--highlight")); + expect(scroll).toHaveBeenCalled(); + }); +}); + +describe("DumpEdit page", () => { + function openEdit(dump: ReturnType, session = owner) { + route("GET /api/dumps/:id", () => wire(dump)); + const user = userEvent.setup(); + renderApp( + , + { + route: `/dumps/${dump.slug ?? dump.id}/edit`, + path: "/dumps/:selectedDump/edit", + session, + categories: [music], + extraRoutes: ( + <> + dump page

} /> + home

} /> + + ), + }, + ); + FakeWebSocket.last.welcome(); + return user; + } + + const urlDump = () => + makeDump({ + id: "d1", + slug: "a-find", + title: "A find", + userId: "owner", + url: "https://a.test/", + comment: "old why", + categoryIds: [], + richContent: { type: "link", url: "https://a.test/", title: "Page's own title" }, + }); + + test("saves title, note, categories and visibility", async () => { + route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1", slug: "new-title" }))); + const user = openEdit(urlDump()); + const title = await screen.findByLabelText("Title"); + expect(document.title).toMatch(/^Editing A find/); + await user.clear(title); + await user.type(title, "New title"); + await user.click(screen.getByRole("button", { name: "Music" })); + await user.click(screen.getByRole("button", { name: "Private" })); + await user.click(screen.getByRole("button", { name: "Save" })); + await screen.findByText("dump page"); + expect(currentPath()).toBe("/dumps/new-title"); + expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ + url: "https://a.test/", + title: "New title", + comment: "old why", + isPrivate: true, + categoryIds: ["c1"], + }); + }); + + test("clearing the note removes it", async () => { + route("PUT /api/dumps/:id", () => wire(makeDump({ id: "d1" }))); + const user = openEdit(urlDump()); + await user.clear(await screen.findByLabelText("Why?")); + await user.click(screen.getByRole("button", { name: "Save" })); + await screen.findByText("dump page"); + expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null); + }); + + test("the title resets to the page's own", async () => { + const user = openEdit(urlDump()); + const title = await screen.findByLabelText("Title"); + await user.click(screen.getByRole("button", { name: "Reset to default" })); + expect(title).toHaveValue("Page's own title"); + expect(screen.getByRole("button", { name: "Reset to default" })).toBeDisabled(); + }); + + test("a refused save stays on the form", async () => { + route("PUT /api/dumps/:id", () => fail("Invalid URL", 400)); + const user = openEdit(urlDump()); + await user.click(await screen.findByRole("button", { name: "Save" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Could not save"); + expect(alert).toHaveTextContent("Invalid URL"); + }); + + test("refreshing the metadata", async () => { + route("POST /api/dumps/:id/refresh-metadata", () => + wire({ ...urlDump(), richContent: { type: "link", url: "https://a.test/", title: "Fresh title", description: "New blurb" } })); + const user = openEdit(urlDump()); + await user.click(await screen.findByRole("button", { name: "Refresh metadata" })); + expect(await screen.findByText("New blurb")).toBeInTheDocument(); + }); + + test("replacing a file keeps a visibility change made alongside", async () => { + const fileDump = makeDump({ id: "f1", kind: "file", title: "Song", userId: "owner", fileName: "song.mp3", fileMime: "audio/mpeg", fileSize: 10, url: undefined, isPrivate: false }); + route("PUT /api/dumps/:id/file", () => wire({ ...fileDump, fileName: "new.mp3" })); + route("PUT /api/dumps/:id", (req) => wire({ ...fileDump, ...(req.body as object) })); + const user = openEdit(fileDump); + await screen.findByText("song.mp3"); + const input = document.querySelector('.fdz input[type="file"]')!; + await user.upload(input, new File([new Uint8Array(3)], "new.mp3", { type: "audio/mpeg" })); + await user.click(screen.getByRole("button", { name: "Private" })); + await user.click(screen.getByRole("button", { name: "Save" })); + await screen.findByText("dump page"); + const [put] = calls("PUT /api/dumps/:id/file"); + expect((put.body as FormData).get("file")).toBeInstanceOf(File); + expect(calls("PUT /api/dumps/:id")[0]?.body).toMatchObject({ isPrivate: true }); + }); + + test("deleting asks, then goes home", async () => { + route("DELETE /api/dumps/:id", () => new Response(null, { status: 204 })); + const user = openEdit(urlDump()); + await user.click(await screen.findByRole("button", { name: "Delete dump" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" })); + await screen.findByText("home"); + expect(JSON.parse(document.querySelector('[data-testid="location"]')!.getAttribute("data-state")!)) + .toEqual({ deletedDumpId: "d1" }); + }); + + test("a delete that can't reach the server says so", async () => { + route("DELETE /api/dumps/:id", () => new TypeError("Failed to fetch")); + const user = openEdit(urlDump()); + await user.click(await screen.findByRole("button", { name: "Delete dump" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete dump" })); + expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/); + }); + + test("a thumbnail that won't upload says so", async () => { + route("PUT /api/dumps/:id/thumbnail", () => fail("Not an image", 400)); + const user = openEdit(urlDump()); + await screen.findByLabelText("Title"); + const picker = document.querySelector('.dump-edit-thumbnail-row input[type="file"]')!; + await user.upload(picker, new File([new Uint8Array(3)], "t.png", { type: "image/png" })); + expect(await screen.findByText("Not an image")).toBeInTheDocument(); + }); + + test("guests are sent to log in", () => { + renderApp(, { + route: "/dumps/x/edit", + path: "/dumps/:selectedDump/edit", + extraRoutes: login page

} />, + }); + expect(screen.getByText("login page")).toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/Index.test.tsx b/src/test/pages/Index.test.tsx new file mode 100644 index 00000000..1fd2255f --- /dev/null +++ b/src/test/pages/Index.test.tsx @@ -0,0 +1,198 @@ +import { describe, expect, test } from "vitest"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useParams } from "react-router"; +import { Index } from "../../pages/Index.tsx"; +import { calls, fail, route } from "../fetch.ts"; +import { makeCategory, makeDump, makeSession, page, wire } from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; +import { FakeIntersectionObserver } from "../setup.ts"; + +const HOUR = 3_600_000; +const music = makeCategory({ id: "c-music", slug: "music", name: "Music" }); + +function Scoped() { + const { scope } = useParams(); + const categorySlug = scope === "~" ? undefined : scope; + return ; +} + +function setup( + url: string, + { session = null as ReturnType | null } = {}, +) { + const user = userEvent.setup(); + renderApp(, { + route: url, + path: "/:scope/:feedTab", + session, + categories: [music], + }); + const socket = FakeWebSocket.last; + socket.welcome(); + return { user, socket }; +} + +const titles = () => + Array.from(document.querySelectorAll(".dump-card-title, .journal-card-title")) + .map((a) => a.textContent); + +describe("Index feeds", () => { + const older = makeDump({ id: "old", title: "Old but loved", voteCount: 30, createdAt: new Date(Date.now() - 5 * HOUR) }); + const newer = makeDump({ id: "new", title: "Brand new", voteCount: 0, createdAt: new Date(Date.now() - 60_000) }); + + test("New sorts by date, Hot by score", async () => { + route("GET /api/dumps/", () => page([older, newer])); + const { user } = setup("/~/new"); + await screen.findByText("Brand new"); + expect(titles()).toEqual(["Brand new", "Old but loved"]); + await user.click(screen.getAllByRole("button", { name: "Hot" })[0]); + expect(currentPath()).toBe("/~/hot"); + expect(titles()).toEqual(["Old but loved", "Brand new"]); + expect(screen.getByText("You've reached the end.")).toBeInTheDocument(); + // One fetch: switching tabs re-sorts what's loaded. + expect(calls("GET /api/dumps/")).toHaveLength(1); + }); + + test("Journal lays the feed out as a mosaic", async () => { + route("GET /api/dumps/", () => page([older, newer])); + setup("/~/journal"); + await screen.findByText("Brand new"); + expect(document.querySelector(".journal-grid")).not.toBeNull(); + }); + + test("an empty feed and a failed one", async () => { + route("GET /api/dumps/", () => page([])); + const first = setup("/~/new"); + await screen.findByText("No dumps yet. Be the first!"); + first.socket.drop(); + }); + + test("a failed load shows an error and disables posting", async () => { + route("GET /api/dumps/", () => fail("boom", 500)); + setup("/~/new", { session: makeSession() }); + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load"); + expect(screen.getByRole("button", { name: /Dump/ })).toBeDisabled(); + }); + + test("sends the token so your own private dumps come back", async () => { + const session = makeSession(); + route("GET /api/dumps/", () => page([])); + setup("/~/new", { session }); + await screen.findByText("No dumps yet. Be the first!"); + expect(calls("GET /api/dumps/")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`); + }); + + test("scrolling to the end loads the next page", async () => { + route("GET /api/dumps/", (req) => + req.url.searchParams.get("page") === "1" + ? page([newer], true) + : page([older], false)); + setup("/~/new"); + await screen.findByText("Brand new"); + const observer = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!; + act(() => observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], observer as never)); + await screen.findByText("Old but loved"); + expect(calls("GET /api/dumps/")[1].url.searchParams.get("page")).toBe("2"); + }); + + test("live: a new dump goes on top; a deleted one goes away", async () => { + route("GET /api/dumps/", () => page([older])); + const { socket } = setup("/~/new"); + await screen.findByText("Old but loved"); + socket.receive({ type: "dump_created", dump: wire(makeDump({ id: "live", title: "Just now", createdAt: new Date() })) }); + expect(titles()[0]).toBe("Just now"); + socket.receive({ type: "dump_deleted", dumpId: "old" }); + expect(screen.queryByText("Old but loved")).not.toBeInTheDocument(); + }); + + test("live votes update the count", async () => { + route("GET /api/dumps/", () => page([older])); + const { socket } = setup("/~/new"); + await screen.findByText("Old but loved"); + socket.receive({ type: "votes_update", dumpId: "old", voteCount: 31, voterId: "x", action: "cast" }); + expect(screen.getByRole("button", { name: "Upvote" })).toHaveTextContent("31"); + }); + + test("guests can't vote", async () => { + route("GET /api/dumps/", () => page([older])); + setup("/~/new"); + await screen.findByText("Old but loved"); + expect(screen.getByRole("button", { name: "Upvote" })).toBeDisabled(); + }); + + test("a reconnect backfills what was missed", async () => { + route("GET /api/dumps/", () => page([older]), { once: true }); + route("GET /api/dumps/", () => page([newer, older])); + const { socket } = setup("/~/new"); + await screen.findByText("Old but loved"); + socket.drop(); + await act(() => new Promise((r) => setTimeout(r, 600))); + FakeWebSocket.last.welcome(); + await screen.findByText("Brand new"); + }); + + test("a category scopes the feed, and live arrivals to it", async () => { + route("GET /api/categories/:slug/dumps", () => page([makeDump({ title: "A song", categoryIds: ["c-music"] })])); + const { socket } = setup("/music/new"); + await screen.findByText("A song"); + expect(calls("GET /api/categories/:slug/dumps")[0].params.slug).toBe("music"); + await waitFor(() => expect(document.title).toMatch(/^Music · /)); + socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Elsewhere", createdAt: new Date() })) }); + socket.receive({ type: "dump_created", dump: wire(makeDump({ title: "Another song", categoryIds: ["c-music"], createdAt: new Date() })) }); + expect(screen.queryByText("Elsewhere")).not.toBeInTheDocument(); + expect(screen.getByText("Another song")).toBeInTheDocument(); + }); + + test("an unknown category is a 404", async () => { + route("GET /api/categories/:slug/dumps", () => fail("nope", 404)); + setup("/nonsense/new"); + expect(await screen.findByRole("heading", { name: "404" })).toBeInTheDocument(); + }); + + test("a guest on the followed tab gets the default feed, not a blank page", async () => { + route("GET /api/dumps/", () => page([older])); + setup("/~/followed"); + await screen.findByText("Old but loved"); + }); + + test("the Android share sheet opens the new-dump form with the link", async () => { + route("GET /api/dumps/", () => page([])); + route("GET /api/preview", () => ({ reached: false, richContent: null })); + route("GET /api/dumps/by-url", () => []); + setup("/~/new?share_url=https%3A%2F%2Fshared.test%2F", { session: makeSession() }); + const dialog = await screen.findByRole("dialog", { name: "New dump" }); + expect(within(dialog).getByPlaceholderText("Paste a link…")).toHaveValue("https://shared.test/"); + }); +}); + +describe("Followed feed", () => { + test("from people and from playlists", async () => { + const session = makeSession(); + route("GET /api/follows/feed/users", () => page([makeDump({ title: "By a friend" })])); + route("GET /api/follows/feed/playlists", () => page([])); + const { user } = setup("/~/followed", { session }); + await screen.findByText("By a friend"); + await user.click(screen.getByRole("button", { name: "From playlists" })); + expect(screen.getByText("Follow some public playlists to see their dumps here.")).toBeInTheDocument(); + expect(calls("GET /api/follows/feed/users")[0].headers.get("Authorization")).toBe(`Bearer ${session.token}`); + }); + + test("a category narrows it", async () => { + route("GET /api/follows/feed/users", () => page([])); + route("GET /api/follows/feed/playlists", () => page([])); + setup("/music/followed", { session: makeSession() }); + await screen.findByText("Follow some users to see their dumps here."); + expect(calls("GET /api/follows/feed/users")[0].url.searchParams.get("category")).toBe("music"); + }); + + test("a server error reads as one, not as a lost connection", async () => { + route("GET /api/follows/feed/users", () => fail("Internal error", 500)); + route("GET /api/follows/feed/playlists", () => page([])); + setup("/~/followed", { session: makeSession() }); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Internal error"); + expect(alert).not.toHaveTextContent(/Could not connect/); + }); +}); diff --git a/src/test/pages/Notifications.test.tsx b/src/test/pages/Notifications.test.tsx new file mode 100644 index 00000000..18b65381 --- /dev/null +++ b/src/test/pages/Notifications.test.tsx @@ -0,0 +1,128 @@ +import { describe, expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Notifications } from "../../pages/Notifications.tsx"; +import type { Notification } from "../../model.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { makeNotification, makeSession, page, wire } from "../fixtures.ts"; +import { renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const me = makeSession({ id: "me" }); +const DAY = 86_400_000; + +function open(items: Notification[], { hasMore = false } = {}) { + route("GET /api/notifications", (req) => + req.url.searchParams.get("page") === "1" ? page(items, hasMore) : page([])); + route("POST /api/notifications/read-all", () => ({})); + const user = userEvent.setup(); + renderApp(, { session: me, route: "/notifications" }); + const socket = FakeWebSocket.last; + socket.welcome({ unreadNotificationCount: items.filter((n) => !n.read).length }); + return { user, socket }; +} + +const n = { + follow: makeNotification({ type: "user_followed", data: { followerId: "f", followerUsername: "fan" } }), + playlistFollow: makeNotification({ type: "playlist_followed", data: { followerId: "f", followerUsername: "fan", playlistId: "p1", playlistTitle: "Mix" } }), + posted: makeNotification({ type: "user_dump_posted", data: { dumperId: "a", dumperUsername: "ann", dumpId: "d1", dumpTitle: "Song" } }), + added: makeNotification({ type: "playlist_dump_added", data: { dumpId: "d2", dumpTitle: "Tune", playlistId: "p", playlistTitle: "Mix" } }), + upvoted: makeNotification({ type: "dump_upvoted", data: { voterId: "v", voterUsername: "bob", dumpId: "d3", dumpTitle: "Pic" } }), + commented: makeNotification({ type: "dump_commented", data: { commenterId: "c", commenterUsername: "cy", commentId: "c1", dumpId: "d4", dumpTitle: "Post" } }), + liked: makeNotification({ type: "comment_liked", data: { likerId: "l", likerUsername: "lu", commentId: "c2", dumpId: "d5", dumpTitle: "Thing" } }), + mentionComment: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "comment", contextId: "c3", contextTitle: "", dumpId: "d6" } }), + mentionChat: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "chat", contextId: "x", contextTitle: "" } }), + mentionPlaylist: makeNotification({ type: "user_mentioned", data: { mentionerId: "m", mentionerUsername: "mo", contextType: "playlist", contextId: "p9", contextTitle: "Faves" } }), +}; + +describe("Notifications", () => { + test("every kind reads right and links to its subject", async () => { + open(Object.values(n)); + const link = async (text: RegExp) => (await screen.findByText(text)).closest("a")!.getAttribute("href"); + expect(await link(/started following you/)).toBe("/users/fan"); + expect(await link(/followed your playlist/)).toBe("/playlists/p1"); + expect(await link(/^posted$/)).toBe("/dumps/d1"); + expect(await link(/was added to/)).toBe("/dumps/d2"); + expect(await link(/^upvoted$/)).toBe("/dumps/d3"); + expect(await link(/commented on/)).toBe("/dumps/d4#comment-c1"); + expect(await link(/liked your comment on/)).toBe("/dumps/d5#comment-c2"); + const mentions = screen.getAllByText(/mentioned you in/).map((el) => el.closest("a")!.getAttribute("href")); + expect(mentions).toEqual(["/dumps/d6#comment-c3", "/notifications?chat=open", "/playlists/p9"]); + expect(screen.getByText("a comment")).toBeInTheDocument(); + expect(screen.getByText("the chat")).toBeInTheDocument(); + expect(screen.getByText("Faves")).toBeInTheDocument(); + }); + + test("grouped by day", async () => { + open([ + makeNotification({ createdAt: new Date() }), + makeNotification({ createdAt: new Date(Date.now() - DAY - 60_000) }), + makeNotification({ createdAt: new Date(Date.now() - 10 * DAY) }), + ]); + expect(await screen.findByRole("heading", { name: "Today" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Earlier" })).toBeInTheDocument(); + }); + + test("opening them marks them read and clears the bell, but still shows which were new", async () => { + open([n.follow, { ...n.upvoted, read: true }]); + await screen.findByText(/started following you/); + await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument() + ); + expect(document.querySelectorAll(".notif-dot")).toHaveLength(1); + expect(screen.getByText(/started following you/).closest("li")) + .toHaveClass("notification-item--unread"); + expect(document.querySelector(".notifications-unread-pill")).toHaveTextContent("1 new"); + }); + + test("a list that loaded stays shown if marking it read fails", async () => { + route("POST /api/notifications/read-all", () => new TypeError("offline")); + open([n.follow]); + expect(await screen.findByText(/started following you/)).toBeInTheDocument(); + await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(1)); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByText(/started following you/)).toBeInTheDocument(); + }); + + test("a failed load shows the server's reason", async () => { + route("GET /api/notifications", () => fail("Database is locked", 500)); + renderApp(, { session: me }); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Failed to load"); + expect(alert).toHaveTextContent("Database is locked"); + expect(calls("POST /api/notifications/read-all")).toHaveLength(0); + }); + + test("empty", async () => { + open([]); + expect(await screen.findByText("Nothing here yet.")).toBeInTheDocument(); + }); + + test("a new one arrives at the top while the page is open", async () => { + const { socket } = open([n.follow]); + await screen.findByText(/started following you/); + socket.receive({ type: "notification_created", notification: wire(n.upvoted) }); + const items = document.querySelectorAll(".notification-item"); + expect(items[0]).toHaveTextContent("bob upvoted Pic"); + expect(items[0]).toHaveClass("notification-item--unread"); + // Seen as it arrives: marked read, so the bell doesn't count it. + await waitFor(() => expect(calls("POST /api/notifications/read-all")).toHaveLength(2)); + await waitFor(() => + expect(screen.getByRole("button", { name: "Notifications" })).toBeInTheDocument() + ); + }); + + test("load more", async () => { + route("GET /api/notifications", (req) => + req.url.searchParams.get("page") === "1" + ? page([n.follow], true) + : page([n.upvoted], false)); + route("POST /api/notifications/read-all", () => ({})); + const user = userEvent.setup(); + renderApp(, { session: me }); + await user.click(await screen.findByRole("button", { name: "Load more" })); + await screen.findByText(/^upvoted$/); + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/PlaylistDetail.test.tsx b/src/test/pages/PlaylistDetail.test.tsx new file mode 100644 index 00000000..9da0228e --- /dev/null +++ b/src/test/pages/PlaylistDetail.test.tsx @@ -0,0 +1,220 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route } from "react-router"; +import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx"; +import type { Dump, Playlist } from "../../model.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const owner = makeSession({ id: "owner", username: "owner" }); +const visitor = makeSession({ id: "visitor" }); + +const dumps = () => [ + makeDump({ id: "a", title: "Alpha" }), + makeDump({ id: "b", title: "Bravo" }), + makeDump({ id: "c", title: "Charlie" }), +]; + +function open( + { session = owner as ReturnType | null, playlist = {} as Partial, items = dumps() as Dump[] } = {}, +) { + const pl = makePlaylist({ + id: "p1", + slug: "mix", + title: "Mix", + userId: "owner", + ownerUsername: "owner", + description: "Good **tunes**", + ...playlist, + }); + route("GET /api/playlists/:id", () => wire({ ...pl, dumps: items })); + const user = userEvent.setup(); + renderApp(, { + route: "/playlists/mix", + path: "/playlists/:playlistId", + session, + extraRoutes: home

} />, + }); + const socket = FakeWebSocket.last; + socket.welcome(); + return { user, socket, pl }; +} + +const titles = () => + Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent); + +describe("PlaylistDetail", () => { + test("shows the playlist and its dumps in order", async () => { + open({ session: null }); + expect(await screen.findByRole("heading", { name: "Mix" })).toBeInTheDocument(); + expect(screen.getByText("tunes").tagName).toBe("STRONG"); + expect(screen.getByRole("link", { name: "@owner" })).toHaveAttribute("href", "/users/owner"); + expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]); + await waitFor(() => expect(document.title).toMatch(/^Mix · /)); + expect(screen.queryByRole("button", { name: "Edit" })).not.toBeInTheDocument(); + }); + + test("not found", async () => { + route("GET /api/playlists/:id", () => fail("nope", 404)); + renderApp(, { route: "/playlists/x", path: "/playlists/:playlistId" }); + expect(await screen.findByRole("alert")).toHaveTextContent("Playlist not found"); + }); + + test("a visitor can follow a public playlist, not edit it", async () => { + open({ session: visitor }); + await screen.findByRole("heading", { name: "Mix" }); + expect(screen.getByRole("button", { name: "Follow playlist" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove from playlist" })).not.toBeInTheDocument(); + }); + + test("the cover is versioned on the last edit", async () => { + open({ playlist: { imageMime: "image/png", updatedAt: new Date(5000) } }); + await screen.findByRole("heading", { name: "Mix" }); + expect(document.querySelector(".playlist-detail-img")).toHaveAttribute( + "src", + "http://localhost:8000/api/playlists/p1/image?v=5000", + ); + }); + + test("editing the description shows at once, without waiting for the socket", async () => { + route("PUT /api/playlists/:id", () => + wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", description: "Better tunes" }))); + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + const box = screen.getByPlaceholderText("Description (optional)"); + await user.clear(box); + await user.type(box, "Better tunes"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Better tunes")).toBeInTheDocument(); + expect(calls("PUT /api/playlists/:id")[0].body).toEqual({ description: "Better tunes", isPublic: true }); + expect(currentPath()).toBe("/playlists/mix"); + }); + + test("a new title moves to the new slug", async () => { + route("PUT /api/playlists/:id", () => + wire(makePlaylist({ id: "p1", slug: "remix", title: "Remix", userId: "owner" }))); + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + const title = screen.getByPlaceholderText("Playlist title"); + await user.clear(title); + await user.type(title, "Remix"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(currentPath()).toBe("/playlists/remix")); + }); + + test("a new cover shows once saved; a refused one says why", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:preview"); + route("PUT /api/playlists/:id", () => wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner" }))); + route("POST /api/playlists/:id/image", () => fail("Not an image", 400), { once: true }); + route("POST /api/playlists/:id/image", () => + wire(makePlaylist({ id: "p1", slug: "mix", title: "Mix", userId: "owner", imageMime: "image/png", updatedAt: new Date(9000) }))); + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + const picker = document.querySelector('.img-picker input[type="file"]')!; + await user.upload(picker, new File([new Uint8Array(3)], "c.png", { type: "image/png" })); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Not an image"); + await user.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => + expect(document.querySelector(".playlist-detail-img")).toHaveAttribute( + "src", + "http://localhost:8000/api/playlists/p1/image?v=9000", + ) + ); + }); + + test("Escape leaves the editor", async () => { + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + await user.type(screen.getByPlaceholderText("Playlist title"), "{Escape}"); + expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument(); + }); + + test("removing a dump fades it out, with an undo", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + route("DELETE /api/playlists/:pid/dumps/:did", () => new Response(null, { status: 204 })); + route("POST /api/playlists/:pid/dumps/:did", () => ({})); + route("PUT /api/playlists/:pid/order", () => ({})); + const { user } = open(); + await screen.findByText("Bravo"); + const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement; + await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" })); + expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe("/api/playlists/mix/dumps/b"); + await user.click(within(bravo).getByRole("button", { name: "Cancel removal" })); + await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)); + expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["a", "b", "c"] }); + + await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" })); + act(() => vi.advanceTimersByTime(2_400)); + expect(screen.queryByText("Bravo")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + test("a refused removal puts the dump back and says so", async () => { + route("DELETE /api/playlists/:pid/dumps/:did", () => fail("Forbidden", 403)); + const { user } = open(); + await screen.findByText("Bravo"); + const bravo = screen.getByText("Bravo").closest(".playlist-dump-item") as HTMLElement; + await user.click(within(bravo).getByRole("button", { name: "Remove from playlist" })); + expect(await screen.findByRole("alert")).toHaveTextContent("couldn't be removed"); + expect(within(bravo).getByRole("button", { name: "Remove from playlist" })).toBeInTheDocument(); + }); + + test("dragging reorders, and a refused reorder reloads the real order", async () => { + route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403)); + open(); + await screen.findByText("Charlie"); + const items = () => Array.from(document.querySelectorAll(".playlist-dump-item")); + for (const el of items()) { + el.getBoundingClientRect = () => ({ top: 0, height: 100 }) as DOMRect; + } + const [a, , c] = items(); + fireEvent.dragStart(a); + fireEvent.dragOver(c, { clientY: 90 }); + expect(titles()).toEqual(["Bravo", "Charlie", "Alpha"]); + fireEvent.dragEnd(items()[2]); + await waitFor(() => expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1)); + expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ dumpIds: ["b", "c", "a"] }); + await waitFor(() => expect(calls("GET /api/playlists/:id")).toHaveLength(2)); + await waitFor(() => expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])); + }); + + test("deleting the playlist asks, then goes home", async () => { + route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 })); + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" })); + expect(await screen.findByText("home")).toBeInTheDocument(); + }); + + test("a refused delete stays and says why", async () => { + route("DELETE /api/playlists/:id", () => fail("Forbidden", 403)); + const { user } = open(); + await user.click(await screen.findByRole("button", { name: "Edit" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden"); + expect(currentPath()).toBe("/playlists/mix"); + }); + + test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => { + route("GET /api/dumps/:id", () => wire(makeDump({ id: "d", title: "Delta" }))); + const { socket } = open({ session: visitor }); + await screen.findByText("Alpha"); + socket.receive({ type: "playlist_dumps_updated", playlistId: "p1", dumpIds: ["d", "a", "b", "c"] }); + expect(await screen.findByText("Delta")).toBeInTheDocument(); + socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Renamed", userId: "owner" })) }); + expect(screen.getByRole("heading", { name: "Renamed" })).toBeInTheDocument(); + socket.receive({ type: "dump_deleted", dumpId: "a" }); + expect(screen.queryByText("Alpha")).not.toBeInTheDocument(); + socket.receive({ type: "playlist_deleted", playlistId: "p1", userId: "owner" }); + expect(await screen.findByText("home")).toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/Search.test.tsx b/src/test/pages/Search.test.tsx new file mode 100644 index 00000000..9934b021 --- /dev/null +++ b/src/test/pages/Search.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { act, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Search } from "../../pages/Search.tsx"; +import { calls, fail, route } from "../fetch.ts"; +import { makeDump, makePlaylist, makeSession, makeUser, wire } from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeIntersectionObserver } from "../setup.ts"; + +function results( + { dumps = [] as ReturnType[], hasMore = false, users = [] as ReturnType[], playlists = [] as ReturnType[] } = {}, +) { + return { + dumps: { items: wire(dumps), total: dumps.length + (hasMore ? 1 : 0), hasMore }, + users: wire(users), + playlists: wire(playlists), + }; +} + +function open(url: string, session: ReturnType | null = null) { + const user = userEvent.setup(); + renderApp(, { route: url, path: "/search/*", session }); + return user; +} + +describe("Search", () => { + test("no query: a prompt, no request", () => { + open("/search"); + expect(screen.getByText("Enter a query to search.")).toBeInTheDocument(); + expect(calls("GET /api/search")).toHaveLength(0); + }); + + test("results by tab, with counts", async () => { + route("GET /api/search", () => + results({ + dumps: [makeDump({ title: "Cat video" })], + users: [makeUser({ username: "catlover", description: "meow" })], + playlists: [makePlaylist({ title: "Cats" })], + })); + const user = open("/search?q=cat", makeSession()); + await screen.findByText("Cat video"); + const [req] = calls("GET /api/search"); + expect(req.url.searchParams.get("q")).toBe("cat"); + expect(req.headers.get("Authorization")).toMatch(/^Bearer /); + await user.click(screen.getByRole("button", { name: "Users (1)" })); + expect(currentPath()).toBe("/search/~/users?q=cat"); + expect(screen.getByRole("link", { name: /@catlover/ })).toHaveAttribute("href", "/users/catlover"); + await user.click(screen.getByRole("button", { name: "Playlists (1)" })); + expect(screen.getByText("Cats")).toBeInTheDocument(); + }); + + test("nothing found", async () => { + route("GET /api/search", () => results()); + const user = open("/search?q=zzz"); + expect(await screen.findByText('No dumps match "zzz".')).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Users (0)" })); + expect(screen.getByText('No users match "zzz".')).toBeInTheDocument(); + }); + + test("a failed search says why", async () => { + route("GET /api/search", () => fail("Query too short", 400)); + open("/search?q=a"); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Search failed"); + expect(alert).toHaveTextContent("Query too short"); + }); + + test("more dumps on scroll; a failed page keeps what's shown", async () => { + route("GET /api/search", () => results({ dumps: [makeDump({ title: "First" })], hasMore: true }), { once: true }); + route("GET /api/search", () => new TypeError("offline"), { once: true }); + route("GET /api/search", () => results({ dumps: [makeDump({ title: "Second" })] })); + open("/search?q=x"); + await screen.findByText("First"); + const scroll = () => { + const obs = FakeIntersectionObserver.instances.findLast((o) => o.elements.size > 0)!; + act(() => obs.callback([{ isIntersecting: true } as IntersectionObserverEntry], obs as never)); + }; + scroll(); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + scroll(); + await screen.findByText("Second"); + expect(calls("GET /api/search").at(-1)!.url.searchParams.get("page")).toBe("2"); + }); + + test("a new query replaces the results", async () => { + route("GET /api/search", (req) => + results({ dumps: [makeDump({ title: `About ${req.url.searchParams.get("q")}` })] })); + const user = open("/search?q=one"); + await screen.findByText("About one"); + const [box] = screen.getAllByRole("searchbox"); + await user.clear(box); + await user.type(box, "two{Enter}"); + await screen.findByText("About two"); + expect(screen.queryByText("About one")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/UserPlaylists.test.tsx b/src/test/pages/UserPlaylists.test.tsx new file mode 100644 index 00000000..10256ba1 --- /dev/null +++ b/src/test/pages/UserPlaylists.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, test } from "vitest"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UserPlaylists } from "../../pages/UserPlaylists.tsx"; +import { calls, fail, route } from "../fetch.ts"; +import { makePlaylist, makeSession, makeUser, page, wire } from "../fixtures.ts"; +import { renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const ann = makeUser({ id: "ann", username: "ann" }); +const annSession = makeSession({ id: "ann", username: "ann" }); + +function open(session: ReturnType | null = null) { + route("GET /api/users/:u", () => wire(ann)); + route("GET /api/users/:u/playlists", () => + page([makePlaylist({ id: "p1", title: "Her mix", userId: "ann" })], true)); + route("GET /api/users/:u/followed-playlists", () => + page([makePlaylist({ id: "f1", title: "Someone's mix", userId: "bob", ownerUsername: "bob" })])); + const user = userEvent.setup(); + renderApp(, { route: "/users/ann/playlists", path: "/users/:username/playlists", session }); + return { user, socket: FakeWebSocket.last }; +} + +describe("UserPlaylists", () => { + test("created and followed playlists", async () => { + open(); + expect(await screen.findByText("Her mix")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Created (1+)" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Followed (1)" })).toBeInTheDocument(); + expect(screen.getByText("Someone's mix")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete playlist" })).not.toBeInTheDocument(); + }); + + test("the owner creates one and it goes on top", async () => { + route("POST /api/playlists", (req) => + wire(makePlaylist({ id: "new", title: (req.body as { title: string }).title, userId: "ann" }))); + const { user } = open(annSession); + await screen.findByText("Her mix"); + await user.click(screen.getByRole("button", { name: /New playlist/ })); + const dialog = screen.getByRole("dialog", { name: "New playlist" }); + await user.type(within(dialog).getByPlaceholderText("Title"), "Road trip"); + await user.click(within(dialog).getByRole("button", { name: "Private" })); + await user.click(within(dialog).getByRole("button", { name: "Create" })); + expect(await screen.findByText("Road trip")).toBeInTheDocument(); + expect(calls("POST /api/playlists")[0].body).toEqual({ title: "Road trip", isPublic: false }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + test("the owner deletes one, after confirming", async () => { + route("DELETE /api/playlists/:id", () => new Response(null, { status: 204 })); + const { user } = open(annSession); + await screen.findByText("Her mix"); + await user.click(screen.getByRole("button", { name: "Delete playlist" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" })); + await screen.findByText("No playlists yet."); + }); + + test("a refused delete keeps the playlist and says why", async () => { + route("DELETE /api/playlists/:id", () => fail("Forbidden", 403)); + const { user } = open(annSession); + await screen.findByText("Her mix"); + await user.click(screen.getByRole("button", { name: "Delete playlist" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete playlist" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden"); + expect(screen.getByText("Her mix")).toBeInTheDocument(); + }); + + test("live: her new public playlist appears, one gone private leaves", async () => { + const { socket } = open(); + await screen.findByText("Her mix"); + socket.welcome(); + socket.receive({ type: "playlist_created", playlist: wire(makePlaylist({ id: "p2", title: "Brand new mix", userId: "ann" })) }); + expect(screen.getByText("Brand new mix")).toBeInTheDocument(); + socket.receive({ type: "playlist_updated", playlist: wire(makePlaylist({ id: "p1", title: "Her mix", userId: "ann", isPublic: false })) }); + expect(screen.queryByText("Her mix")).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/pages/UserPublicProfile.test.tsx b/src/test/pages/UserPublicProfile.test.tsx new file mode 100644 index 00000000..08aeb2f5 --- /dev/null +++ b/src/test/pages/UserPublicProfile.test.tsx @@ -0,0 +1,316 @@ +import { describe, expect, test, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UserPublicProfile } from "../../pages/UserPublicProfile.tsx"; +import type { PublicUser } from "../../model.ts"; +import { calls, fail, route } from "../fetch.ts"; +import { + makeCategory, + makeDump, + makePlaylist, + makeSession, + makeUser, + page, + wire, +} from "../fixtures.ts"; +import { currentPath, renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; +import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; + +const ann = makeUser({ id: "ann", username: "ann", description: "I **dump** things", invitedByUsername: "root" }); +const annSession = makeSession({ id: "ann", username: "ann", email: "ann@x.test" }); + +function open( + { + session = null as ReturnType | null, + user = ann as PublicUser, + url = "/users/ann", + dumps = [makeDump({ id: "d1", title: "Her dump", userId: "ann" })], + votes = [makeDump({ id: "v1", title: "She liked this" })], + categories = [] as ReturnType[], + } = {}, +) { + route("GET /api/users/:u", () => wire(user)); + route("GET /api/users/:u/dumps", () => page(dumps)); + route("GET /api/users/:u/votes", () => page(votes)); + route("GET /api/users/:u/playlists", () => page([makePlaylist({ title: "Her mix", userId: "ann" })])); + const u = userEvent.setup(); + renderApp(, { + route: url, + path: "/users/:username/*", + session, + categories, + }); + return { user: u, socket: FakeWebSocket.last }; +} + +describe("UserPublicProfile", () => { + test("header, description, and the dumps tab", async () => { + open(); + expect(await screen.findByRole("heading", { name: "ann" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "@root" })).toHaveAttribute("href", "/users/root"); + expect(screen.getByText("dump").tagName).toBe("STRONG"); + expect(screen.getByText("Her dump")).toBeInTheDocument(); + expect(screen.getByText("She liked this")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Dumps (1)" })).toBeInTheDocument(); + await waitFor(() => expect(document.title).toMatch(/^ann · /)); + expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); + }); + + test("an unknown user", async () => { + route("GET /api/users/:u", () => fail("User not found", 404)); + route("GET /api/users/:u/dumps", () => page([])); + route("GET /api/users/:u/votes", () => page([])); + route("GET /api/users/:u/playlists", () => page([])); + renderApp(, { route: "/users/nobody", path: "/users/:username/*" }); + expect(await screen.findByRole("alert")).toHaveTextContent("User not found"); + }); + + test("a role chip for moderators, and none for users", async () => { + open({ user: { ...ann, role: "moderator" } }); + expect(await screen.findByText("Moderator")).toBeInTheDocument(); + }); + + test("tabs are in the URL", async () => { + const { user } = open(); + await screen.findByRole("heading", { name: "ann" }); + await user.click(screen.getByRole("button", { name: "Playlists" })); + expect(currentPath()).toBe("/users/ann/~/playlists"); + expect(screen.getByText("Her mix")).toBeInTheDocument(); + }); + + test("followed: people and playlists, with more people on demand", async () => { + route("GET /api/users/:u/followed-users", (req) => + req.url.searchParams.get("page") === "1" + ? page([makeUser({ username: "bob" })], true) + : page([makeUser({ username: "cy" })])); + route("GET /api/users/:u/followed-playlists", () => page([makePlaylist({ title: "Their mix" })])); + const { user } = open({ url: "/users/ann/~/followed" }); + expect(await screen.findByRole("link", { name: /@bob/ })).toHaveAttribute("href", "/users/bob"); + expect(screen.getByText("Their mix")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Load more" })); + expect(await screen.findByRole("link", { name: /@cy/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + }); + + test("invitees as a tree", async () => { + route("GET /api/users/:u/invitees", () => wire([ + { id: "b", username: "bob", invitedById: "ann", createdAt: new Date() }, + { id: "c", username: "cy", invitedById: "b", createdAt: new Date() }, + ])); + open({ url: "/users/ann/~/invitees" }); + const bob = (await screen.findByRole("link", { name: /@bob/ })).closest("li")!; + expect(within(bob).getByRole("link", { name: /@cy/ })).toBeInTheDocument(); + }); + + test("visitors can follow; signed in, a settings tab is only on your own profile", async () => { + open({ session: makeSession({ id: "bob" }) }); + expect(await screen.findByRole("button", { name: "Follow ann" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Settings" })).not.toBeInTheDocument(); + }); + + describe("your own profile", () => { + test("edit the description; clearing it really clears it", async () => { + route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) })); + const { user } = open({ session: annSession }); + const view = (await screen.findByText("dump")).closest("[title='Edit description']") as HTMLElement; + view.focus(); + await user.keyboard("{Enter}"); + const box = screen.getByPlaceholderText("Who am I?"); + await user.clear(box); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Who am I?")).toBeInTheDocument(); + expect(calls("PATCH /api/users/me")[0].body).toEqual({ description: "" }); + }); + + test("edit the email", async () => { + route("PATCH /api/users/me", (req) => wire({ ...ann, ...(req.body as object) })); + const { user } = open({ session: annSession }); + await user.click(await screen.findByRole("button", { name: /ann@x\.test/ })); + const input = screen.getByDisplayValue("ann@x.test"); + await user.clear(input); + await user.type(input, "new@x.test{Enter}"); + expect(await screen.findByRole("button", { name: /new@x\.test/ })).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.email).toBe("new@x.test"); + }); + + test("a refused email says why", async () => { + route("PATCH /api/users/me", () => fail("Email already in use", 409)); + const { user } = open({ session: annSession }); + await user.click(await screen.findByRole("button", { name: /ann@x\.test/ })); + await user.type(screen.getByDisplayValue("ann@x.test"), "x{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent("Email already in use"); + }); + + test("upload an avatar", async () => { + route("POST /api/avatars/me", () => wire({ ...ann, avatarMime: "image/png", updatedAt: new Date(7000) })); + const { user } = open({ session: annSession }); + await screen.findByRole("heading", { name: "ann" }); + const input = document.querySelector(".avatar-change-overlay input")!; + await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" })); + await waitFor(() => + expect(document.querySelector(".profile-avatar-wrapper img")).toHaveAttribute( + "src", + "http://localhost:8000/api/avatars/ann?v=7000", + ) + ); + }); + + test("a rejected avatar says why", async () => { + route("POST /api/avatars/me", () => fail("Not an image", 400)); + const { user } = open({ session: annSession }); + await screen.findByRole("heading", { name: "ann" }); + const input = document.querySelector(".avatar-change-overlay input")!; + await user.upload(input, new File([new Uint8Array(3)], "me.png", { type: "image/png" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Not an image"); + }); + + test("invite someone: a link to copy", async () => { + route("POST /api/invites", () => ({ token: "tok123" })); + const { user } = open({ session: annSession }); + await user.click(await screen.findByRole("button", { name: "+ Invite someone" })); + const link = await screen.findByRole("textbox", { name: "Invite link" }); + expect(link).toHaveValue(`${location.origin}/register?token=tok123`); + await user.click(screen.getByRole("button", { name: "Copy" })); + // user-event stands in for the clipboard. + expect(await navigator.clipboard.readText()).toBe(`${location.origin}/register?token=tok123`); + expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument(); + }); + + test("your upvotes show before the live connection welcomes you", async () => { + open({ session: annSession }); + // No welcome is sent. + expect(await screen.findByText("She liked this")).toBeInTheDocument(); + }); + + test("unvoting fades it out of your upvoted column", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { socket } = open({ session: annSession }); + await screen.findByText("She liked this"); + socket.welcome({ myVotes: ["v1"] }); + socket.receive({ type: "vote_ack", dumpId: "v1", action: "remove", voteCount: 0 }); + expect(screen.getByText("She liked this").closest(".dump-card")).toHaveClass("dump-card--fading"); + } finally { + vi.useRealTimers(); + } + }); + + test("settings: theme, colour scheme, default tab and sounds", async () => { + const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); + await user.click(await screen.findByRole("button", { name: "Brutalist" })); + expect(document.documentElement.getAttribute("data-style")).toBe("brutalist"); + await user.click(screen.getByRole("button", { name: "Dark" })); + expect(document.documentElement.getAttribute("data-color-scheme")).toBe("dark"); + await user.click(screen.getByRole("button", { name: "New" })); + expect(localStorage.getItem("default-feed-tab")).toBe("new"); + await user.click(screen.getByRole("button", { name: "Off" })); + expect(localStorage.getItem("sounds")).toBe("off"); + document.documentElement.removeAttribute("data-style"); + document.documentElement.removeAttribute("data-color-scheme"); + }); + + test("change password keeps you signed in with the fresh tokens", async () => { + route("POST /api/users/me/change-password", () => ({ token: "fresh-token", mediaToken: "fresh-media" })); + const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); + await user.click(await screen.findByRole("button", { name: "Change password…" })); + const dialog = screen.getByRole("dialog", { name: "Change password" }); + await user.type(within(dialog).getByLabelText("Current password"), "oldpassword"); + await user.type(within(dialog).getByLabelText("New password"), "newpassword"); + await user.type(within(dialog).getByLabelText("Confirm new password"), "different"); + expect(await within(dialog).findByText("Passwords do not match")).toBeInTheDocument(); + await user.clear(within(dialog).getByLabelText("Confirm new password")); + await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword"); + await user.click(within(dialog).getByRole("button", { name: "Change password" })); + expect(await within(dialog).findByText("Password changed successfully.")).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe("fresh-token"); + expect(calls("POST /api/users/me/change-password")[0].body).toEqual({ + currentPassword: "oldpassword", + newPassword: "newpassword", + }); + }); + + test("a wrong current password is reported", async () => { + route("POST /api/users/me/change-password", () => fail("Current password is incorrect", 400)); + const { user } = open({ session: annSession, url: "/users/ann/~/settings" }); + await user.click(await screen.findByRole("button", { name: "Change password…" })); + const dialog = screen.getByRole("dialog", { name: "Change password" }); + await user.type(within(dialog).getByLabelText("Current password"), "wrong"); + await user.type(within(dialog).getByLabelText("New password"), "newpassword"); + await user.type(within(dialog).getByLabelText("Confirm new password"), "newpassword{Enter}"); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("Current password is incorrect"); + }); + + test("log out", async () => { + const { user } = open({ session: annSession }); + await user.click(await screen.findByRole("button", { name: "Log out" })); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + }); + }); + + describe("manage", () => { + test("an admin can change someone's role", async () => { + route("PATCH /api/users/:id/role", (req) => wire({ ...ann, role: (req.body as { role: string }).role })); + const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" }); + await user.click(await screen.findByRole("button", { name: "Moderator" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Moderator" })).toHaveClass("active")); + expect(calls("PATCH /api/users/:id/role")[0].path).toBe("/api/users/ann/role"); + expect(document.querySelector(".profile-role-chip")).toHaveTextContent("Moderator"); + }); + + test("a refused role change says why", async () => { + route("PATCH /api/users/:id/role", () => fail("Cannot demote the last admin", 400)); + const { user } = open({ session: makeSession({ id: "admin", role: "admin" }), url: "/users/ann/~/manage" }); + await user.click(await screen.findByRole("button", { name: "Admin" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Cannot demote the last admin"); + }); + + test("non-admins get no manage tab", async () => { + open({ session: makeSession({ id: "mod", role: "moderator" }), url: "/users/ann/~/manage" }); + await screen.findByRole("heading", { name: "ann" }); + expect(screen.queryByRole("button", { name: "Manage" })).not.toBeInTheDocument(); + expect(screen.getByText("Her dump")).toBeInTheDocument(); + }); + + test("on your own profile, an admin manages categories", async () => { + const music = makeCategory({ id: "c1", slug: "music", name: "Music" }); + let list = [music]; + route("GET /api/categories", () => wire(list)); + route("POST /api/categories", (req) => { + const c = makeCategory({ id: "c2", ...(req.body as object) }); + list = [...list, c]; + return wire(c); + }); + route("DELETE /api/categories/:id", () => { + list = list.filter((c) => c.id !== "c1"); + return null; + }); + const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); + const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); + await user.click(await screen.findByRole("button", { name: "Add category" })); + await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema"); + await user.type(screen.getByRole("textbox", { name: "URL slug" }), "cinema"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByRole("button", { name: "Cinema" })).toBeInTheDocument(); + expect(calls("POST /api/categories")[0].body).toEqual({ name: "Cinema", slug: "cinema" }); + + await user.click(screen.getByRole("button", { name: "Music" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Delete category" })); + await waitFor(() => expect(screen.queryByRole("button", { name: "Music" })).not.toBeInTheDocument()); + }); + + test("a refused category is reported, and Escape closes the editor", async () => { + route("GET /api/categories", () => []); + route("POST /api/categories", () => fail("Slug already taken", 409)); + const admin = makeSession({ id: "ann", username: "ann", role: "admin" }); + const { user } = open({ session: admin, url: "/users/ann/~/manage", user: { ...ann, role: "admin" } }); + await user.click(await screen.findByRole("button", { name: "Add category" })); + await user.type(screen.getByRole("textbox", { name: "Name" }), "X"); + await user.type(screen.getByRole("textbox", { name: "URL slug" }), "x{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent("Slug already taken"); + await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}"); + expect(screen.getByRole("button", { name: "Add category" })).toBeInTheDocument(); + }); + }); +}); diff --git a/src/test/pages/UserSubpages.test.tsx b/src/test/pages/UserSubpages.test.tsx new file mode 100644 index 00000000..8b7de3ca --- /dev/null +++ b/src/test/pages/UserSubpages.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, test, vi } from "vitest"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { UserDumps } from "../../pages/UserDumps.tsx"; +import { UserUpvoted } from "../../pages/UserUpvoted.tsx"; +import { fail, route } from "../fetch.ts"; +import { makeDump, makeSession, makeUser, page, wire } from "../fixtures.ts"; +import { renderApp } from "../render.tsx"; +import { FakeWebSocket } from "../ws.ts"; + +const ann = makeUser({ id: "ann", username: "ann" }); +const annSession = makeSession({ id: "ann", username: "ann" }); + +function openDumps(session: ReturnType | null = null) { + const user = userEvent.setup(); + renderApp(, { route: "/users/ann/dumps", path: "/users/:username/dumps", session }); + return { user, socket: FakeWebSocket.last }; +} + +describe("UserDumps", () => { + test("lists a user's dumps under a header back to the profile", async () => { + route("GET /api/users/:u", () => wire(ann)); + route("GET /api/users/:u/dumps", () => page([makeDump({ title: "Hers", userId: "ann" })])); + openDumps(); + expect(await screen.findByText("Hers")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Dumps" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "← ann" })).toHaveAttribute("href", "/users/ann"); + expect(screen.queryByRole("button", { name: /New dump/ })).not.toBeInTheDocument(); + }); + + test("an unknown user", async () => { + route("GET /api/users/:u", () => fail("User not found", 404)); + route("GET /api/users/:u/dumps", () => page([])); + openDumps(); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "← Back to profile" })).toBeInTheDocument(); + }); + + test("on your own page: a new-dump button, and your dumps going private stay", async () => { + route("GET /api/users/:u", () => wire(ann)); + const mine = makeDump({ id: "m", title: "Mine", userId: "ann" }); + route("GET /api/users/:u/dumps", () => page([mine])); + const { user, socket } = openDumps(annSession); + await screen.findByText("Mine"); + socket.welcome(); + socket.receive({ type: "dump_updated", dump: wire({ ...mine, isPrivate: true }) }); + expect(screen.getByText("Mine")).toBeInTheDocument(); + route("GET /api/preview", () => ({ reached: false, richContent: null })); + await user.click(screen.getByRole("button", { name: /New dump/ })); + expect(screen.getByRole("dialog", { name: "New dump" })).toBeInTheDocument(); + }); + + test("a visitor loses a dump that goes private, and gets it back where it was", async () => { + route("GET /api/users/:u", () => wire(ann)); + const list = ["x", "y", "z"].map((id) => makeDump({ id, title: `T-${id}`, userId: "ann" })); + route("GET /api/users/:u/dumps", () => page(list)); + const { socket } = openDumps(); + await screen.findByText("T-z"); + socket.welcome(); + socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: true }) }); + expect(screen.queryByText("T-y")).not.toBeInTheDocument(); + socket.receive({ type: "dump_updated", dump: wire({ ...list[1], isPrivate: false }) }); + const titles = Array.from(document.querySelectorAll(".dump-card-title")).map((a) => a.textContent); + expect(titles).toEqual(["T-x", "T-y", "T-z"]); + }); +}); + +describe("UserUpvoted", () => { + const voted = () => [makeDump({ id: "v1", title: "Liked one" }), makeDump({ id: "v2", title: "Liked two" })]; + + function openVotes(session: ReturnType | null = null) { + route("GET /api/users/:u", () => wire(ann)); + route("GET /api/users/:u/votes", () => page(voted())); + const user = userEvent.setup(); + renderApp(, { route: "/users/ann/upvoted", path: "/users/:username/upvoted", session }); + return { user, socket: FakeWebSocket.last }; + } + + test("lists what they upvoted", async () => { + openVotes(); + expect(await screen.findByText("Liked one")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Upvoted" })).toBeInTheDocument(); + }); + + test("your own list shows even before the live connection says what you voted", async () => { + openVotes(annSession); + // The socket is never opened: no welcome, no myVotes. + expect(await screen.findByText("Liked one")).toBeInTheDocument(); + expect(screen.getByText("Liked two")).toBeInTheDocument(); + }); + + test("unvoting on your own list fades the dump out", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { socket } = openVotes(annSession); + await screen.findByText("Liked one"); + socket.welcome({ myVotes: ["v1", "v2"] }); + socket.receive({ type: "votes_update", dumpId: "v1", voteCount: 0, voterId: "ann", action: "remove" }); + expect(screen.getByText("Liked one").closest(".dump-card")).toHaveClass("dump-card--fading"); + act(() => vi.advanceTimersByTime(2_400)); + expect(screen.queryByText("Liked one")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + test("their new upvote appears at the top", async () => { + route("GET /api/dumps/:id", () => wire(makeDump({ id: "v3", title: "Fresh like" }))); + const { socket } = openVotes(); + await screen.findByText("Liked one"); + socket.welcome(); + socket.receive({ type: "votes_update", dumpId: "v3", voteCount: 1, voterId: "ann", action: "cast" }); + await waitFor(() => + expect(document.querySelector(".dump-card-title")).toHaveTextContent("Fresh like") + ); + }); +}); diff --git a/src/test/pages/auth.test.tsx b/src/test/pages/auth.test.tsx new file mode 100644 index 00000000..a528dd1c --- /dev/null +++ b/src/test/pages/auth.test.tsx @@ -0,0 +1,237 @@ +import { describe, expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route } from "react-router"; +import { UserLogin } from "../../pages/UserLogin.tsx"; +import { UserRegister } from "../../pages/UserRegister.tsx"; +import { ResetPassword } from "../../pages/ResetPassword.tsx"; +import { NotFound } from "../../pages/NotFound.tsx"; +import { RestrictedGuest } from "../../pages/RestrictedGuest.tsx"; +import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx"; +import { AUTH_STORAGE_KEY } from "../../utils/authStorage.ts"; +import { calls, deferred, fail, route } from "../fetch.ts"; +import { makeSession, wire } from "../fixtures.ts"; +import { currentPath, renderWithContexts } from "../render.tsx"; + +const home = home page

} />; + +describe("UserLogin", () => { + function setup() { + const user = userEvent.setup(); + renderWithContexts(, { route: "/login", path: "/login", extraRoutes: home }); + return user; + } + + test("logs in, stores the session and goes home", async () => { + const session = makeSession({ username: "ann" }); + route("POST /api/users/login", () => wire(session)); + const user = setup(); + expect(document.title).toMatch(/^Log in · /); + await user.type(screen.getByPlaceholderText("Username"), "ann"); + await user.type(screen.getByPlaceholderText("Password"), "hunter22{Enter}"); + await screen.findByText("home page"); + expect(calls("POST /api/users/login")[0].body).toEqual({ username: "ann", password: "hunter22" }); + expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).user.username).toBe("ann"); + }); + + test("both fields are required", async () => { + const user = setup(); + // The header has a "Log in" button too; this is the form's. + await user.click(document.querySelector(".auth-card button[type=submit]")!); + expect(calls("POST /api/users/login")).toHaveLength(0); + expect(screen.getByPlaceholderText("Username")).toHaveAttribute("aria-invalid", "true"); + }); + + test("shows the server's refusal", async () => { + route("POST /api/users/login", () => fail("Invalid credentials", 401)); + const user = setup(); + await user.type(screen.getByPlaceholderText("Username"), "ann"); + await user.type(screen.getByPlaceholderText("Password"), "nope{Enter}"); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Login failed"); + expect(alert).toHaveTextContent("Invalid credentials"); + expect(currentPath()).toBe("/login"); + }); + + test("says when the server can't be reached", async () => { + route("POST /api/users/login", () => new TypeError("Failed to fetch")); + const user = setup(); + await user.type(screen.getByPlaceholderText("Username"), "ann"); + await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent(/Could not connect/); + }); + + test("the button says so while logging in", async () => { + const pending = deferred(); + route("POST /api/users/login", () => pending.promise); + const user = setup(); + await user.type(screen.getByPlaceholderText("Username"), "ann"); + await user.type(screen.getByPlaceholderText("Password"), "pw{Enter}"); + expect(await screen.findByRole("button", { name: "Logging in…" })).toBeDisabled(); + pending.resolve(new Response(JSON.stringify({ success: true, data: wire(makeSession()) }))); + await screen.findByText("home page"); + }); + + test("password reset request", async () => { + route("POST /api/users/request-password-reset", () => ({})); + const user = setup(); + await user.click(screen.getByRole("button", { name: "Forgot password?" })); + await user.type(screen.getByPlaceholderText("Your email address"), " a@b.c {Enter}"); + await screen.findByText(/you'll receive a reset link/); + expect(calls("POST /api/users/request-password-reset")[0].body).toEqual({ email: "a@b.c" }); + }); + + test("a failed reset request is reported, not claimed as sent", async () => { + route("POST /api/users/request-password-reset", () => fail("Internal error", 500)); + const user = setup(); + await user.click(screen.getByRole("button", { name: "Forgot password?" })); + await user.type(screen.getByPlaceholderText("Your email address"), "a@b.c{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent("Request failed"); + expect(screen.queryByText(/you'll receive a reset link/)).not.toBeInTheDocument(); + }); +}); + +describe("UserRegister", () => { + function setup(search = "?token=inv-1") { + const user = userEvent.setup(); + renderWithContexts(, { + route: `/register${search}`, + path: "/register", + extraRoutes: home, + }); + return user; + } + + test("no token: an invalid invite, no request", () => { + setup(""); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid invite"); + expect(calls("GET /api/invites/:t")).toHaveLength(0); + }); + + test("an unknown token is invalid", async () => { + route("GET /api/invites/:t", () => fail("nope", 404)); + setup(); + expect(screen.getByText("Checking invite…")).toBeInTheDocument(); + expect(await screen.findByRole("alert")).toHaveTextContent("already used"); + }); + + test("registers with a valid invite", async () => { + route("GET /api/invites/:t", () => ({})); + const session = makeSession({ username: "new_one" }); + route("POST /api/users/register", () => wire(session)); + const user = setup(); + await user.type(await screen.findByPlaceholderText("Username"), "new_one"); + await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe"); + await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}"); + await screen.findByText("home page"); + expect(calls("POST /api/users/register")[0].body).toEqual({ + username: "new_one", + password: "longenough", + inviteToken: "inv-1", + email: "n@o.pe", + }); + expect(calls("GET /api/invites/:t")[0].params.t).toBe("inv-1"); + }); + + test("validates the username and password length", async () => { + route("GET /api/invites/:t", () => ({})); + const user = setup(); + await user.type(await screen.findByPlaceholderText("Username"), "bad name!"); + await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe"); + await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "short{Enter}"); + expect(await screen.findByText(/letters, numbers, or underscores/)).toBeInTheDocument(); + expect(screen.getByText("At least 8 characters")).toBeInTheDocument(); + expect(calls("POST /api/users/register")).toHaveLength(0); + }); + + test("shows a server refusal (username taken)", async () => { + route("GET /api/invites/:t", () => ({})); + route("POST /api/users/register", () => fail("Username already taken", 409)); + const user = setup(); + await user.type(await screen.findByPlaceholderText("Username"), "taken"); + await user.type(screen.getByPlaceholderText("Email address"), "n@o.pe"); + await user.type(screen.getByPlaceholderText("Password (min. 8 characters)"), "longenough{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent("Username already taken"); + }); +}); + +describe("ResetPassword", () => { + function setup(search = "?token=rst") { + const user = userEvent.setup(); + renderWithContexts(, { + route: `/reset-password${search}`, + path: "/reset-password", + extraRoutes: login page

} />, + }); + return user; + } + + test("no token: an invalid link", () => { + setup(""); + expect(screen.getByRole("heading", { name: "Invalid link" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login"); + }); + + test("the two passwords must match", async () => { + const user = setup(); + await user.type(screen.getByPlaceholderText("New password"), "longenough"); + await user.type(screen.getByPlaceholderText("Confirm new password"), "different"); + expect(await screen.findByText("Passwords do not match")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Set new password" })); + expect(calls("POST /api/users/reset-password")).toHaveLength(0); + }); + + test("sets the password, then offers login", async () => { + route("POST /api/users/reset-password", () => ({})); + const user = setup(); + await user.type(screen.getByPlaceholderText("New password"), "longenough"); + await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}"); + await screen.findByRole("heading", { name: "Password updated" }); + expect(calls("POST /api/users/reset-password")[0].body).toEqual({ token: "rst", newPassword: "longenough" }); + await user.click(screen.getByRole("button", { name: "Go to login" })); + expect(screen.getByText("login page")).toBeInTheDocument(); + }); + + test("an expired token is reported", async () => { + route("POST /api/users/reset-password", () => fail("Reset link expired", 400)); + const user = setup(); + await user.type(screen.getByPlaceholderText("New password"), "longenough"); + await user.type(screen.getByPlaceholderText("Confirm new password"), "longenough{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent("Reset link expired"); + }); +}); + +describe("route guards and 404", () => { + test("guest-only pages send members home", () => { + renderWithContexts(

form

, { + session: makeSession(), + route: "/login", + path: "/login", + extraRoutes: home, + }); + expect(screen.getByText("home page")).toBeInTheDocument(); + }); + + test("member-only pages send guests to login", () => { + renderWithContexts(

secret

, { + route: "/notifications", + path: "/notifications", + extraRoutes: login page

} />, + }); + expect(screen.getByText("login page")).toBeInTheDocument(); + }); + + test("each guard lets the right people through", () => { + renderWithContexts(

secret

, { + session: makeSession(), + }); + expect(screen.getByText("secret")).toBeInTheDocument(); + }); + + test("NotFound", async () => { + renderWithContexts(, { route: "/nope" }); + expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument(); + await waitFor(() => expect(document.title).toMatch(/^Page not found/)); + expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute("href", "/"); + }); +}); diff --git a/src/test/po.d.ts b/src/test/po.d.ts new file mode 100644 index 00000000..cbfe4648 --- /dev/null +++ b/src/test/po.d.ts @@ -0,0 +1,4 @@ +declare module "*.po" { + import type { Messages } from "@lingui/core"; + export const messages: Messages; +} diff --git a/src/test/render.tsx b/src/test/render.tsx new file mode 100644 index 00000000..671d85a7 --- /dev/null +++ b/src/test/render.tsx @@ -0,0 +1,213 @@ +import { type ReactElement, type ReactNode, useState } from "react"; +import { render, type RenderOptions } from "@testing-library/react"; +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; + +import type { AuthResponse, Category } from "../model.ts"; +import { AuthContext } from "../contexts/AuthContext.ts"; +import { AuthProvider } from "../contexts/AuthProvider.tsx"; +import { WSContext, type WSContextValue } from "../contexts/WSContext.ts"; +import { WSProvider } from "../contexts/WSProvider.tsx"; +import { FollowContext, type FollowContextValue } from "../contexts/FollowContext.ts"; +import { FollowProvider } from "../contexts/FollowProvider.tsx"; +import { + CategoriesContext, + type CategoriesContextValue, +} from "../contexts/CategoriesContext.ts"; +import { CategoriesProvider } from "../contexts/CategoriesProvider.tsx"; +import { PlayerContext, type PlayerContextValue } from "../contexts/PlayerContext.ts"; +import { PlayerProvider } from "../contexts/PlayerProvider.tsx"; +import { ThemeProvider } from "../contexts/ThemeProvider.tsx"; +import { ChatProvider } from "../contexts/ChatProvider.tsx"; +import { route } from "./fetch.ts"; +import { storeSession } from "./fixtures.ts"; + +/** Renders the current location into the DOM, for asserting navigation. */ +export function LocationProbe() { + const loc = useLocation(); + return ( + + ); +} + +/** The path the probe last saw. */ +export function currentPath(): string { + return document.querySelector('[data-testid="location"]')?.textContent ?? ""; +} + +interface RouterOptions { + /** Initial URL, or several for a history stack (the last is current). */ + route?: string | string[]; + /** Route pattern the element is mounted on, for useParams. */ + path?: string; + /** Extra routes to mount next to it, e.g. a redirect target. */ + extraRoutes?: ReactNode; +} + +function Router( + { children, route = "/", path = "*", extraRoutes }: RouterOptions & { + children: ReactNode; + }, +) { + const entries = Array.isArray(route) ? route : [route]; + return ( + + + + {extraRoutes} + + + + ); +} + +// ── Stubbed contexts ───────────────────────────────────────────────────────── + +export interface StubOptions extends RouterOptions { + session?: AuthResponse | null; + ws?: Partial; + follows?: Partial; + categories?: Category[] | Partial; + player?: Partial; +} + +function StubAuth( + { session, children }: { session: AuthResponse | null; children: ReactNode }, +) { + const [authResponse, setAuthResponse] = useState(session); + return ( + + {children} + + ); +} + +function categoriesValue( + c: StubOptions["categories"], +): CategoriesContextValue { + const list = Array.isArray(c) ? c : c?.categories ?? []; + return { + categories: list, + byId: new Map(list.map((x) => [x.id, x])), + isLoaded: true, + refresh: async () => {}, + ...(Array.isArray(c) ? {} : c), + }; +} + +/** A context's default value — what a consumer outside any provider gets. + * React keeps it on the context object; there is no public accessor. */ +export function defaultOf(context: React.Context): T { + return (context as unknown as { _currentValue: T })._currentValue; +} + +/** + * Renders `ui` with context values supplied directly — no network, no socket. + * For components whose inputs are the contexts they read. + */ +export function renderWithContexts( + ui: ReactElement, + opts: StubOptions = {}, + renderOptions?: RenderOptions, +) { + const defaultWS = defaultOf(WSContext); + const defaultFollows = defaultOf(FollowContext); + const defaultPlayer = defaultOf(PlayerContext); + // A wrapper rather than one tree, so `rerender(ui)` keeps the contexts. + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + {children} + + + + + + + ); + return render(ui, { ...renderOptions, wrapper: Wrapper }); +} + +// ── The real providers ─────────────────────────────────────────────────────── + +export interface AppOptions extends RouterOptions { + /** Signed in as this session (stored before mount, as after a login). */ + session?: AuthResponse | null; + categories?: Category[]; +} + +/** The routes every provider tree calls on mount, as fallbacks: a test's + * own routes for the same paths win, whenever they were added. */ +export function defaultRoutes( + { session, categories = [] }: Pick = {}, +) { + const fallback = { fallback: true }; + route( + "GET /api/categories", + () => categories.map((c) => JSON.parse(JSON.stringify(c))), + fallback, + ); + route("GET /api/follows/status", () => ({ + followedUserIds: [], + followedPlaylistIds: [], + }), fallback); + // Standing, not fallbacks: a page test's own `GET /api/users/:u` would + // otherwise answer for "me" and hand the session someone else's role. + route( + "GET /api/users/me", + () => JSON.parse(JSON.stringify(session?.user ?? null)), + ); + route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" })); +} + +/** + * Renders `ui` inside the same provider tree as App — real auth, socket (a + * FakeWebSocket), follows, categories, player and chat — over a memory router. + * Register routes the page needs with `route()` before or after calling this; + * the defaults for the providers' own requests are installed first so a test + * can override them. + */ +export function renderApp(ui: ReactElement, opts: AppOptions = {}) { + if (opts.session) storeSession(opts.session); + defaultRoutes(opts); + const { route: initial = "/", path = "*", extraRoutes } = opts; + const entries = Array.isArray(initial) ? initial : [initial]; + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + + + + + + + + {extraRoutes} + + + + + + + + + + + + ); + return render(ui, { wrapper: Wrapper }); +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..f9f28690 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,95 @@ +// Runs before every test file: DOM matchers, the English catalog, and the +// browser APIs jsdom leaves out that the app reaches for at render time. +import "@testing-library/jest-dom/vitest"; +import { cleanup, configure } from "@testing-library/react"; +import { afterEach, beforeEach, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { messages } from "../locales/en.po"; +import { FakeWebSocket } from "./ws.ts"; +import { resetFetch } from "./fetch.ts"; + +// Lazy chunks and the app's own debounces (a link preview waits 600ms) make +// the default 1s too tight once every file runs in parallel. +configure({ asyncUtilTimeout: 4000 }); + +i18n.load("en", messages); +i18n.activate("en"); + +class FakeIntersectionObserver { + static instances: FakeIntersectionObserver[] = []; + elements = new Set(); + callback: IntersectionObserverCallback; + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + FakeIntersectionObserver.instances.push(this); + } + observe(el: Element) { + this.elements.add(el); + } + unobserve(el: Element) { + this.elements.delete(el); + } + disconnect() { + this.elements.clear(); + } + takeRecords() { + return []; + } +} + +class FakeResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.assign(globalThis, { + IntersectionObserver: FakeIntersectionObserver, + ResizeObserver: FakeResizeObserver, +}); + +if (!globalThis.matchMedia) { + globalThis.matchMedia = (query: string) => + ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }) as MediaQueryList; +} + +Element.prototype.scrollIntoView ??= function () {}; +Element.prototype.animate ??= function () { + return { cancel() {}, finished: Promise.resolve() } as unknown as Animation; +}; +Element.prototype.getAnimations ??= () => []; +globalThis.scrollTo = () => {}; +HTMLMediaElement.prototype.play = function () { + return Promise.resolve(); +}; +HTMLMediaElement.prototype.pause = function () {}; +HTMLMediaElement.prototype.load = function () {}; +// The emoji picker probes emoji support on a canvas; jsdom has none. +HTMLCanvasElement.prototype.getContext = (() => null) as never; +// jsdom has no layout; execCommand("insertText") is how the editor types. +document.execCommand ??= () => false; + +beforeEach(() => { + resetFetch(); + FakeWebSocket.reset(); + vi.stubGlobal("WebSocket", FakeWebSocket); + FakeIntersectionObserver.instances = []; +}); + +afterEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); + document.documentElement.removeAttribute("style"); +}); + +export { FakeIntersectionObserver }; diff --git a/src/test/utils/journal.test.ts b/src/test/utils/journal.test.ts new file mode 100644 index 00000000..f40971c9 --- /dev/null +++ b/src/test/utils/journal.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "vitest"; +import { + composeJournal, + hasQuote, + hasThumbnail, + type JournalShape, +} from "../../utils/journalLayout.ts"; +import { hotScore } from "../../utils/hotScore.ts"; +import type { Dump } from "../../model.ts"; +import { makeDump } from "../fixtures.ts"; + +const HOUR = 3_600_000; +const SPAN: Record = { + feature: [2, 2], + wide: [2, 1], + tall: [1, 2], + square: [1, 1], +}; + +/** Replays the layout onto a 4-column grid, failing on overlap. Returns rows. */ +function tile(shapes: JournalShape[]): boolean[][] { + const grid: boolean[][] = []; + const row = (r: number) => (grid[r] ??= [false, false, false, false]); + let cursor = 0; + for (const shape of shapes) { + while (row(Math.floor(cursor / 4))[cursor % 4]) cursor++; + const r = Math.floor(cursor / 4), c = cursor % 4; + const [w, h] = SPAN[shape]; + for (let dr = 0; dr < h; dr++) { + for (let dc = 0; dc < w; dc++) { + if (c + dc >= 4) throw new Error(`${shape} overflows at ${r},${c}`); + if (row(r + dr)[c + dc]) throw new Error(`overlap at ${r + dr},${c + dc}`); + row(r + dr)[c + dc] = true; + } + } + } + return grid; +} + +function feed(n: number, withArt: (i: number) => boolean): Dump[] { + return Array.from({ length: n }, (_, i) => + makeDump({ + id: `d${i}`, + // Strictly decreasing heat, so the ranking is the index order. + createdAt: new Date(Date.now() - (i + 1) * HOUR), + richContent: withArt(i) + ? { type: "link", url: "https://x", thumbnailUrl: "https://x/t.png" } + : undefined, + })); +} + +describe("hotScore", () => { + test("more votes is hotter, older is colder", () => { + const now = makeDump({ createdAt: new Date(), voteCount: 0 }); + const voted = { ...now, voteCount: 5 }; + const old = { ...now, createdAt: new Date(Date.now() - 48 * HOUR) }; + expect(hotScore(voted)).toBeGreaterThan(hotScore(now)); + expect(hotScore(old)).toBeLessThan(hotScore(now)); + }); +}); + +describe("hasThumbnail / hasQuote", () => { + test("image and video files have art; other files don't", () => { + expect(hasThumbnail(makeDump({ kind: "file", fileMime: "image/png" }))) + .toBe(true); + expect(hasThumbnail(makeDump({ kind: "file", fileMime: "video/mp4" }))) + .toBe(true); + expect(hasThumbnail(makeDump({ kind: "file", fileMime: "audio/mpeg" }))) + .toBe(false); + }); + test("an uploaded thumbnail or a rich preview image counts", () => { + expect(hasThumbnail(makeDump({ thumbnailMime: "image/webp" }))).toBe(true); + expect( + hasThumbnail( + makeDump({ richContent: { type: "x", url: "u", thumbnailUrl: "t" } }), + ), + ).toBe(true); + expect(hasThumbnail(makeDump())).toBe(false); + }); + test("a quote needs non-blank text", () => { + expect(hasQuote(makeDump({ comment: " " }))).toBe(false); + expect(hasQuote(makeDump({ comment: "yes" }))).toBe(true); + }); +}); + +describe("composeJournal", () => { + test("nothing in, nothing out", () => { + expect(composeJournal([])).toEqual([]); + }); + + test("sorts by heat and leads with a feature when the top dump has art", () => { + const dumps = feed(5, () => true).reverse(); + const [block] = composeJournal(dumps); + expect(block.entries.map((e) => e.dump.id)).toEqual([ + "d0", + "d1", + "d2", + "d3", + "d4", + ]); + expect(block.entries[0].shape).toBe("feature"); + }); + + test("a bare note on top gives way to art from just behind it", () => { + const dumps = feed(6, (i) => i === 2); + const [block] = composeJournal(dumps); + expect(block.entries[0].dump.id).toBe("d2"); + expect(block.entries[0].shape).toBe("feature"); + }); + + test("art too far down doesn't jump the queue", () => { + const dumps = feed(8, (i) => i === 5); + const [block] = composeJournal(dumps); + expect(block.entries[0].dump.id).toBe("d0"); + expect(block.entries[0].shape).toBe("wide"); + }); + + test("only dumps with art take the big footprints", () => { + const blocks = composeJournal(feed(40, (i) => i % 3 === 0)); + for (const { entries } of blocks) { + for (const { dump, shape } of entries) { + if (shape === "tall" || shape === "feature") { + expect(hasThumbnail(dump)).toBe(true); + } + } + } + }); + + for (const [name, art] of [ + ["all art", () => true], + ["no art", () => false], + ["mixed", (i: number) => i % 2 === 0], + ["sparse", (i: number) => i % 7 === 0], + ] as const) { + test(`tiles without gaps or overlap (${name})`, () => { + for (const n of [1, 2, 3, 7, 13, 25, 60]) { + const blocks = composeJournal(feed(n, art)); + expect(blocks.flatMap((b) => b.entries)).toHaveLength(n); + for (const block of blocks) { + const grid = tile(block.entries.map((e) => e.shape)); + // Every row but the last closes flush. + grid.slice(0, -1).forEach((r) => expect(r).toEqual([true, true, true, true])); + } + } + }); + } + + test("long feeds break into blocks, each opening on a hero", () => { + const blocks = composeJournal(feed(60, () => true)); + expect(blocks.length).toBeGreaterThan(1); + for (const block of blocks) { + expect(["feature", "wide"]).toContain(block.entries[0].shape); + expect(block.key).toBe(block.entries[0].dump.id); + } + }); + + test("no break leaves a fragment at the tail", () => { + const blocks = composeJournal(feed(60, () => true)); + expect(blocks.at(-1)!.entries.length).toBeGreaterThanOrEqual(6); + }); + + test("appending a page doesn't reshuffle what came before", () => { + const first = composeJournal(feed(30, (i) => i % 2 === 0)); + const more = composeJournal(feed(60, (i) => i % 2 === 0)); + const flat = (bs: typeof first) => + bs.flatMap((b) => b.entries.map((e) => `${e.dump.id}:${e.shape}`)); + // Shapes near the old tail may grow once more cards follow, but the order + // and the opening stretch are fixed. + expect(flat(more).slice(0, 20)).toEqual(flat(first).slice(0, 20)); + }); +}); diff --git a/src/test/utils/misc.test.ts b/src/test/utils/misc.test.ts new file mode 100644 index 00000000..2d455a71 --- /dev/null +++ b/src/test/utils/misc.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { can } from "../../utils/permissions.ts"; +import { canProxyImage, proxyImageUrl } from "../../utils/proxyImage.ts"; +import { relativeTime } from "../../utils/relativeTime.ts"; +import { + hueFromString, + initialsFor, + isSafeHex, + tintFor, +} from "../../utils/thumbnailTint.ts"; +import { + dumpFileUrl, + dumpThumbnailUrl, + dumpUrl, + normalizeUrl, + playlistUrl, +} from "../../utils/urls.ts"; +import { API_URL } from "../../config/api.ts"; +import { i18n } from "../../i18n.ts"; + +describe("can", () => { + test("admins can do everything, moderators moderate, users nothing", () => { + expect(can({ role: "admin" }, "category:manage")).toBe(true); + expect(can({ role: "admin" }, "user:manage")).toBe(true); + expect(can({ role: "moderator" }, "dump:moderate")).toBe(true); + expect(can({ role: "moderator" }, "chat:moderate")).toBe(true); + expect(can({ role: "moderator" }, "category:manage")).toBe(false); + expect(can({ role: "user" }, "dump:moderate")).toBe(false); + }); + test("no user, no permission", () => { + expect(can(null, "dump:moderate")).toBe(false); + expect(can(undefined, "dump:moderate")).toBe(false); + }); +}); + +describe("proxyImageUrl", () => { + test("http images go through the proxy; https ones don't", () => { + expect(proxyImageUrl("http://img.test/a.png")).toBe( + `${API_URL}/api/proxy-image?url=${encodeURIComponent("http://img.test/a.png")}`, + ); + expect(proxyImageUrl("https://img.test/a.png")).toBe( + "https://img.test/a.png", + ); + }); + test("force proxies https too", () => { + expect(proxyImageUrl("https://img.test/a.png", { force: true })).toContain( + "/api/proxy-image?url=", + ); + }); + test("relative, local and non-http URLs are left alone", () => { + expect(proxyImageUrl("/api/files/x")).toBe("/api/files/x"); + expect(proxyImageUrl("http://localhost:8000/a.png")).toBe( + "http://localhost:8000/a.png", + ); + expect(proxyImageUrl("http://127.0.0.1/a.png")).toBe("http://127.0.0.1/a.png"); + expect(proxyImageUrl("data:image/png;base64,AA")).toBe( + "data:image/png;base64,AA", + ); + }); +}); + +describe("canProxyImage", () => { + test("third-party http(s) only", () => { + expect(canProxyImage("https://elsewhere.test/a.png")).toBe(true); + expect(canProxyImage(`${API_URL}/api/files/x`)).toBe(false); + expect(canProxyImage("/relative.png")).toBe(false); + expect(canProxyImage("data:image/png;base64,AA")).toBe(false); + expect(canProxyImage("http://[bad")).toBe(false); + }); +}); + +describe("relativeTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-15T12:00:00Z")); + }); + afterEach(() => vi.useRealTimers()); + + const ago = (s: number) => new Date(Date.now() - s * 1000); + + test("picks the largest sensible unit", () => { + expect(relativeTime(ago(0))).toBe("now"); + expect(relativeTime(ago(30))).toBe("30 seconds ago"); + expect(relativeTime(ago(120))).toBe("2 minutes ago"); + expect(relativeTime(ago(3 * 3600))).toBe("3 hours ago"); + expect(relativeTime(ago(86400))).toBe("yesterday"); + expect(relativeTime(ago(14 * 86400))).toBe("2 weeks ago"); + expect(relativeTime(ago(60 * 86400))).toBe("2 months ago"); + expect(relativeTime(ago(800 * 86400))).toBe("2 years ago"); + }); + + test("follows the active locale", () => { + try { + i18n.load("fr", {}); + i18n.activate("fr"); + expect(relativeTime(ago(120))).toBe("il y a 2 minutes"); + } finally { + i18n.activate("en"); + } + }); +}); + +describe("thumbnail tint", () => { + test("isSafeHex accepts only canonical #rrggbb", () => { + expect(isSafeHex("#a1b2c3")).toBe(true); + expect(isSafeHex("#A1B2C3")).toBe(false); + expect(isSafeHex("#abc")).toBe(false); + expect(isSafeHex("red")).toBe(false); + expect(isSafeHex("#000000;background:url(x)")).toBe(false); + expect(isSafeHex(undefined)).toBe(false); + }); + + test("hueFromString is stable and in range", () => { + expect(hueFromString("example.com")).toBe(hueFromString("example.com")); + const hue = Number(/hsl\((\d+)/.exec(hueFromString("x"))![1]); + expect(hue).toBeGreaterThanOrEqual(0); + expect(hue).toBeLessThan(360); + }); + + test("tintFor prefers the page colour, then the hostname sans www", () => { + expect(tintFor({ accentColor: "#112233", url: "https://a.b" })).toBe( + "#112233", + ); + expect(tintFor({ accentColor: "evil", url: "https://www.a.b/x" })).toBe( + hueFromString("a.b"), + ); + expect(tintFor({ seed: "file.mp3" })).toBe(hueFromString("file.mp3")); + expect(tintFor({ url: "not a url" })).toBe(hueFromString("not a url")); + }); + + test("initialsFor takes the first letter or digit", () => { + expect(initialsFor(" bandcamp", undefined)).toBe("B"); + expect(initialsFor(undefined, "https://www.youtube.com/x")).toBe("Y"); + expect(initialsFor("«Élan»", undefined)).toBe("É"); + expect(initialsFor("", "")).toBe("?"); + }); +}); + +describe("urls", () => { + test("dumpFileUrl versions by size and carries the token", () => { + expect(dumpFileUrl({ id: "d1", fileSize: 42 })).toBe( + `${API_URL}/api/files/d1?v=42`, + ); + expect(dumpFileUrl({ id: "d1" }, "t o+k")).toBe( + `${API_URL}/api/files/d1?v=0&token=t%20o%2Bk`, + ); + }); + test("dumpThumbnailUrl", () => { + expect(dumpThumbnailUrl({ id: "d1" })).toBe(`${API_URL}/api/thumbnails/d1`); + expect(dumpThumbnailUrl({ id: "d1" }, "tk")).toBe( + `${API_URL}/api/thumbnails/d1?token=tk`, + ); + }); + test("normalizeUrl adds a scheme when there's none", () => { + expect(normalizeUrl(" example.com/x ")).toBe("https://example.com/x"); + expect(normalizeUrl("//cdn.test/a")).toBe("https://cdn.test/a"); + expect(normalizeUrl("HTTP://A.B")).toBe("HTTP://A.B"); + expect(normalizeUrl(" ")).toBe(""); + }); + test("slug beats id in page URLs", () => { + expect(dumpUrl({ id: "1", slug: "nice-title" })).toBe("/dumps/nice-title"); + expect(dumpUrl({ id: "1" })).toBe("/dumps/1"); + expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix"); + expect(playlistUrl({ id: "2" })).toBe("/playlists/2"); + }); +}); diff --git a/src/test/utils/model.test.ts b/src/test/utils/model.test.ts new file mode 100644 index 00000000..cb7ac776 --- /dev/null +++ b/src/test/utils/model.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { + deserializeAuthResponse, + deserializeChatMessage, + deserializeComment, + deserializeDump, + deserializeNotification, + deserializePlaylistMembership, + deserializePlaylistWithDumps, + parseAPIResponse, +} from "../../model.ts"; + +describe("parseAPIResponse", () => { + test("accepts either envelope", () => { + expect(parseAPIResponse({ success: true, data: 1 })).toEqual({ + success: true, + data: 1, + }); + expect(parseAPIResponse({ success: false, error: { message: "x" } })) + .toMatchObject({ success: false }); + }); + test("rejects anything else", () => { + expect(() => parseAPIResponse(null)).toThrow(/Unexpected/); + expect(() => parseAPIResponse({ data: 1 })).toThrow(/Unexpected/); + expect(() => parseAPIResponse("ok")).toThrow(/Unexpected/); + }); +}); + +describe("deserializers", () => { + const createdAt = "2026-03-01T10:00:00.000Z"; + const updatedAt = "2026-03-02T10:00:00.000Z"; + + test("revive createdAt and an optional updatedAt", () => { + const d = deserializeDump({ + id: "d", + kind: "url", + title: "t", + userId: "u", + createdAt, + voteCount: 0, + commentCount: 0, + isPrivate: false, + }); + expect(d.createdAt).toEqual(new Date(createdAt)); + expect(d.updatedAt).toBeUndefined(); + + const c = deserializeComment({ + id: "c", + dumpId: "d", + userId: "u", + body: "b", + createdAt, + updatedAt, + deleted: false, + likeCount: 0, + authorUsername: "a", + }); + expect(c.updatedAt).toEqual(new Date(updatedAt)); + + const m = deserializeChatMessage({ + id: "m", + userId: "u", + body: "b", + createdAt, + authorUsername: "a", + }); + expect(m.createdAt.getTime()).toBe(Date.parse(createdAt)); + + const n = deserializeNotification({ + id: "n", + userId: "u", + type: "user_followed", + data: { followerId: "f", followerUsername: "fan" }, + read: false, + createdAt, + }); + expect(n.createdAt).toBeInstanceOf(Date); + }); + + test("nested values are revived too", () => { + const auth = deserializeAuthResponse({ + token: "t", + user: { id: "u", username: "u", role: "user", createdAt }, + }); + expect(auth.user.createdAt).toBeInstanceOf(Date); + + const p = deserializePlaylistWithDumps({ + id: "p", + userId: "u", + title: "t", + isPublic: true, + createdAt, + dumps: [{ + id: "d", + kind: "url", + title: "t", + userId: "u", + createdAt, + voteCount: 0, + commentCount: 0, + isPrivate: false, + }], + }); + expect(p.dumps[0].createdAt).toBeInstanceOf(Date); + + const mem = deserializePlaylistMembership({ + playlist: { id: "p", userId: "u", title: "t", isPublic: true, createdAt }, + hasDump: true, + }); + expect(mem.playlist.createdAt).toBeInstanceOf(Date); + expect(mem.hasDump).toBe(true); + }); +}); diff --git a/src/test/utils/remote.test.ts b/src/test/utils/remote.test.ts new file mode 100644 index 00000000..0719db99 --- /dev/null +++ b/src/test/utils/remote.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { resolveBandcampQueue } from "../../utils/bandcamp.ts"; +import { + isStreamStale, + resolveStreamQueue, + STREAM_STALE_MS, +} from "../../utils/streamSources.ts"; +import { + embedListId, + fetchPlaylistTracks, + withJsApi, +} from "../../utils/youtube.ts"; +import { extractPeaks } from "../../utils/waveform.ts"; +import { calls, fail, route } from "../fetch.ts"; +import type { PlayerItem } from "../../contexts/PlayerContext.ts"; +import { playerItemKey } from "../../contexts/PlayerContext.ts"; + +afterEach(() => vi.useRealTimers()); + +const album = { + sourceUrl: "https://artist.bandcamp.com/album/a", + itemType: "album", + title: "A", + artist: "Artist", + artworkUrl: "https://f4.bcbits.com/a.jpg", + resolvedAt: 1000, + tracks: [ + { index: 0, trackNum: 1, title: "One", duration: 100, streamUrl: "https://s/1", streamable: true, capped: false }, + { index: 1, trackNum: 2, title: "Two", streamUrl: null, streamable: false, capped: true }, + { index: 2, trackNum: 3, title: "Three", duration: 50, streamUrl: "https://s/3", streamable: true, capped: false }, + ], +}; + +describe("resolveBandcampQueue", () => { + test("turns the streamable tracks into a queue", async () => { + route("GET /api/bandcamp/tracks", () => album); + const items = await resolveBandcampQueue(album.sourceUrl, { + dumpHref: "/dumps/x", + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + }); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + kind: "stream", + type: "bandcamp", + streamUrl: "https://s/1", + title: "One", + subtitle: "Artist", + trackNum: 1, + duration: 100, + artworkUrl: album.artworkUrl, + dumpHref: "/dumps/x", + resolveUrl: album.sourceUrl, + resolveIndex: 0, + resolvedAt: 1000, + embedUrl: "https://bandcamp.com/EmbeddedPlayer/album=1", + }); + expect(items[1]).toMatchObject({ resolveIndex: 2 }); + const [req] = calls("GET /api/bandcamp/tracks"); + expect(req.url.searchParams.get("url")).toBe(album.sourceUrl); + expect(req.url.searchParams.has("force")).toBe(false); + }); + + test("force asks the server to skip its cache", async () => { + route("GET /api/bandcamp/tracks", () => album); + await resolveBandcampQueue(album.sourceUrl, {}, { force: true }); + expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force")) + .toBe("1"); + }); + + test("falls back to the given artwork", async () => { + route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined })); + const [item] = await resolveBandcampQueue(album.sourceUrl, { + fallbackArtworkUrl: "https://mine/art.png", + }); + expect(item.artworkUrl).toBe("https://mine/art.png"); + }); + + test("throws on an HTTP error, an error envelope, or nothing to play", async () => { + route("GET /api/bandcamp/tracks", () => fail("down", 502), { once: true }); + await expect(resolveBandcampQueue("u")).rejects.toThrow(/502/); + + route("GET /api/bandcamp/tracks", () => fail("nope", 200), { once: true }); + await expect(resolveBandcampQueue("u")).rejects.toThrow(/no data/); + + route("GET /api/bandcamp/tracks", () => ({ + ...album, + tracks: [album.tracks[1]], + })); + await expect(resolveBandcampQueue("u")).rejects.toThrow(/no streamable/); + }); +}); + +describe("stream sources", () => { + const stream: Extract = { + kind: "stream", + type: "bandcamp", + streamUrl: "https://s/1", + resolveUrl: album.sourceUrl, + resolveIndex: 0, + resolvedAt: Date.now(), + artworkUrl: "https://old/art.png", + dumpHref: "/dumps/x", + }; + + test("re-resolving a bandcamp stream forces a fresh lookup and keeps the art", async () => { + route("GET /api/bandcamp/tracks", () => ({ ...album, artworkUrl: undefined })); + const items = await resolveStreamQueue(stream); + expect(items[0].artworkUrl).toBe("https://old/art.png"); + expect(calls("GET /api/bandcamp/tracks")[0].url.searchParams.get("force")) + .toBe("1"); + }); + + test("an unknown stream type has no resolver", async () => { + await expect(resolveStreamQueue({ ...stream, type: "other" })).rejects + .toThrow(/no resolver/); + }); + + test("isStreamStale after the window", () => { + vi.useFakeTimers(); + vi.setSystemTime(10 * STREAM_STALE_MS); + expect(isStreamStale({ ...stream, resolvedAt: Date.now() - 1000 })).toBe(false); + expect( + isStreamStale({ ...stream, resolvedAt: Date.now() - STREAM_STALE_MS - 1 }), + ).toBe(true); + }); + + test("playerItemKey ignores the signed URL", () => { + expect(playerItemKey(stream)).toBe( + playerItemKey({ ...stream, streamUrl: "https://s/refreshed" }), + ); + expect(playerItemKey({ kind: "file", fileUrl: "f", mimeType: "audio/mpeg" })) + .toBe("file:f"); + expect(playerItemKey({ kind: "embed", embedUrl: "e", type: "youtube" })) + .toBe("embed:e"); + }); +}); + +describe("youtube", () => { + test("embedListId reads ?list= from either embed shape", () => { + expect(embedListId("https://www.youtube.com/embed/videoseries?list=PL1")) + .toBe("PL1"); + expect(embedListId("https://www.youtube.com/embed/abc?list=PL2")).toBe("PL2"); + expect(embedListId("https://www.youtube.com/embed/abc")).toBeNull(); + expect(embedListId("nonsense")).toBeNull(); + }); + + test("withJsApi adds the API parameters and our origin", () => { + const url = new URL(withJsApi("https://www.youtube.com/embed/abc?list=PL")); + expect(url.searchParams.get("enablejsapi")).toBe("1"); + expect(url.searchParams.get("origin")).toBe(location.origin); + expect(url.searchParams.get("list")).toBe("PL"); + expect(withJsApi("bad url")).toBe("bad url"); + }); + + test("fetchPlaylistTracks", async () => { + route("GET /api/youtube/playlist", (req) => + req.url.searchParams.get("list") === "PL" + ? { tracks: [{ videoId: "a", title: "A", duration: 3 }] } + : { tracks: [] }); + await expect(fetchPlaylistTracks("PL")).resolves.toEqual([ + { videoId: "a", title: "A", duration: 3 }, + ]); + await expect(fetchPlaylistTracks("EMPTY")).rejects.toThrow(/no tracks/); + route("GET /api/youtube/playlist", () => fail("x", 500)); + await expect(fetchPlaylistTracks("PL")).rejects.toThrow(/500/); + }); +}); + +describe("extractPeaks", () => { + test("decodes once per URL and takes each block's peak", async () => { + const data = new Float32Array([0.1, -0.5, 0.2, 0.9, -0.3, 0.4]); + const decode = vi.fn(async () => ({ getChannelData: () => data })); + const close = vi.fn(); + vi.stubGlobal( + "AudioContext", + class { + decodeAudioData = decode; + close = close; + }, + ); + route("GET /api/files/:id", () => new Response(new Uint8Array([1, 2]))); + + const peaks = await extractPeaks("http://localhost:8000/api/files/w1", 3); + expect([...peaks].map((p) => +p.toFixed(2))).toEqual([0.5, 0.9, 0.4]); + expect(close).toHaveBeenCalled(); + + await extractPeaks("http://localhost:8000/api/files/w1", 3); + expect(decode).toHaveBeenCalledTimes(1); + expect(calls("GET /api/files/:id")).toHaveLength(1); + }); + + test("a failed fetch throws", async () => { + route("GET /api/files/:id", () => fail("gone", 404)); + await expect(extractPeaks("http://localhost:8000/api/files/w2", 3)).rejects + .toThrow(/404/); + }); +}); diff --git a/src/test/utils/sound.test.ts b/src/test/utils/sound.test.ts new file mode 100644 index 00000000..73f024cd --- /dev/null +++ b/src/test/utils/sound.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// sound.ts keeps its AudioContext and chat throttle at module level, so each +// test loads a fresh copy against a fake Web Audio. +class FakeAudioContext { + static instances: FakeAudioContext[] = []; + state: "suspended" | "running" = "running"; + currentTime = 0; + destination = {}; + oscillators = 0; + constructor() { + FakeAudioContext.instances.push(this); + } + resume = vi.fn(async () => { + this.state = "running"; + }); + createOscillator() { + this.oscillators++; + const node = { + type: "", + frequency: { setValueAtTime() {} }, + connect: () => ({ connect() {} }), + start() {}, + stop() {}, + }; + return node; + } + createGain() { + return { + gain: { setValueAtTime() {}, exponentialRampToValueAtTime() {} }, + }; + } +} + +async function load() { + vi.resetModules(); + return await import("../../utils/sound.ts"); +} + +const notes = () => + FakeAudioContext.instances.reduce((n, c) => n + c.oscillators, 0); + +beforeEach(() => { + FakeAudioContext.instances = []; + vi.stubGlobal("AudioContext", FakeAudioContext); + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); +}); +afterEach(() => vi.useRealTimers()); + +describe("sounds preference", () => { + test("on by default, stored as on/off", async () => { + const s = await load(); + expect(s.readSoundsEnabled()).toBe(true); + s.writeSoundsEnabled(false); + expect(localStorage.getItem("sounds")).toBe("off"); + expect(s.readSoundsEnabled()).toBe(false); + }); + + test("unavailable storage leaves sounds on", async () => { + const s = await load(); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(s.readSoundsEnabled()).toBe(true); + }); +}); + +describe("playChime", () => { + test("a notification plays two notes, a chat message one", async () => { + const s = await load(); + s.playChime("notify", "n1"); + expect(notes()).toBe(2); + s.playChime("chat", "c1"); + expect(notes()).toBe(3); + }); + + test("silent when sounds are off", async () => { + const s = await load(); + s.writeSoundsEnabled(false); + s.playChime("notify", "n1"); + expect(notes()).toBe(0); + }); + + test("one chime per event across tabs", async () => { + const s = await load(); + s.playChime("notify", "same"); + s.playChime("notify", "same"); + expect(notes()).toBe(2); + expect(localStorage.getItem("sounds-last-claim")).toBe("notify:same"); + }); + + test("chat blips are throttled", async () => { + const s = await load(); + s.playChime("chat", "a"); + s.playChime("chat", "b"); + expect(notes()).toBe(1); + vi.advanceTimersByTime(4_001); + s.playChime("chat", "c"); + expect(notes()).toBe(2); + }); + + test("a suspended context stays quiet until a gesture unlocks it", async () => { + const s = await load(); + s.playChime("notify", "warmup"); // creates the context + const ctx = FakeAudioContext.instances[0]; + ctx.state = "suspended"; + s.playChime("notify", "n2"); + expect(ctx.oscillators).toBe(2); + globalThis.dispatchEvent(new Event("keydown")); + expect(ctx.resume).toHaveBeenCalled(); + }); + + test("previewChime resumes and plays regardless of the preference", async () => { + const s = await load(); + s.writeSoundsEnabled(false); + s.previewChime("chat"); + expect(notes()).toBe(1); + FakeAudioContext.instances[0].state = "suspended"; + s.previewChime("notify"); + await vi.runAllTimersAsync(); + expect(notes()).toBe(3); + }); + + test("no Web Audio, no crash", async () => { + vi.stubGlobal("AudioContext", class { + constructor() { + throw new Error("unsupported"); + } + }); + const s = await load(); + expect(() => s.playChime("notify", "x")).not.toThrow(); + expect(() => s.previewChime("chat")).not.toThrow(); + }); +}); diff --git a/src/test/utils/storage.test.ts b/src/test/utils/storage.test.ts new file mode 100644 index 00000000..eec29b76 --- /dev/null +++ b/src/test/utils/storage.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + AUTH_STORAGE_KEY, + forgetStoredSession, + isTokenExpired, + readStoredSession, + storeSession, +} from "../../utils/authStorage.ts"; +import { + clearDumpDraft, + EMPTY_DUMP_DRAFT, + isDumpDraftEmpty, + loadDumpDraft, + saveDumpDraft, +} from "../../utils/dumpDraft.ts"; +import { + isDumpVisited, + isPlaylistVisited, + isRecent, + markDumpVisited, + markPlaylistVisited, +} from "../../utils/visited.ts"; +import { makeSession, makeToken, wire } from "../fixtures.ts"; + +afterEach(() => vi.useRealTimers()); + +describe("isTokenExpired", () => { + test("reads exp from the payload", () => { + expect(isTokenExpired(makeToken({ expiresIn: 60 }))).toBe(false); + expect(isTokenExpired(makeToken({ expiresIn: -60 }))).toBe(true); + }); + test("a token it can't read is treated as expired", () => { + expect(isTokenExpired("garbage")).toBe(true); + expect(isTokenExpired("a.b.c")).toBe(true); + }); + test("no exp means it doesn't expire", () => { + const token = `x.${btoa(JSON.stringify({ sub: "u" }))}.y`; + expect(isTokenExpired(token)).toBe(false); + }); +}); + +describe("stored session", () => { + test("round-trips, with dates revived", () => { + const session = makeSession({ username: "ann" }); + storeSession(session); + const read = readStoredSession(); + expect(read?.user.username).toBe("ann"); + expect(read?.user.createdAt).toBeInstanceOf(Date); + }); + + test("nothing stored", () => { + expect(readStoredSession()).toBeNull(); + }); + + test("unparseable, expired and roleless sessions are discarded", () => { + localStorage.setItem(AUTH_STORAGE_KEY, "{not json"); + expect(readStoredSession()).toBeNull(); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + + const expired = makeSession(); + expired.token = makeToken({ expiresIn: -1 }); + storeSession(expired); + expect(readStoredSession()).toBeNull(); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + + const legacy = wire(makeSession()) as unknown as { + user: { role?: string }; + }; + delete legacy.user.role; + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(legacy)); + expect(readStoredSession()).toBeNull(); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + }); + + test("forgetStoredSession only forgets the session holding that token", () => { + const session = makeSession(); + storeSession(session); + forgetStoredSession("some-older-token"); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).not.toBeNull(); + forgetStoredSession(session.token); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + }); + + test("forgetStoredSession clears an unreadable entry", () => { + localStorage.setItem(AUTH_STORAGE_KEY, "{bad"); + forgetStoredSession("anything"); + expect(localStorage.getItem(AUTH_STORAGE_KEY)).toBeNull(); + }); +}); + +describe("dump draft", () => { + const draft = { + ...EMPTY_DUMP_DRAFT, + url: "https://a.b", + comment: "why", + }; + + test("an empty draft is empty; any content isn't", () => { + expect(isDumpDraftEmpty(EMPTY_DUMP_DRAFT)).toBe(true); + expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, url: " " })).toBe(true); + expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, isPublic: false })).toBe( + false, + ); + expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, categoryIds: ["c"] })).toBe( + false, + ); + }); + + test("a title from the preview isn't worth keeping; a typed one is", () => { + expect(isDumpDraftEmpty({ ...EMPTY_DUMP_DRAFT, title: "Scraped" })).toBe( + true, + ); + expect( + isDumpDraftEmpty({ + ...EMPTY_DUMP_DRAFT, + title: "Mine", + titleFromUser: true, + }), + ).toBe(false); + }); + + test("save, load and clear", () => { + saveDumpDraft(draft); + expect(loadDumpDraft()).toEqual(draft); + clearDumpDraft(); + expect(loadDumpDraft()).toBeNull(); + }); + + test("saving an empty draft removes the stored one", () => { + saveDumpDraft(draft); + saveDumpDraft(EMPTY_DUMP_DRAFT); + expect(localStorage.getItem("gerbeur.dumpDraft")).toBeNull(); + }); + + test("a malformed stored draft is sanitised or ignored", () => { + localStorage.setItem( + "gerbeur.dumpDraft", + JSON.stringify({ url: 5, comment: "c", categoryIds: ["a", 3, null] }), + ); + expect(loadDumpDraft()).toEqual({ + ...EMPTY_DUMP_DRAFT, + comment: "c", + categoryIds: ["a"], + }); + localStorage.setItem("gerbeur.dumpDraft", "nope"); + expect(loadDumpDraft()).toBeNull(); + }); + + test("storage that throws never breaks posting", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceeded"); + }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + expect(() => saveDumpDraft(draft)).not.toThrow(); + expect(loadDumpDraft()).toBeNull(); + }); +}); + +describe("visited", () => { + test("dumps and playlists are tracked apart", () => { + markDumpVisited("a"); + markPlaylistVisited("b"); + expect(isDumpVisited("a")).toBe(true); + expect(isDumpVisited("b")).toBe(false); + expect(isPlaylistVisited("b")).toBe(true); + expect(isPlaylistVisited("a")).toBe(false); + }); + + test("a corrupt entry reads as nothing visited", () => { + localStorage.setItem("visited_dumps", "{"); + expect(isDumpVisited("a")).toBe(false); + markDumpVisited("a"); + expect(isDumpVisited("a")).toBe(true); + }); + + test("isRecent is a seven-day window", () => { + const day = 24 * 3600 * 1000; + expect(isRecent(new Date(Date.now() - 6 * day))).toBe(true); + expect(isRecent(new Date(Date.now() - 8 * day))).toBe(false); + }); +}); diff --git a/src/test/utils/text.test.ts b/src/test/utils/text.test.ts new file mode 100644 index 00000000..c7a743de --- /dev/null +++ b/src/test/utils/text.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "vitest"; +import { insideCode, isEmojiOnly } from "../../utils/editorText.ts"; +import { markdownToPlainText, mentionsUser } from "../../utils/chatText.ts"; +import { charCountClass, overLimit } from "../../utils/charCount.ts"; +import { formatBytes, titleFromFileName } from "../../utils/format.ts"; +import { fmt } from "../../utils/duration.ts"; +import { friendlyFetchError } from "../../utils/apiError.ts"; + +describe("insideCode", () => { + test("plain text is not code", () => { + expect(insideCode("hello @bob")).toBe(false); + }); + test("an unclosed inline span is code", () => { + expect(insideCode("try `@bob")).toBe(true); + expect(insideCode("try `a` then `@bob")).toBe(true); + }); + test("a closed inline span is not", () => { + expect(insideCode("try `a` @bob")).toBe(false); + }); + test("an open fence is code, a closed one is not", () => { + expect(insideCode("```\n@bob")).toBe(true); + expect(insideCode("```js\nx\n```\n@bob")).toBe(false); + }); + test("the fence line itself is not an inline span", () => { + expect(insideCode("text\n```\ncode\n```")).toBe(false); + }); + test("backticks on an earlier line don't carry over", () => { + expect(insideCode("a ` b\n@bob")).toBe(false); + }); +}); + +describe("isEmojiOnly", () => { + test("one to three emoji", () => { + expect(isEmojiOnly("🎉")).toBe(true); + expect(isEmojiOnly("👍👍")).toBe(true); + expect(isEmojiOnly(" 🎉 🎉 🎉 ")).toBe(true); + }); + test("more than the max", () => { + expect(isEmojiOnly("🎉🎉🎉🎉")).toBe(false); + expect(isEmojiOnly("🎉🎉🎉🎉", 4)).toBe(true); + }); + test("a ZWJ sequence, a skin tone and a flag count as one each", () => { + expect(isEmojiOnly("👨‍👩‍👧‍👦")).toBe(true); + expect(isEmojiOnly("👍🏽")).toBe(true); + expect(isEmojiOnly("🇫🇷🇫🇷🇫🇷")).toBe(true); + }); + test("text, digits and symbols are not emoji", () => { + expect(isEmojiOnly("")).toBe(false); + expect(isEmojiOnly("hi 🎉")).toBe(false); + expect(isEmojiOnly("123")).toBe(false); + expect(isEmojiOnly("#")).toBe(false); + expect(isEmojiOnly("1🎉")).toBe(false); + }); +}); + +describe("mentionsUser", () => { + test("case-insensitive whole-word match", () => { + expect(mentionsUser("hey @Alice!", "alice")).toBe(true); + expect(mentionsUser("hey @alicex", "alice")).toBe(false); + }); + test("follows the server's rule: not in an email, a link or brackets", () => { + expect(mentionsUser("mail bob@alice", "alice")).toBe(false); + expect(mentionsUser("[@alice](x)", "alice")).toBe(false); + expect(mentionsUser("(@alice)", "alice")).toBe(false); + }); + test("any of several mentions", () => { + expect(mentionsUser("@bob @carol @alice", "alice")).toBe(true); + }); +}); + +describe("markdownToPlainText", () => { + test("links become their text, images their alt or a label", () => { + expect(markdownToPlainText("see [this](https://x.y) now", "img")).toBe( + "see this now", + ); + expect(markdownToPlainText("![cat](a.png)", "Image")).toBe("cat"); + expect(markdownToPlainText("![](a.png)", "Image")).toBe("Image"); + }); + test("markup characters and block markers go", () => { + expect(markdownToPlainText("# Title\n> quote\n- **bold** `code`", "")).toBe( + "Title quote bold code", + ); + }); + test("a body cut off mid-link doesn't show a half URL", () => { + expect(markdownToPlainText("look [here](https://exa", "img")).toBe( + "look here", + ); + expect(markdownToPlainText("pic ![alt](https://exa", "Image")).toBe( + "pic alt", + ); + expect(markdownToPlainText("pic ![](https://exa", "Image")).toBe( + "pic Image", + ); + expect(markdownToPlainText("pic ![unfinished", "Image")).toBe("pic"); + }); + test("whitespace collapses to one line", () => { + expect(markdownToPlainText("a\n\n b\tc", "")).toBe("a b c"); + }); +}); + +describe("character limits", () => { + test("charCountClass warns from 85% and flags the limit", () => { + expect(charCountClass(10, 100)).toBe(""); + expect(charCountClass(85, 100)).toBe(" text-editor-count--warn"); + expect(charCountClass(100, 100)).toBe(" text-editor-count--danger"); + }); + test("overLimit is strictly past it", () => { + expect(overLimit("abc", 3)).toBe(false); + expect(overLimit("abcd", 3)).toBe(true); + }); +}); + +describe("formatBytes", () => { + test("B, KB and MB", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB"); + }); +}); + +describe("titleFromFileName", () => { + test("drops the extension and turns underscores into spaces", () => { + expect(titleFromFileName("holiday_photo-01.final.jpg")).toBe( + "holiday photo-01.final", + ); + }); + test("a dotfile keeps its name", () => { + expect(titleFromFileName(".gitignore")).toBe(".gitignore"); + }); + test("a name that is only separators falls back to itself", () => { + expect(titleFromFileName("___.txt")).toBe("___.txt"); + }); +}); + +describe("fmt", () => { + test("m:ss", () => { + expect(fmt(0)).toBe("0:00"); + expect(fmt(65.9)).toBe("1:05"); + expect(fmt(3600)).toBe("60:00"); + }); + test("non-finite reads as zero", () => { + expect(fmt(NaN)).toBe("0:00"); + expect(fmt(Infinity)).toBe("0:00"); + }); +}); + +describe("friendlyFetchError", () => { + test("a TypeError is a connection problem", () => { + expect(friendlyFetchError(new TypeError("Failed to fetch"))).toMatch( + /Could not connect/, + ); + }); + test("other errors keep their message", () => { + expect(friendlyFetchError(new Error("Nope"))).toBe("Nope"); + expect(friendlyFetchError("weird")).toBe("An unexpected error occurred."); + }); +}); diff --git a/src/test/ws.ts b/src/test/ws.ts new file mode 100644 index 00000000..a1eea7f5 --- /dev/null +++ b/src/test/ws.ts @@ -0,0 +1,101 @@ +import type { IncomingWSMessage } from "../model.ts"; +import { act } from "@testing-library/react"; + +/** + * Stands in for the browser WebSocket. Every socket the app opens is kept in + * `FakeWebSocket.instances`; a test opens it, feeds it server messages and + * reads back what the client sent. + */ +export class FakeWebSocket extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + static instances: FakeWebSocket[] = []; + + static reset() { + FakeWebSocket.instances = []; + } + + /** The newest socket the app opened. */ + static get last(): FakeWebSocket { + const ws = FakeWebSocket.instances.at(-1); + if (!ws) throw new Error("no WebSocket was opened"); + return ws; + } + + readyState = FakeWebSocket.CONNECTING; + sent: string[] = []; + onopen: ((e: Event) => void) | null = null; + onmessage: ((e: MessageEvent) => void) | null = null; + onclose: ((e: CloseEvent) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + + url: string; + + constructor(url: string) { + super(); + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string) { + if (this.readyState !== FakeWebSocket.OPEN) { + throw new Error("send on a socket that isn't open"); + } + this.sent.push(data); + } + + close() { + if (this.readyState === FakeWebSocket.CLOSED) return; + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(new Event("close") as CloseEvent); + } + + /** What the client sent, parsed. */ + get messages(): Array> { + return this.sent.map((s) => JSON.parse(s)); + } + + // ── Server side ─────────────────────────────────────────────────────────── + + open() { + act(() => { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(new Event("open")); + }); + } + + receive(msg: IncomingWSMessage | Record) { + act(() => { + this.onmessage?.( + new MessageEvent("message", { data: JSON.stringify(msg) }), + ); + }); + } + + receiveRaw(data: string) { + act(() => { + this.onmessage?.(new MessageEvent("message", { data })); + }); + } + + /** The server drops the connection. */ + drop() { + act(() => this.close()); + } + + /** Opens the socket and sends a welcome, as the server does on connect. */ + welcome(overrides: Partial> = {}) { + this.open(); + this.receive({ + type: "welcome", + users: [], + myVotes: [], + myCommentLikes: [], + unreadNotificationCount: 0, + unreadChatCount: 0, + ...overrides, + }); + } +} diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 7bd06c78..7709cc57 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -37,3 +37,12 @@ export function dumpUrl(dump: { id: string; slug?: string }): string { export function playlistUrl(playlist: { id: string; slug?: string }): string { return `/playlists/${playlist.slug ?? playlist.id}`; } + +/** A playlist's cover. Versioned on `updatedAt`, which a new cover bumps, so a + * replaced image isn't served from the browser's cache. */ +export function playlistImageUrl( + playlist: { id: string; updatedAt?: Date }, +): string { + const v = playlist.updatedAt ? `?v=${playlist.updatedAt.getTime()}` : ""; + return `${API_URL}/api/playlists/${playlist.id}/image${v}`; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..af129dc0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config.ts"; + +// The app's own Vite config, so the tests compile exactly what ships: the SWC +// React transform with the Lingui macro plugin, and .po catalogs through the +// Lingui loader. +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["src/test/setup.ts"], + restoreMocks: true, + unstubGlobals: true, + css: false, + }, + }), +);