Commit Graph

83 Commits

Author SHA1 Message Date
khannurien
7960d74c55 v4: weekly digests and monthly recaps go out by email, every email is written once as Markdown in the app's one catalog, and a review of the last two commits removes what they stated twice — users get a weekly digest (what happened to them, what the people and playlists they follow posted, the week's top dumps, the community) and a monthly recap (the month's top dumps and the community), both on by default, in their own style and scheme. digest-service sends what is due every quarter of an hour from digest_schedule (migration 0020), which holds each user's rolling 7- or 30-day period; existing users' first periods are spread over the coming week or month, a digest more than a period late covers the last full one, an empty one isn't sent, and an address like admin@localhost is skipped. Each digest carries a one-click unsubscribe (List-Unsubscribe and List-Unsubscribe-Post, POST /api/users/unsubscribe) and a link to the new /unsubscribe page, which asks before turning that cadence off, since mail scanners open every link; the token (lib/jwt) names the user and the cadence and never expires. The welcome email now says the digests are on. The emails' words moved out of a hand-written api/mail/strings.ts into the Lingui catalogs the interface uses: lingui extracts api/mail/ too, the catalogs moved to a top-level locales/ that both halves read, compile emits ES modules the server imports (api/mail/i18n.ts), messages take ICU plurals, and the labels the settings tab, the unsubscribe page and the emails share are one message each (their French had already drifted apart). Each template writes its email once as Markdown, which is the plain-text part, and EmailKit.render draws its blocks in the style (a lone link is a button, a list item starting with an image a thumbnail row, a numbered list a ranking, --- the footer), so no string is declared for an HTML and a text version; user text is escaped for Markdown, which the welcome body didn't do for usernames, whose _ read as emphasis. paint() set colors before a border-top shorthand that reset them, so every divider was black. The review also gave one home to what was written twice: the locale list (shared/locale.ts, read by lingui.config, the client, the server and its catalog loader), the base style and default scheme (shared/theme.ts), an email's look (lookOf, which digest-service, welcome and reset share), whether email is on (EMAIL_ENABLED, read by email-service and index.html), a setting's column and its default in the digests' SQL (SETTINGS_COLUMNS, DEFAULT_SETTINGS), the unsubscribe token (made once, not parsed back from a URL), AuthProvider's compare-and-store, ThemeProvider's twin style and scheme code (useThemeChoice), the test JWT encoder, and duplicate digest messages. The email palettes stay a flattened copy of the themes' tokens, now under a test that caught Times' secondary and link colors drifting. The Dockerfile copies api/mail and locales so the build extracts the emails' messages and the server finds its catalogs, and deno.json maps @lingui/core for the runtime image. 236 backend tests (new: digest building, sending, catch-up, scheduling and unsubscribing; the Markdown renderer; French from the catalog; palettes against themes; the stagger migration; unsubscribe tokens) and 661 frontend tests (new: the unsubscribe page) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:14:46 +00:00
khannurien
5f0cf86fe6 v3: the dump page's "In collections" list only shows the owner's private badge — every card there said "public", which says nothing on a list of playlists holding the dump. PlaylistCard takes privateBadgeOnly, passed through PlaylistCardList, which hides the visibility badge except on a private playlist of the signed-in user's; the other lists of playlists keep both badges. 5 PlaylistCard tests pass (new: privateBadgeOnly), and tsc, eslint, doc:lint and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 53s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:19:06 +00:00
khannurien
f3f07ed1e3 v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00
khannurien
07ac4bc629 v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:19:33 +00:00
khannurien
69d9bf899b v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:50:49 +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
a36a4cac6b v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500.

Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:35:53 +00:00
khannurien
0d4e344d77 v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:31:14 +00:00
khannurien
563daa720c v3: src/ is documented like api/ and shared/, and the reference covers the whole repository — the frontend had no module docs, JSDoc on some components and hooks and not on others, props typed inline in half the components and in private interfaces in the rest, // comments on declarations, and no return types on 147 exported functions, so deno doc --lint over src/ found 637 errors. The rule api/ follows is applied to every non-test module of src/: a /** … @module */ block, JSDoc on every export and every field of an exported interface, /** */ for declarations and // for notes, verbs for functions ("Renders…" for a component), parameters named in the prose, {@link} only for symbols in scope, and a page's JSDoc names its route and who may see it (App.tsx's module doc lists the route table). For deno doc to show them, every exported function states its return type: components React.JSX.Element (or | null, or a portal), memo and forwardRef components their exotic types, contexts React.Context<…>, and a hook returning several things a documented Use<Name>Result interface (useAuth, useRequiredAuth, useFading, useEmojiTrigger, useMentionAutocomplete, useDumpCardLink, usePlayRichContent; FeedCacheResult, YouTubePlaylistControls and UseApiFormReturn are renamed to match). Every component that takes props takes an exported, documented <Name>Props interface — the 64 inline prop types are lifted into one, DumpFeed gets DumpFeedProps extending MainFeedProps. React types go through the global React namespace: the four default imports of react are gone, since deno doc can't follow them. Two duplicates go too: useMentionAutocomplete's UserResult was shared/'s UserSuggestion, and JournalCard re-exported JournalShape for no one; i18n.ts no longer re-exports Lingui's i18n, which its three users now import from @lingui/core. deno task doc and doc:lint now take src/ (but its tests and the generated locales), the index page gains a Frontend section linking every module by layer and domain, mod.test.ts walks src/ as well, and the docs workflow runs on changes to src/. deno doc doesn't document npm packages, so it takes a type imported from one — react-hook-form's FieldValues in the form fields, frimousse's SkinTone — for a private one; doc:lint now goes through doc-lint.ts, which fails on every diagnostic but those, 25 of them. The README states the frontend's part of the rule. No behaviour changes; 205 backend and 628 frontend tests pass, and tsc, deno check and deno lint are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 07:31:22 +00:00
khannurien
d62e4a9883 v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00
khannurien
d392be7cb8 v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:19:48 +00:00
khannurien
2b6e68391b v3: src/ gets one layout rule and keeps to it — each layer holds one kind of module, and every layer is split into the same domains — the previous pass sorted components into folders but left hooks, helpers and a barrel inside them, kept hooks/, utils/ and contexts/ flat, and still grouped most tests by theme. The rule: components/ holds only components (.tsx), contexts/ holds FooContext.ts + FooProvider.tsx pairs, hooks/ every hook, utils/ every non-React module, pages/ one component per route and nothing else. Each of components/, contexts/, hooks/ and utils/ is split into the same domains — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no one domain, so the chat is components/chat, contexts/chat, hooks/chat and utils/chat. components/ui becomes components/shared to match. Out of components/: useApiForm goes to hooks/form, chat's message.ts and time.ts to utils/chat (with chatDraft and chatText, now draft.ts and text.ts), the emoji skin tone to utils/editor, the remembered volume to utils/player; player/icons.tsx is PlayerIcons.tsx. components/form/index.ts is gone: everything is imported from its own file, FormProvider straight from react-hook-form, and expectOk sits beside friendlyFetchError in utils/shared/api.ts (apiError.ts), since pages that have no form used it too. Out of pages/: the index's feed views, tab bar and presence row move to components/feed; HotFeed and NewFeed, identical but for the sort, are one DumpFeed with an order prop, which also takes over MainFeedProps from pages/index/types.ts. App is a named export like everything else. Tests mirror the tree one file per module — test/<layer>/<domain>/<Module>.test.tsx — so the themed files (hooks/misc, data, editor, sync; utils/misc, text, storage, remote, journal; contexts/providers; pages/auth and UserSubpages; layout/Fabs) are split by subject, the setup each piece needs kept and the rest dropped; the floating-button test becomes one test per button, 627 tests now. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:44:14 +00:00
khannurien
07d0344be7 v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:30:28 +00:00
khannurien
de5abeceb2 v3: a performance pass over both halves — indexed queries, streamed files, cached previews, and feeds that stop re-rendering on every tick — a review turned up whole-file reads, unindexed scans and render storms; each fix comes with a test. Database: migration 0016 adds the indexes the hot queries lacked — dumps by created_at, (user_id, created_at) and slug, votes by (user_id, created_at), playlists by slug, users by lower(username) for @mentions, and the child side of every cascading foreign key (comment replies, a user's comments, follows both ways, chat messages, invites, reset tokens); the feeds, dump and playlist pages, upvotes and follower fan-out all plan as index searches now, with no temp-table sort. SQLite runs in WAL with synchronous=NORMAL and a busy timeout, and a transaction() helper wraps the multi-row writes (a dump and its categories, category sets, backlinks, attachment links, playlist reorders, follower notifications) so they commit once instead of once a row. Lists no longer run a COUNT(*) per page: they fetch limit + 1 rows and hasMore comes from the extra one; total stays only on search, the one place it's shown. Files: /api/files read the whole upload (up to 50 MB) for every range request a player makes while seeking; api/lib/serve-file.ts streams the requested bytes from disk, with an ETag and 304s, and serves avatars, attachments, covers and thumbnails too. File and thumbnail URLs carry the file's version (size and last change), so the current one is cached for a year and anything else revalidates — a same-size replacement used to stay stale, and a video's still was cached immutable forever under a URL that never changed. Feeds showed image dumps at full size: /api/thumbnails now makes a 960px preview with ffmpeg (GIFs and unreadable images go out as they are), one run per output however many cards ask at once, and ffmpeg is probed once per process instead of spawned per request. Uploads stream to disk; replacing a file writes beside it and renames over, a hard link keeping the old one for rollback instead of holding both in memory; generated stills and previews go when the file is replaced or the dump deleted, and a custom thumbnail with its dump. Outbound: fetchWithTimeout's deadline now covers the body — a server that answered at once and then dripped could hold a create open indefinitely — and pages are read to 4 MB at most; metadata is cached five minutes so the create form's preview and the post that follows share one fetch (refresh-metadata still goes to the page); proxied images sit in a 32 MB LRU. Broadcasts serialize once rather than per socket, and index.html is kept in memory until the file changes. Frontend: currentTime lived in PlayerContext, so every card re-rendered — and re-parsed its Markdown — four times a second while anything played; the playhead has its own PlayerTimeContext now. The socket context is split three ways (feed, chat via useWSChat, presence via useWSPresence), so typing, presence and chat messages leave the feeds alone. DumpCard, JournalCard and Markdown are memoized; card images use the server preview and load lazily; the feed cache is written when the page is left rather than after every pause in scrolling; the theme context value is memoized.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:35:51 +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
c8bf0d583c v3: the tests learn to expect trouble, and seven bugs they now catch are fixed — a review of the two test-suite commits found them written mostly for the happy path; each fix below comes with the test that fails without it. Infinite scroll: a later page that failed turned loadingMore back off, which made a new IntersectionObserver, which reports a sentinel still in view at once — so an offline feed asked again as fast as the request could fail, on Search, Index, the followed feeds, user dumps and user playlists alike. useInfiniteScroll now waits 1s, 2s, 4s… up to 30s while the sentinel stays put, counted from the last load so re-renders don't restart it, and asks at once again when the sentinel leaves the view or is pushed down by new items; the fake observer in src/test can report an element in view on observe(), as a browser's does. Playlist covers: the row was updated and playlist_updated sent before the file was written, so a client fetching image?v=<new> in between cached the old cover (or a 404) under the new URL for an hour, and a failed write left image_mime set with no file behind it. The file now goes in first, written beside and renamed over, and only then does the row change. Playlist order: the server wanted every member named, but GET hides private dumps the requester can't see, so a playlist holding someone else's dump that went private — or one a moderator reorders around the owner's private dumps — could never be reordered. The requester now orders what they can see and hidden members keep their slots; the visibility rule lives in api/lib/playlist-members.ts, shared by GET, reorder and the socket. playlist_dumps_updated also sent every member's id, private ones included, to a public playlist's whole audience; each viewer now gets the ids they can see, plus a dumpCount of all members so playlist cards keep the server's count. File dumps: replacing the file and changing visibility took two requests, so a failed second one left the new file live under the old visibility; PUT /api/dumps/:id/file takes isPrivate and applies both in one UPDATE, and it now broadcasts like any edit (it sent nothing, so other viewers never saw a new title). Notifications: one that arrived while the list was loading was dropped, then marked read by the read-all after the load, so it never showed; it's kept and merged in, once. Ranges: bytes=0-0,5-6 got a 416; several ranges, another unit or an unparseable header are ignored now and the whole file sent with a 200, as RFC 9110 allows — the old test asserted the 416.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:13:57 +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
khannurien
85d0703077 v3: the editors stop fighting the person typing — a review of every edit box turned up an Enter that sent a chat message mid-IME, an emoji picker that grabbed focus on ":D" and never went away, markdown that folded every line break, and limits that were counted but not kept. TextEditor: an Enter confirming an IME candidate no longer also sends, saves or picks. The : trigger wants two letters (Unicode ones too), not one, so emoticons, "note:" and "12:30" type through, and it keeps focus in the textarea — the picker's search follows what's typed, Enter or Tab takes the best match (a name equal to the query, then one starting with it, rather than frimousse's first in emoji order, which made ":rocket" an astronaut), the arrows step into the grid and typing there carries on in the textarea, and Escape closes just the picker, not the chat around it. It closes on a press outside, which it never did, and neither autocomplete fires inside code or a ``` fence. A ☺ button in each editor's corner opens the picker for mouse users, with a skin-tone selector that's remembered, and its search takes focus only once the picker is placed (a hidden field can't). Emoji, mentions and pasted images go in through execCommand, so Ctrl+Z takes back the insertion instead of wrecking the undo stack. Uploading an image made the textarea disabled, which dropped focus for good; it's read-only now, says "Uploading image…" under the box rather than only in an empty one's placeholder, reports a failed upload instead of swallowing it, and holds Enter until it's done. Any dropped file is caught — a non-image used to be opened by the browser in place of the page, draft and all — and a paste carrying text (a spreadsheet's cells come with a picture of themselves) pastes the text. The mention list follows the caret (a stale lookup can't close a newer one, lookups abort), wraps, scrolls its selection into view, closes on blur, and is a real listbox with aria-activedescendant. The counter shows from 80% of the limit by default rather than a standing "0 / 5000". Limits: chat Send and edit Save refused nothing over 2000 and let the server bounce it; they're disabled past it now. RichTextField's bare maxLength rule failed without a message, so an over-long "Why?" made Save do nothing and say nothing; it says so now. Inline editors answer Escape through a new onCancel (comment reply and edit, profile description, playlist description and title), the playlist editor takes Ctrl+Enter and can't be saved without a title, and saving a comment unchanged no longer stamps it edited. Markdown: a single newline is a line break, so a message written over three lines stops reading as one; links into the app route in place instead of opening a tab; headings are sized for prose instead of 2em page titles, and dropped from card teasers; GFM tables scroll in their own box and task lists lose the bullet; images load lazily. A chat message of only an emoji or three is shown large. Themes: brutalist and geocities give the new buttons no chrome, and geocities' ghost-button list, split in two by the placeholder-thumbnail rules in 1cb904d, is whole again, so its modal ✕, emoji cells and the picker's ✕ are flat as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:24:30 +00:00
khannurien
5fd1d364da v3: the header stops cropping itself and the search stops losing its place — a review of the header, search bar and presence row turned up tabs cut off at both ends, avatars that could only be all there or gone, and a search that forgot what it was showing. Between 860px and about 1000px, and again just past 1150px where the username and Playlists links arrive, presence plus tabs ran wider than the header's centre column; the slot couldn't shrink, so the column clipped it on both sides, and logged in FOLLOWED went missing with one person online. The container queries meant to shed presence under 460px and tabs under 280px never fired, overridden by later rules of the same weight, and no fixed width would have been right anyway: the tabs run from 314px (nyt, English) to 485px (melange, French). The slot can now shrink, and the presence row sizes itself through a new useRoomBeside, which measures the room the tabs leave it in the header and in the row under it: as many avatars as fit, at most six seats, the rest folded into a "+N" counter (square in brutalist), and never fewer than an avatar and the counter — below that the tabs scroll instead. So presence shows at every width, phones included, where a 512px rule used to hide it. When the header's tabs do scroll, they get the app's right-edge fade in the header's own colour, only while they overflow, and the active tab is scrolled clear of it. The avatars fan out on hover only where there's a pointer, so a tap doesn't leave them spread, and the label-less "+" button squares up with the icons beside it on small screens. Search: the ✕ on results opened directly ran navigate(-1) out of the app and now goes home; after several searches it stepped back one query at a time and now returns to where the search began in one step, carried in the entry's state, which useTabParam now keeps across result tabs; those tabs (/search/~/users) weren't recognised as the results page at all. On results the bar stays open holding the current query, including after a new search or Back, where it used to collapse and empty. The input no longer grabs focus on a results page, so a phone's keyboard doesn't cover the results, and submitting puts it away. Escape closes an opened search and returns focus to its button, or on results reverts an edited query, overriding the search input's own clear-on-Escape. The two search bars share one draft, so crossing the breakpoint keeps it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:15 +00:00
khannurien
bfce3186f8 v3: the frontend stops trusting what it last saw — a review of src/ turned up counts that fell to zero, stale copies winning over fresh ones, sessions that tabs trampled on each other, and a player queue that outlived its listener, and this closes them. Voting or liking something with no live count yet started from 0, so a dump with five votes read 1 on the click and, with the socket down, 0 for good once the undo fired; the buttons now pass the count they show, which the optimistic update and its undo start from. After a reconnect the chat re-read its history as the truth, but the merge let the live copy of a message win, so an edit made while the socket was down never showed; the resync now replaces live copies with what it fetched. Every dump_updated added the dump to the live list if it wasn't there, so retitling a months-old dump put it at the top of New, Hot and Journal for everyone, while one that had arrived live kept its old title; an edit now refreshes a live entry in place and adds nothing, and the server announces a dump going public as a dump_created, which is the one case that is an arrival. The new-dump modal let a slow preview for the previous link land on the new one, title suggestion included; changing the link now aborts both lookups. Markdown found mentions by rewriting the source text with a rule that, unlike the server's, didn't skip an @ after a word character, so an email address linked its domain, a URL like mastodon.social/@bob was split, and @ in code came out as literal link syntax; mentions are now found by a remark step over text nodes only, by the server's rule. Changing the password in one tab stored fresh tokens that any other open tab wiped the moment its old token drew a 401; the session now lives behind utils/authStorage.ts, tabs follow each other through storage events (so a logout also signs out every tab), a 401 only forgets the stored session if it still holds that token, and in-place session updates go through a new updateSession() rather than re-reading localStorage. The persisted player queue carries media-token URLs yet survived logout for the next person to sign in; it is now stamped with its owner, not restored for anyone else, cleared on logout or account switch (a guest's queue survives signing in), and older unstamped queues holding a file are dropped. Playlist checkboxes in the new-dump and add-to-playlist modals flipped whether or not the server agreed and now wait for it. CommentThread and the Dump page's loads use authFetch like the rest, so a 401 there signs out consistently; isTokenExpired has one home; and the useScrollLock imports carry their extension so deno check passes.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:24:38 +00:00
khannurien
2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00
khannurien
73b80af08e v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 20:36:49 +00:00
khannurien
b313f70d31 v3: chat mentions stop going missing, and live events get a light sound — the server skips a mention notification for anyone with the chatbox open, on the grounds that they're reading it, but "open" also meant a chatbox left open in a background tab, and the same modal cleared the unread badge on every arrival, so a mention there left no bell, no badge and no trace. Reading the chat now means open and on screen: the focus flag is re-announced on every visibilitychange, and the unread count only clears while the tab is visible. A reconnect with the chatbox open refetches the latest page, since whatever was said while the socket was down never reached the live buffer. The server's mention regex excluded a backslash and the letter w rather than word characters (\\w inside a class), so an email address mentioned its domain; it now matches the autocomplete's rule. On top of that, sounds synthesised with Web Audio — a soft blip for chat you aren't looking at, throttled, and a two-note chime for notifications and for a mention you're reading live, which gets no notification of its own — claimed through localStorage so several tabs chime once, with an on/off toggle under a new Notifications section in settings. The tab title carries the unread count, composed with the page title in useDocumentTitle so navigation doesn't drop it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 19:25:19 +00:00
khannurien
e4d50f047c v3: the journal feed breathes — one mosaic running unbroken down the page reads as a slab, so the composer now cuts it into sub-blocks, each its own grid with a themable rule between. A break can only fall where a row starts clean and isn't reached into from above, which the exact tiling makes cheap to test: every row closes flush, so the only boundaries ruled out are the ones a portrait or a hero straddles.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Two things set the rhythm, and neither is a row counter — that beats like a metronome. It comes due on a count of *cards*: a block is a set of stories and a hero is one story however many cells it takes, so a stretch thick with big footprints reaches the count over more rows than a stretch of squares does, and block heights end up tracking the size mix (measured: four to five rows through square-heavy runs, six to seven where art clusters). And the break *makes* its own section front rather than waiting for one, promoting the card that opens a new block to a hero. Waiting was the first attempt and it failed on the numbers — a banner lands at the start of a row on its own two to five times in thirty-six, so the preference almost never fired and a max-rows cap did all the work. Promotion always fits, since a break only falls on a row with all four cells free. A tail guard keeps the last cut from leaving a rule with a fragment under it.

The break sits between two grids rather than inside one: a grid item would be stretched to the 9rem row track, and out here its height is its own, down to nothing. Three custom properties are the whole contract a theme writes against — the air around it, the rule through it, an optional glyph centred on that rule — with `none` on the last two meaning the real absence of each, so a theme can remove the separation entirely rather than leave an empty band. Themes wanting something a rule can't be reach past the properties to plain CSS: nyt pairs hairlines in the grid's own pen and clips them to the art inset (which moves up to the feed, since a property on the grid can't reach a sibling); brutalist spends air around the 2px border it already divides everything else with; kanagawa takes a comment divider, geocities the rainbow its header ends on, melange a thinner band of the mélange. The page gutter moves to a feed wrapper so every block lines up on the same edges, and nyt's masking frame now runs once per block, which is what makes each of them read frameless and not just the feed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 08:01:42 +00:00
khannurien
27c55ea96f v3: melange joins the styles — savq/melange-nvim's palette taken verbatim (the a.* neutrals, b.yellow as the accent since it's the scheme's Function colour, the dusty c.* row as furniture), but built as a warm 70s print instead of a second editor skin: one cut of Fraunces with opsz pinned low, so a headline and the caption under it stay the same face; pillowy borderless cards lifted on warm sunlight shadow rather than held inside a line; the mélange itself — every desert hue blended once — laid along the bottom edge of the header; and grain over the whole page, which has to differ per scheme because soft-light grey noise is invisible on a pale ground: fine film grain on the coffee dark, a coarser multiply tooth on the plaster light, both switched through custom properties since light-dark() only carries colours. Light keeps the warmth in the page and the bleach in the cards — a.float underneath, tints above — since melange's own light bg reads grey next to its floats; the header takes a lighter shade of its own so it sits over the page as a board.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 22:32:26 +00:00
khannurien
084ebcb627 v3: dump edit balances its two columns — the dump itself (preview, refresh, thumbnail, URL or replacement file) on the left, its presentation (title, why, categories, visibility) on the right, actions in a full-width footer bar so both columns end level; "Why?" stretches to absorb a taller preview on desktop
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:32:18 +00:00
khannurien
a9edd8fbb8 v3: invite panel reworked — "+ Invite someone" toggles a panel sized to the action row, easing in like the inline editors: link field fused with a full-width Copy, single-use/7-day hint, no longer clipped or faded on mobile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:06:21 +00:00
khannurien
dde3fe8fe2 v3: type checking and lint pass clean — thumbnail placeholder takes file-dump props, no manual memo the compiler can't keep, no setState straight in effect bodies
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:42:32 +00:00
khannurien
6026286bbf v3: journal becomes the default feed tab for visitors and for users who haven't picked one
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:39:17 +00:00
khannurien
20d717f879 v3: new Kanagawa style, after kanagawa.nvim — Wave palette in dark, a cooled Lotus in light, all JetBrains Mono
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:37:18 +00:00
khannurien
8c3efe6c30 v3: inline edit forms and the narrow-viewport search row ease open instead of popping in
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:26:18 +00:00
khannurien
1cb904d2cf v3: generated placeholder thumbnails for pages with no preview image, and a fix for hotlink-protected ones
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Thumbnails were failing in two different ways that both ended as an empty box.

Cloudflare hotlink protection answers a cross-site Referer with 403, so images
we had extracted correctly (dles.aukspot.com's og:image among them) never
rendered — every onError handler set display:none and swallowed it. The new
Thumbnail component loads with referrerPolicy="no-referrer", retries once
through /api/proxy-image for hosts that reject an empty referrer too, and only
then falls back to a placeholder.

Separately, the extraction cascade ended at the page's icon and then a guessed
/favicon.ico, so thumbnailUrl was almost never empty — just a 16x16 icon
cover-cropped into a 128x72 box. It now stops at real artwork, with faviconUrl
and accentColor (theme-color / msapplication-TileColor / mask-icon) as their own
fields. An absent thumbnailUrl finally means "no artwork", which is what makes
the placeholder possible: the site's own color mixed into the theme surface,
with its favicon centered on it, or its initial. Contrast holds for any
third-party color by construction rather than by luminance math, so nyt only
has to set --thumb-tint-strength to 0% to stay monochrome and geocities only
has to raise it. Missing accents fall back to a stable hostname-derived hue, so
rows saved before this get a tint with no backfill.

Migration 0011 reclassifies favicon-shaped thumbnailUrls on existing dumps.
The journal mosaic keeps its pull-quote and text fallbacks — the placeholder
appears there only to repair a broken image.

Also fixed: refresh silently overwrote good metadata with a failure stub, the
refresh button swallowed every error, refresh never broadcast the update,
extractBestIcon ranked SVG icons below 16x16 PNGs, and shared links with no
artwork carried no og:image at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00
khannurien
a9b94c0bfb v3: added an "in collections" section to dump pages, listing the collections a dump belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:41:36 +00:00
7773a8a501 v3: fixed roles propagation bug, fixed inconsistent file drop zone, fixed chat attachments wrongfully pruned, some visual tweaks
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
2026-06-30 13:41:36 +02:00
khannurien
6c4f410d9b v3: added a chatbox
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
2026-06-29 20:25:10 +00:00
khannurien
aab5b5bfd5 v3: various improvements and fixes to real-time updates
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
2026-06-29 07:52:56 +00:00
khannurien
324bff3133 v3: various minor visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 39s
2026-06-28 17:00:44 +00:00
khannurien
fae25f3e6c v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s
2026-06-28 16:23:44 +00:00
khannurien
c8c7b05c25 v3: fixed inconsistent page titles
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
2026-06-28 06:51:42 +00:00
khannurien
551e29c564 v3: added playlist moderation permission
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
2026-06-28 06:37:23 +00:00
khannurien
810faaf21a v3: added a roles/permissions system, added user management tab on user profile
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
2026-06-28 06:21:23 +00:00
khannurien
b567e390d9 v3: fixed journal view not scrolling, fixed some user profile layout bugs, increase thumbnail size on the index
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
2026-06-28 05:27:49 +00:00
khannurien
bcbb8f5583 v3: minor visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
2026-06-27 20:10:55 +00:00
khannurien
c29f45fbc8 v3: added index default tab selection setting, fixed notification pages not loading, reduced invite token length, global player state survives page reloads, many visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
2026-06-27 19:44:35 +00:00
khannurien
96c41f01da v3: tracked down many visual inconsistencies, added two new themes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
2026-06-27 14:28:33 +00:00
khannurien
01940e14be v3: overhauled journal view
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
2026-06-27 10:57:12 +00:00
khannurien
76de798faf v3: fixed authorization bug with stale tokens, fixed private dump access bug, various small fixes across the app
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 20s
2026-06-27 10:40:07 +00:00
khannurien
73e0114bf7 v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
2026-06-26 22:20:07 +00:00
khannurien
26f5abfa4e v3: fixed linter error, code format
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
2026-06-22 17:31:21 +00:00
khannurien
a1b71ad0c8 v3: consistent tabs url across app, maxy small fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
2026-06-22 17:25:40 +00:00