Commit Graph

8 Commits

Author SHA1 Message Date
khannurien
308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00
khannurien
6dfba07341 v4: the followed feed's two views are pages — "From people" and "From playlists" on a feed's "follow" tab were a state of the page, which a reload, Back or a shared link lost, while a profile's same split is /@name/follow/playlists. They are now /follow and /follow/playlists, and /music/follow/playlists in a category, feedPath taking the view as userPath does (FOLLOW_VIEWS); picking a category keeps it. Their dumps come from GET /api/dumps?follow=users|playlists, signed in, which the feed's word for it names, instead of GET /api/me/feed?from=; an unknown category is a 404 there, as on the rest of the feed, rather than an empty list.
A feed's tab, as a profile's, a search's or the community page's, now takes the place of the current history entry, since it is a view of the same page; picking a category, another page, still adds one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:09:12 +00:00
khannurien
53ceb6bcbc v4: signing in comes back to the page that asked for it — a guest on a signed-in page, the dump editor, /notifications, /community, /settings or /manage, went to /login and, once signed in, to /, so a digest's link opened signed out lost its way; and since the redirect pushed, Back led to it again. RestrictedLoggedIn now replaces the page with /login?next=<the page>, as /authorize already did. A guest on a feed's "follow" tab, which only a link leads to, is sent there the same way, rather than shown the default tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:05:39 +00:00
khannurien
4468438ba7 v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:08:59 +00:00
khannurien
80f3292c8f v3: fix the frontend's findings, and the backend pieces they needed — each by a rule the code now keeps everywhere. Votes and likes go through one useOptimisticToggles in WSProvider: clicks on one item before any ack share one baseline, which a failure or the timeout restores (a second click used to cancel the first one's rollback), only the last ack's count is applied, a votes_update or comment_likes_update waits while this tab has toggles in flight, and a welcome reconciles them against the server's sets (a vote cast offline left its count one too high). lastLikeEvent, which nothing read, is gone. Follows are refetched when the new follows_changed message, sent to the follower's sockets on every follow write, says another tab changed them; a failed load keeps the Follow buttons disabled and is retried 10 s later instead of leaving them disabled for good (success: false) or enabled over empty sets (network error); follow and unfollow resolve to whether they worked, and the button says when they didn't. CategoriesProvider follows the same rule, so a network error no longer turns every category URL into a 404. Every list is kept by one rule: useDumpListSync and usePlaylistListSync never add an item, and usePositionAwareSync remembers the place of any item that leaves a list, whatever took it out, and puts it back when its next event shows it again; with dump_created now setting lastDumpEvent, a dump going private and back returns to its place on another user's profile, which never happened before, and an updated playlist missing from a list is no longer prepended. useFeedCache keys every cache by viewer (the profile pages restored the owner's private dumps to whoever went back) and reads it again when its key changes (another profile in the same page showed the previous one's list). Every response of a list's first page or next pages goes through expectOk (Index, UserPlaylists, useUserDumpFeed and Notifications read body.data unchecked).
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 55s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Smaller fixes: the redirects from / and /<slug> keep the navigation state, so the index hides the dump DumpEdit just deleted; Index's mainFetchDone ref, which blocked nothing, is gone; DumpEdit refuses someone else's dump up front, as the API does; the Dump page takes a handed-over dump once and clears it from the history entry, so a reload fetches; a newly upvoted dump is fetched with the viewer's token; the notifications page sends read-all once per notification; the profile's followed and invitees tabs finish loading when left mid-fetch; UserListPopover no longer closes when its own list scrolls; the vote and like counts open their lists for visitors too (a disabled button swallowed the click; they are aria-disabled now); the removal of a playlist's dump forgets it (an edit brought it back), a failed removal puts it back where it was, an undo waits for the removal in flight, and a refused undo says so; PlaylistCreateForm tells whether the dump made it into the new playlist; the cover preview's object URL is revoked; the muted icon has its cross; the bell no longer stays rung after two notifications within 700 ms; a stale invite check can't overwrite a newer one; a stored theme or color scheme is checked; the default feed tab is one store every reader follows, across tabs; the file fields enforce the size they state, derived from DUMP_MAX_FILE_SIZE_BYTES; formatBytes, friendlyFetchError and ImagePicker are translated (Intl names the units), and ImagePicker ignores keys while uploading; visited dumps and playlists keep a week of visits and are parsed once; a chime is claimed under a Web Lock, so two tabs can't both play it; a date in the future reads as "now". Dead code goes: PageShell's centerSlot, overLimit, the exports of isSafeHex and hueFromString, the comment tree's token prop and ChatContext's isChatOpen and closeChat. The French catalogue has the 11 new messages. 214 backend and 632 frontend tests pass; tsc, deno lint, ESLint and doc:lint are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:08:00 +00:00
khannurien
e9f6a1f23e v3: deno check src/ passes again — the tests import userEvent by name — 71 errors, all the same one: @testing-library/user-event 14.6.7 ships ESM-syntax declarations without "type": "module", so Deno reads them as CommonJS and the default import is the module namespace, on which setup() and click() don't exist. Vite resolves it the other way, which is why the suite still ran. The package exports userEvent by name as well; the 40 test files that imported it use that now. No behaviour changes; the 627 tests pass and the lint is clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:49:49 +00:00
khannurien
364c498a24 v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:05:29 +00:00
khannurien
54e86c4366 v3: the frontend gets a test suite, and the bugs it turned up are fixed — deno task test:web runs 612 tests over src/ in about 11s: Vitest on jsdom with Testing Library, built on the app's own Vite config so the SWC/Lingui transform and the .po loader are what ship. src/test/ holds the harness: a routing fetch stub (queued once-responses, fallback defaults), a FakeWebSocket the tests open, feed and drop, jsdom polyfills, fixtures, and two render helpers — one over hand-set context values, one over the real provider tree (auth, socket, player, follows, categories, chat). It covers every util and hook, every provider (the socket protocol end to end: votes and likes with ack/timeout/error rollback, chat sends/echoes/retries/read markers/reconcile, reconnect backfill), the components (header and search history, cards, Markdown, thumbnails, media and global player, the text editor's mentions/emoji/uploads, comments, the new-dump modal, the chat), and every page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
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 <noreply@anthropic.com>
2026-09-27 14:35:41 +00:00