Compare commits

...

14 Commits

Author SHA1 Message Date
khannurien
eec73e9749 v4: a comment and a chat message are edited in the same field as every text edited in place — each had an editor of its own: a comment's was its reply form, which showed a refused edit as a card and left the focus nowhere once closed, and a chat message's a box of its own with its own buttons and rules. InlineEdit gives its field, InlineEditField, to the components that open it with an action of their own, as these two do with their Edit and ✎: they keep those, a click on someone's message being no request to edit it. The field opens on the text with the caret at its end and as tall as the text, saves on Ctrl+Enter (and on Enter in the chat, where Shift+Enter breaks a line, as in its composer), leaves on Escape, sends nothing for a text left as it was, refuses an empty one, says why a save was refused in one line, and gives the focus back to the button that opened it, unless the caller has put it elsewhere, as the chat does to its composer. CommentForm only posts a comment or a reply now; ChatEditBox keeps only what is the chat's, scrolling itself into the list's view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:16:43 +00:00
khannurien
3e03626cc1 v4: text is edited where it stands through one component, InlineEdit, and a dump's note and a playlist's title and description join its title, a profile's description and email — those three were written apart, each with its own state, keys, buttons, error and CSS, and the note and the playlist could only be changed in their full editors. InlineEdit (components/form) shows the text; a click on it, or the ✎ after it, opens a field holding it, with Save and Cancel under it. A one-line field takes the text's own type in every style, from the class it is handed (dump-title, playlist-detail-title), and sits on the faint plate the text showed under the pointer, so that nothing moves but the buttons that come; a Markdown text opens the editor comments use. Enter saves a line, Ctrl+Enter a Markdown text, Escape backs out, a text left as it was closes without a request, a required one can't be emptied, a refused save says why in one line beside the buttons, and the focus goes back to the ✎. A click on a link in the text, or one that ends a selection, follows the link or keeps the selection. The ✎ is now a button with the action's name, where a div pretended to be one, and the text keeps its own role, a heading or Markdown. The plate is drawn under the text by a pseudo-element, tinted from its colour: the surface colour it had was the card's own, and showed nothing on the dump page. On a playlist's page the title edits in place beside Follow and Edit, which go to the row's end, and the description too; an empty note or description says what goes there to whoever may write it. DescriptionEditor and EmailEditor go.
The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width.

The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
b92c674726 v4: the devcontainer has Playwright's browsers — they were installed by hand in the home directory, which the rebuild onto Node 26 left without them, and the screenshots of the dev instances stopped working. The Dockerfile installs Firefox and the Chromium headless shell of npm:playwright@1.63.0, with the system libraries they need, in /opt/ms-playwright (PLAYWRIGHT_BROWSERS_PATH), which the workspace and the instances' services share as part of the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:08:53 +00:00
khannurien
7d0a0e7534 v4: the devcontainer runs Deno 2.9.7 on Node 26 — its image took Deno 2.9.0 and the Node 20 base. The Dockerfile now copies the deno binary from denoland/deno:bin-2.9.7 onto mcr.microsoft.com/devcontainers/typescript-node:26.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 13:38:16 +00:00
khannurien
51439f475d v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:38:44 +00:00
khannurien
62e51f1622 v4: the six styles are reworked, each around one idea of its own, and the tab row stops painting the page — a pass over every theme, then the faults it showed in what they share. Brutalist draws every box in ink with a solid offset shadow and sets the palette's colors as flat blocks (a teal tag, an apricot quote, an inked label); its dark scheme keeps that ink darker than the page, on Twilight Indigo, where eggshell lines glared across every frame. GeoCities goes to the desktop of its day: the header is the taskbar, the site's name its Start button, the controls grey bevels, and a marquee crawls under the header (Marquee.tsx, drawn in that style alone), with Comic Neue where the system has no Comic Sans. Melange leaves its browns and Fraunces for a sorbet blend in Bricolage Grotesque, run along the header, through the site's name, where it drifts, and between a journal's blocks; its two schemes are one print and its negative, trading ink and paper while the pink and the plum written on it stay, and they share one fine grain where the light one had a coarse tile that greyed the page. Kanagawa opens the comments and the related dumps on a status line and reads on Lotus's brightest paper; its page is flat. Times draws its heavy rules in a mid grey at night, where ink is near white, and sets a journal cell with no picture as text. In what the styles share: a row of tabs (TabBar) hid the tabs scrolled under its scope switcher, and faded its ends, by painting the page's color over them, which showed as a patch on any page that is a gradient or a picture. The switcher now sits before the row that scrolls, which clips the tabs at its edge, and the fades are a mask (--tab-fade-start and --tab-fade-end); useRoomBeside measures the row through that one more level. A page's title keeps a word whole unless it alone is wider than the line: Firefox sizes a title set with negative letter-spacing a hair narrower than it lays it out, which dropped its last letter to a second line in any style that tightened it, so the rule is the base's and the two styles' copies go. A card holding one paragraph, such as the instances a new account hasn't signed in to on /settings, is spaced by its padding and not by the paragraph's margins as well. The site's name answers the pointer as a card's title does in each style, where two of them left it still. The emails follow the styles' tokens, Melange's blend among them.
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 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:57 +00:00
khannurien
eb4e64683c v4: the Times nameplate sits level with the header's buttons — the name was 7 pixels under their middle on a wide screen and 3 on a narrow one, with twice the paper above its letters as below. The blackletter sets its letters low in its line, an ascender stopping a fifth of an em under the line's top and a descender ending on its bottom, and the nameplate's padding was the heavier above besides. The name now carries 0.22em under its line, which leaves as much below the letters as above at either size, and the nameplate's padding is the same on both sides, 0.6rem and 0.7rem from 860 pixels up. The wide masthead's row is a pixel taller, the narrow header keeps its 60.
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 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:30:02 +00:00
khannurien
d54ae554a4 v4: the Times style gets a masthead and a face for each job — one condensed serif did everything, buttons and tabs included, under a plain header bar. The site's name is now a blackletter nameplate (Manufacturing Consent), its emoji beside it on the same baseline at the size of a printer's mark. From 860 pixels up it takes the middle of a row of its own, the buttons to its right, and what a page puts in the header, a feed's tabs or the open search field, runs underneath between a hairline and a double rule; a page that puts nothing there keeps one row and a single ink rule. Instrument Serif stays for headlines, at its one weight and larger sizes, Newsreader sets what is read at length (summaries, notes, comments), and Libre Franklin everything the interface says: controls, tabs, datelines, bylines, labels (--font-ui, listed in themes/nyt.css). Tabs are sentence case with an ink underline on the open one. The comments, the related dumps, a profile's sections and a page's groups open on an ink rule with their label in bold capitals under it. A dump's categories are small capital kickers, where they were grey chips. Buttons and fields take a 3px radius, pictures, cards and sheets staying square. The header sits on the page's color in the dark scheme too. In the journal each cell draws its four rules as a 1px shadow in a 1px gap, where it drew its top and left edges: a last row the dumps don't fill was left open on its right, and the row above it unruled over the empty tracks. A page's title keeps a word whole unless it is wider than the line. deno.lock also takes the key format of the Deno that wrote it, with no version changed beyond the three font packages.
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 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:26:09 +00:00
khannurien
3e15e9a4f2 v4: /follow/playlists opens the followed playlists' feed, and a page's test opens it through the app's routes — the path was a 404 since the two views of the followed feed became pages: the route table held each feed tab as an exact path, /follow, so /follow/playlists fell to /:handle/*, which read "follow" as a category and "playlists" as a tab it hasn't. /music/follow/playlists, which goes through that route, worked. The tabs' routes now take what follows them (/${tab}/*), which Feed already reads and refuses when it names no view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 57s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 28s
No test saw it because each page's test mounted the page on a route pattern of its own, a copy of App's, with stand-in pages where a redirect lands: Index's parsed the path itself, and passed on one the app doesn't serve. renderPage (src/test/render.tsx) now opens a URL through AppRoutes, which App.tsx exports for it, inside the real providers, and every page's test uses it: the page is the one the app routes that URL to, behind its guard, and a redirect is checked by the path it lands on. An entry may be a location, to arrive with the state a link hands over. Every page being loaded with its route, the first query after opening one waits. src/test/App.test.tsx tests the table itself: the paths that are a feed, those that are not found, those that send a guest to sign in and back, and those that send a signed-in user home.

The profile's test held /@ann/dumps/x to be a path the profile answers as not found, which only its own pattern made true: in the app it is a dump's page. It now asks for /@ann/dumps/x/y. The tests of the two guards still mount them around a stand-in child, being tests of the guard alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:52 +00:00
khannurien
cbb770bff8 v4: a still is scaled from its first frame alone, a second image in its file made the copy flash — an avatar came out as a WebP of two frames looping at 40 ms each, the picture then a blank. A still's file may hold more than one image, a phone's JPEG carrying a gain map or a depth map after the photo, and ffmpeg reads each as a frame: only the JPEG and PNG copies were held to one, the WebP keeping them all as it does a GIF's. scalableImage (lib/upload.ts) now says whether the image is an animation, which a GIF alone is, and scaleImage (lib/ffmpeg.ts) writes one frame of anything else, in every format; the image proxy scales through the same call. The copies already made stay, a copy being current while it has its upload's date: uploads/scaled is to be deleted once on an instance that ran the previous version, everything in it being made again on first request.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 52s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:40:14 +00:00
khannurien
d2b7d21824 v4: the page a visit lands on asks for its content while its code loads — a first load went one step after the other: the entry script, then the interface's messages, then the page's chunk and its twenty-odd dependencies, and only then the request for the feed. The built page now carries a script in its head that asks for the messages' chunk at once, beside the entry, picking the language as detectLocale does (preloadCatalog in vite.config.ts; the two must agree). And main.tsx sends the landing page's first request as the app boots (utils/shared/earlyFetch.ts): the feed's first page on / and its tabs, the dump on a dump's page, as the stored session. authFetch hands that response to the page that asks for the same URL, once, to the session it was sent as and within ten seconds, in place of a request of its own; past that the page asks again. feedApiUrl (utils/shared/urls.ts) is the feed's URL for both. A category's feed, a profile, a playlist and the other pages wait for their code as before. On a local build with 150 ms of latency added, the feed's request leaves at 0.69 s where it left at 1.70 s, and the first card shows at 1.59 s where it showed at 1.92 s.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00
khannurien
90fc281e90 v4: every image the API serves comes scaled to the size it is shown at — a first visit to the feed loaded 4.8 MB of images out of 5.1 MB in all: an avatar shown at 32 pixels was the upload itself, a 1.4 MB GIF among them, and so were a cover, a custom thumbnail, an attachment and a proxied image, only an image dump having a preview, 960 pixels wide. Every route that answers an image now takes ?w= and sends a WebP copy at most that wide, of the widths shared/image.ts lists, 128, 320, 640 and 960, a width between two taking the one above, and never wider than the image; without it the upload goes out as before, a dump's thumbnail alone being scaled to the widest by default, the custom ones included. serveImage (lib/upload.ts) is where they all end, and the image proxy scales what it fetched the same way, keeping each width in its cache. lib/ffmpeg.ts makes the copies as it made the previews, which they replace: on first request, under uploads/scaled/ at the upload's own path, each given its upload's date and made again when the two differ, so a new avatar or cover needs nobody to delete the old ones. WebP keeps a PNG's transparency and a GIF's animation. A JPEG is turned by its EXIF orientation here, ffmpeg's own rotation of images depending on its version. An animated WebP, which ffmpeg can't read, and an image it fails on go out as they are, a failure being remembered until the upload changes. ?f=compat asks for the copy as a JPEG, or a PNG for what may be transparent: the digest's emails take their thumbnails that way, at 640 pixels, not every mail client reading WebP. On the client, imageProps (utils/shared/urls.ts) gives an image its srcset of those copies and the width it is laid out at, which config/imageSizes.ts states for each place from App.css: Avatar, Thumbnail, which scales an API image and leaves another site's as that site sends it, and the playlists' covers use it. The images of notes and comments still load as uploaded. The copies go with the dump, the custom thumbnail or the attachment they were made from, and generatedMediaSweepJob, a minute after the start then daily, takes those whose upload is gone, the previews under their former name and the temporary files a generation left unfinished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00
khannurien
6ab398e687 v4: the API's JSON and the page are sent compressed — a page of the feed went out as it was, twenty kilobytes where a fifth does: only the built frontend's files were compressed, at build time, and Deno's server leaves alone a response whose length Oak has set. compressMiddleware (api/middleware/compress.ts), installed after the error middleware and around everything that sets a body, sends an object or array, and a string whose type its route set, in brotli or gzip as the request's Accept-Encoding takes, a coding refused with q=0 not being taken, from a kilobyte up, the threshold the build compresses from. Brotli is at quality 5, the frontend's files, compressed once, staying at 11. Files, streams and bytes, a response already encoded, a HEAD and one marked no-transform go out as they are, and a compressible answer says Vary: Accept-Encoding whether compressed or not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:06 +00:00
khannurien
1bd5c924b0 v4: Dark Reader is kept off the pages, which lost every background under it — on a phone in dark mode with the extension on, the avatar menu, the cards and the tabs were transparent over Dark Reader's own page colour, in every style. The build gives each light-dark() a fallback for the browsers without it, two custom properties of which one is empty, and Dark Reader's rewrite of the stylesheet drops the empty one under a dark system theme: both halves of every colour then apply at once, which is no colour, so nothing paints a background or a border. index.html now carries <meta name="darkreader-lock">, which the extension obeys by generating nothing; the site has dark palettes of its own, following the system or the user's choice. The dev server never showed it, serving light-dark() as written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:26:21 +00:00
124 changed files with 6352 additions and 2007 deletions

View File

@@ -1,11 +1,20 @@
FROM denoland/deno:bin-2.9.0 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:20
FROM denoland/deno:bin-2.9.7 AS deno
FROM mcr.microsoft.com/devcontainers/typescript-node:26
COPY --from=deno /deno /usr/local/bin/deno
# The API draws thumbnails and waveforms with ffmpeg, in the workspace and in
# the instances' services alike (compose.yaml), which don't get the features.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Playwright's Firefox and Chromium headless shell, with the system libraries
# they need, for screenshots of the dev instances (`npm:playwright@1.63.0`): a
# script importing another version looks for other builds. They live outside
# the home directory, which a rebuild would otherwise leave without them.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
RUN DENO_DIR=/tmp/deno deno run --no-lock -A npm:playwright@1.63.0 install \
--with-deps firefox chromium-headless-shell \
&& chmod -R a+rX /opt/ms-playwright \
&& rm -rf /tmp/deno /var/lib/apt/lists/*
# The services share Deno's cache through a volume (compose.yaml), which Docker
# creates owned as this directory is, so it must exist and be node's.
RUN mkdir -p /home/node/.cache/deno && chown -R node:node /home/node/.cache

15
API.md
View File

@@ -105,6 +105,15 @@ status. A list answers `{ "items", "hasMore" }` and takes `?page=` (from 1) and
thumbnail, waveform, cover or attachment takes the owner's media token as
`?token=`.
An image the API serves, an avatar, a thumbnail, a cover, an attachment or a
remote image, takes `?w=` and answers a WebP copy scaled down to that width,
snapped up to one of `shared/image.ts`'s (128, 320, 640, 960), and never wider
than the image; without it, the image as it was uploaded, a dump's thumbnail
alone being scaled to the widest by default. `?f=compat` asks for the copy as a
JPEG or PNG, for a mail client that reads no WebP. An image no copy can be made
of goes out as it is. JSON answers of a kilobyte or more are compressed, in
brotli or gzip, for a client that accepts it.
### me
- `GET /api/me` — signed in. The caller's own user.
@@ -207,6 +216,12 @@ there: those endpoints answer it 400.
- `GET /api/community?period=weekly|monthly` — signed in. The site's top dumps,
new members, new public playlists and totals over the last week (by default)
or month, as of the server's last refresh.
- `GET /api/community/feed` — signed in. The community feed: the 12 latest
things done in public, newest first (a public dump posted, a public playlist
created, a comment or an upvote on a public dump, someone joining), and the
last week's totals and most upvoted dump, as of the server's last refresh. A
dump has one comment event and one upvote event at most, its latest. Every
reader gets the same feed.
### chat

View File

@@ -199,7 +199,7 @@ api/
test/ # Backend tests (Deno): test/<layer>/<module>.test.ts
sql/
gerbeur.db # SQLite database (not committed); <name>.db per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … (not committed); <name>/ per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
src/ # React frontend (Vite)
config/ # API base URL, default feed tab, player mode, instance settings
pages/ # One component per route, nothing else

View File

@@ -120,6 +120,12 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
* thumbnails route generates from file dumps.
*/
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/**
* Holds the scaled-down copies of the images above, which lib/ffmpeg.ts makes
* as they are first asked for, each at its upload's path with its width. It
* can be emptied at any time: the copies are made again.
*/
export const SCALED_DIR = `${UPLOADS_DIR}/scaled`;
/**
* Caps the size of an image upload, in bytes, for avatars, covers, thumbnails

View File

@@ -11,6 +11,7 @@ import {
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
UNUSED_INVITES_RETENTION_DAYS,
} from "../config.ts";
import { removeScaledImages } from "../lib/ffmpeg.ts";
import { hashPassword } from "../lib/password.ts";
/**
@@ -60,6 +61,7 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
if (orphanedAttachments.length > 0) {
for (const { id } of orphanedAttachments) {
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
await removeScaledImages(`${ATTACHMENTS_DIR}/${id}`);
}
db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`)
.run(attachmentCutoff);

View File

@@ -1,15 +1,26 @@
/**
* Makes what the server derives from a file dump's upload with ffmpeg: a
* video's still and an image's scaled-down copy, which the thumbnails route
* serves, and an audio file's waveform, which the waveform route serves. Each
* is made on its first request and kept in `THUMBNAILS_DIR` under a name this
* module gives, until dump-service replaces or deletes the upload and
* {@link removeGeneratedMedia} drops them.
* Makes what the server derives from an upload with ffmpeg: a video's still,
* which the thumbnails route serves, an audio file's waveform, which the
* waveform route serves, and the scaled-down copies of any image, which
* `serveImage` (lib/upload.ts) serves for every route that answers one. Each
* is made on its first request and kept under a name this module gives, a
* dump's in `THUMBNAILS_DIR` and the scaled copies in `SCALED_DIR`, until
* dump-service replaces or deletes the upload and
* {@link removeGeneratedMedia} drops them. {@link generatedMediaSweepJob}
* takes what that leaves behind.
*
* @module
*/
import { THUMBNAILS_DIR } from "../config.ts";
import { dirname, relative } from "node:path";
import {
DUMPS_DIR,
SCALED_DIR,
THUMBNAILS_DIR,
UPLOADS_DIR,
} from "../config.ts";
import { IMAGE_WIDTHS, type ImageWidth } from "../../shared/image.ts";
import type { Job } from "./scheduler.ts";
import { type DumpWaveform, peaksOf } from "../../shared/waveform.ts";
import type { APIResponse } from "../../shared/api.ts";
@@ -48,7 +59,7 @@ const VIDEO_FORMATS =
* Lists the formats ffmpeg may read an image as, for the reason given at
* {@link VIDEO_FORMATS}.
*/
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe,gif";
/**
* Lists the formats ffmpeg may read an audio file as, for the reason given at
* {@link VIDEO_FORMATS}; an `.m4a` or a `.webm` is read by its container's.
@@ -57,35 +68,50 @@ const AUDIO_FORMATS =
"mp3,ogg,flac,wav,aiff,aac,asf,caf,w64,wv,ape,mov,mp4,m4a,matroska,webm";
/**
* Maps the image types that get a scaled-down preview to the preview's format.
* A GIF is served as it is, to keep its animation.
* Returns where the still and the waveform generated from dump `dumpId`'s
* upload are kept.
*/
export const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/jpeg": "jpg",
"image/bmp": "jpg",
// May carry transparency, which JPEG would fill with black.
"image/png": "png",
"image/webp": "png",
};
/**
* Caps an image preview's width. It covers the widest feed card on a
* high-density screen, at a fraction of a phone photo's bytes.
*/
const PREVIEW_WIDTH = 960;
/** Returns where the media generated from dump `dumpId`'s upload are kept. */
export function generatedPaths(dumpId: string): {
still: string;
preview: Record<"jpg" | "png", string>;
waveform: string;
} {
const base = `${THUMBNAILS_DIR}/${dumpId}`;
return {
still: `${base}.jpg`,
preview: { jpg: `${base}.preview.jpg`, png: `${base}.preview.png` },
waveform: `${base}.waveform.json`,
};
return { still: `${base}.jpg`, waveform: `${base}.waveform.json` };
}
/**
* The formats a scaled copy comes in: WebP for a browser, and a JPEG or a PNG
* for what doesn't read it, as some mail clients don't.
*/
export type ScaledFormat = "webp" | "jpg" | "png";
const SCALED_FORMATS: readonly ScaledFormat[] = ["webp", "jpg", "png"];
/**
* Returns where the copy of the upload at `srcPath` scaled to `width` is
* kept, in `format`: under `SCALED_DIR`, at the upload's own path below
* `UPLOADS_DIR`.
*/
export function scaledImagePath(
srcPath: string,
width: ImageWidth,
format: ScaledFormat = "webp",
): string {
return `${SCALED_DIR}/${relative(UPLOADS_DIR, srcPath)}.w${width}.${format}`;
}
/**
* Deletes the scaled copies of the upload at `srcPath`, once it is deleted.
* One that is replaced needs no call: its copies are made again, being older
* than it ({@link ensureGenerated}).
*/
export async function removeScaledImages(srcPath: string): Promise<void> {
await Promise.all(
IMAGE_WIDTHS.flatMap((width) =>
SCALED_FORMATS.map((format) =>
Deno.remove(scaledImagePath(srcPath, width, format)).catch(() => {})
)
),
);
}
/**
@@ -93,12 +119,11 @@ export function generatedPaths(dumpId: string): {
* has replaced or deleted it. The routes make them again from a new one.
*/
export async function removeGeneratedMedia(dumpId: string): Promise<void> {
const { still, preview, waveform } = generatedPaths(dumpId);
await Promise.all(
[still, preview.jpg, preview.png, waveform].map((path) =>
Deno.remove(path).catch(() => {})
),
);
const { still, waveform } = generatedPaths(dumpId);
await Promise.all([
...[still, waveform].map((path) => Deno.remove(path).catch(() => {})),
removeScaledImages(`${DUMPS_DIR}/${dumpId}`),
]);
}
/**
@@ -109,7 +134,7 @@ async function writeAtomically(
outPath: string,
write: (tmpPath: string) => Promise<void>,
): Promise<void> {
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });
await Deno.mkdir(dirname(outPath), { recursive: true });
const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`;
try {
await write(tmpPath);
@@ -156,25 +181,104 @@ export function videoStill(srcPath: string, outPath: string): Promise<void> {
], outPath);
}
/** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
export function imagePreview(srcPath: string, outPath: string): Promise<void> {
/**
* Maps a JPEG's EXIF orientation to the filters that turn its pixels upright.
* A browser applies the orientation to the upload itself; the copy carries no
* EXIF, so it is turned here, and not by ffmpeg's own autorotation, which
* only its recent versions apply to images.
*/
const ORIENTATION_FILTERS: Record<number, string> = {
2: "hflip",
3: "hflip,vflip",
4: "vflip",
5: "transpose=0",
6: "transpose=1",
7: "transpose=3",
8: "transpose=2",
};
/** What ffmpeg is told to write each {@link ScaledFormat} with. */
const ENCODERS: Record<ScaledFormat, string[]> = {
webp: ["-c:v", "libwebp", "-q:v", "80", "-loop", "0", "-f", "webp"],
jpg: ["-c:v", "mjpeg", "-q:v", "4", "-f", "image2"],
png: ["-c:v", "png", "-f", "image2"],
};
/**
* What {@link scaleImage} is told of the image it scales, which
* `scalableImage` (lib/upload.ts) reads from its first bytes.
*/
export interface ImageTraits {
/** The EXIF orientation to turn the image by, 1 when it is upright. */
orientation: number;
/**
* Whether its frames are an animation, as a GIF's are. A still may hold
* more than one image too, a phone's JPEG carrying a gain map or a depth
* map after the photo, and ffmpeg reads each as a frame.
*/
animated: boolean;
}
/**
* Writes a copy of an image at most `width` wide, never wider than the image
* itself, as a WebP unless `format` names another. WebP keeps a PNG's
* transparency and a GIF's animation, at a fraction of either's bytes.
*/
export function scaleImage(
srcPath: string,
outPath: string,
width: ImageWidth,
{ orientation, animated }: ImageTraits,
format: ScaledFormat = "webp",
): Promise<void> {
const upright = ORIENTATION_FILTERS[orientation];
// A JPEG's colour planes halve its height, which they need even.
const height = format === "jpg" ? -2 : -1;
// Only an animation keeps its frames, and only as a WebP: of anything else
// the first is the image, and a copy of them all would flash through what
// follows it.
const frames = animated && format === "webp" ? [] : ["-frames:v", "1"];
return runFfmpeg([
"-noautorotate",
"-format_whitelist",
IMAGE_FORMATS,
"-i",
srcPath,
"-frames:v",
"1",
"-vf",
`scale='min(${PREVIEW_WIDTH},iw)':-2`,
"-f",
"image2",
...(outPath.endsWith(".png")
? ["-c:v", "png"]
: ["-c:v", "mjpeg", "-q:v", "4"]),
`${upright ? `${upright},` : ""}scale='min(${width},iw)':${height}`,
"-an",
...frames,
...ENCODERS[format],
], outPath);
}
/**
* Returns `bytes`, an image held in memory, scaled as {@link scaleImage}
* does, or null when it can't be, because ffmpeg is missing or failed. The
* image proxy scales what it fetched with it, and keeps the result itself.
*/
export async function scaleImageBytes(
bytes: Uint8Array,
width: ImageWidth,
traits: ImageTraits,
): Promise<Uint8Array | null> {
if (!await ffmpegAvailable()) return null;
const srcPath = `${SCALED_DIR}/tmp/${crypto.randomUUID()}`;
const outPath = `${srcPath}.webp`;
try {
await Deno.mkdir(dirname(srcPath), { recursive: true });
await Deno.writeFile(srcPath, bytes);
await scaleImage(srcPath, outPath, width, traits);
return await Deno.readFile(outPath);
} catch {
return null;
} finally {
await Promise.all(
[srcPath, outPath].map((path) => Deno.remove(path).catch(() => {})),
);
}
}
/**
* The sample rate the audio is decoded at for its waveform. A bar stands for
* a second or more of a song, so 8 kHz keeps every peak worth drawing at a
@@ -269,20 +373,140 @@ export function audioWaveform(srcPath: string, outPath: string): Promise<void> {
*/
const inFlight = new Map<string, Promise<void>>();
/**
* Spares an ffmpeg run per request for an image it can't read: the
* generations that failed, by output path, with the date of the source they
* failed on. A new upload gets a new try.
*/
const failed = new Map<string, number>();
/** Returns the date `path` was last written, or null when it doesn't exist. */
async function writtenAt(path: string): Promise<number | null> {
try {
return (await Deno.stat(path)).mtime?.getTime() ?? 0;
} catch {
return null;
}
}
/**
* Makes `outPath` with `generate` unless it exists. Resolves false when it
* can't be made, because ffmpeg is missing or failed.
*
* With `srcPath`, the file it is made from, `outPath` takes that file's date,
* and is made again when the two differ: an upload replaced in place, as an
* avatar or a cover is, gets new copies without anyone deleting the old.
* Without it, the caller deletes `outPath` when its source changes.
*/
export async function ensureGenerated(
outPath: string,
generate: () => Promise<void>,
srcPath?: string,
): Promise<boolean> {
if (await Deno.stat(outPath).then(() => true, () => false)) return true;
const made = await writtenAt(outPath);
let version: number | null = null;
if (srcPath) {
version = await writtenAt(srcPath);
if (version === null) return false;
if (made === version) return true;
if (failed.get(outPath) === version) return false;
} else if (made !== null) {
return true;
}
if (!await ffmpegAvailable()) return false;
let pending = inFlight.get(outPath);
if (!pending) {
pending = generate().finally(() => inFlight.delete(outPath));
pending = generate()
.then(() =>
version === null
? undefined
: Deno.utime(outPath, new Date(), new Date(version))
)
.catch((err) => {
if (version !== null) failed.set(outPath, version);
throw err;
})
.finally(() => inFlight.delete(outPath));
inFlight.set(outPath, pending);
}
return await pending.then(() => true, () => false);
}
/**
* How old a temporary file must be for the sweep to take it: one being
* written, by a generation under way, is seconds old, and one a crash left
* is never finished.
*/
const ABANDONED_AFTER_MS = 60 * 60_000;
/** Yields the path of every file under `dir`, and nothing when it is missing. */
async function* filesUnder(dir: string): AsyncGenerator<string> {
let entries: Deno.DirEntry[];
try {
entries = await Array.fromAsync(Deno.readDir(dir));
} catch {
return;
}
for (const entry of entries) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) yield* filesUnder(path);
else if (entry.isFile) yield path;
}
}
/**
* Deletes the generated files nothing serves any more, and returns how many:
* the scaled copies of an upload that is gone, which the services' own
* removals missed or never made, as for a user's avatar; the previews of
* image dumps under their former name, `<id>.preview.jpg` or `.png`, which
* the scaled copies replaced; and the temporary files a generation left
* unfinished. Nothing else of `THUMBNAILS_DIR` is touched, the custom
* thumbnails kept there being uploads.
*/
export async function sweepGeneratedMedia(): Promise<number> {
let removed = 0;
const remove = (path: string) =>
Deno.remove(path).then(() => {
removed++;
}, () => {});
const abandoned = async (path: string) => {
const at = await writtenAt(path);
return at !== null && Date.now() - at > ABANDONED_AFTER_MS;
};
for await (const path of filesUnder(THUMBNAILS_DIR)) {
if (
/\.preview\.(jpg|png)$/.test(path) ||
(path.endsWith(".tmp") && await abandoned(path))
) await remove(path);
}
for await (const path of filesUnder(SCALED_DIR)) {
// A copy is named after its upload's path: see scaledImagePath.
const copy = relative(SCALED_DIR, path).match(
/^(.+)\.w\d+\.(?:webp|jpg|png)$/,
);
const unused = copy
? await writtenAt(`${UPLOADS_DIR}/${copy[1]}`) === null
: await abandoned(path);
if (unused) await remove(path);
}
return removed;
}
/**
* Runs {@link sweepGeneratedMedia} a minute after the server starts, which
* clears what a new version no longer uses as soon as it is deployed, then
* once a day. `main.ts` starts it.
*/
export const generatedMediaSweepJob: Job = {
name: "media-sweep",
everyMs: 24 * 60 * 60_000,
firstAfterMs: 60_000,
async run() {
const removed = await sweepGeneratedMedia();
if (removed > 0) {
console.log(`[media-sweep] removed ${removed} generated files unused`);
}
},
};

View File

@@ -1,6 +1,7 @@
/**
* Checks the images users upload and serves them back. The avatar, playlist
* cover, dump thumbnail and attachment routes call it.
* Checks the images users upload and serves them back, scaled down to the
* width a request names. The avatar, playlist cover, dump thumbnail and
* attachment routes call it.
*
* @module
*/
@@ -10,6 +11,14 @@ import { APIException } from "./api-exception.ts";
import { serveFile } from "./serve-file.ts";
import { dirname } from "node:path";
import { MAX_IMAGE_SIZE_BYTES } from "../config.ts";
import { type ImageWidth, parseImageWidth } from "../../shared/image.ts";
import {
ensureGenerated,
type ImageTraits,
type ScaledFormat,
scaledImagePath,
scaleImage,
} from "./ffmpeg.ts";
/**
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
@@ -62,10 +71,176 @@ export function validateImageUpload(data: Uint8Array): string {
}
/**
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
* and only by the browser when `isPrivate`. It answers 404 and resolves false
* when the file is missing. The caller checks first that the viewer may see
* it.
* Returns the EXIF orientation of a JPEG, 1 to 8, from the start of its
* bytes, and 1, upright, when it states none. A phone stores a photo as its
* sensor saw it and says here how to turn it.
*/
function jpegOrientation(data: Uint8Array): number {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
// The segments follow the two-byte SOI, each a marker and its length.
let at = 2;
while (at + 4 <= data.length && data[at] === 0xFF) {
const marker = data[at + 1];
const length = view.getUint16(at + 2);
// APP1 holding "Exif\0\0", then a TIFF header and its first directory.
if (marker === 0xE1 && view.getUint32(at + 4) === 0x45786966) {
const tiff = at + 10;
if (tiff + 8 > data.length) return 1;
const little = view.getUint16(tiff) === 0x4949;
const ifd = tiff + view.getUint32(tiff + 4, little);
if (ifd + 2 > data.length) return 1;
const entries = view.getUint16(ifd, little);
for (let i = 0; i < entries; i++) {
const entry = ifd + 2 + i * 12;
if (entry + 12 > data.length) return 1;
if (view.getUint16(entry, little) === 0x0112) {
const value = view.getUint16(entry + 8, little);
return value >= 1 && value <= 8 ? value : 1;
}
}
return 1;
}
// The image data starts: no EXIF came before it.
if (marker === 0xDA) return 1;
at += 2 + length;
}
return 1;
}
/**
* How much of a file {@link scalableImage} needs: the EXIF segment, which
* comes first in a JPEG, is 64 KB at most.
*/
const IMAGE_HEAD_BYTES = 64 * 1024 + 32;
/** Returned by {@link scalableImage}, for `scaleImage`. */
export interface ScalableImage extends ImageTraits {
/**
* The format of its copy for a reader without WebP: a JPEG, or a PNG for
* an image that may be transparent, which a JPEG would fill with black.
*/
compat: "jpg" | "png";
}
/**
* Returns how to scale the image whose first {@link IMAGE_HEAD_BYTES} are
* `head`, or null when it is to be served as it is: an animated WebP, which
* ffmpeg can't read, or anything but a JPEG, PNG, GIF, WebP or BMP.
*/
export function scalableImage(head: Uint8Array): ScalableImage | null {
const mime = detectImageMime(head);
// A file dump may be a BMP ("BM"), which no upload route here accepts.
if (!mime) {
return head[0] === 0x42 && head[1] === 0x4D
? { orientation: 1, animated: false, compat: "jpg" }
: null;
}
// "RIFF....WEBPVP8X", whose flags then say whether frames follow.
const animatedWebp = mime === "image/webp" && head[15] === 0x58 &&
(head[20] & 0x02) !== 0;
if (animatedWebp) return null;
return mime === "image/jpeg"
? { orientation: jpegOrientation(head), animated: false, compat: "jpg" }
: { orientation: 1, animated: mime === "image/gif", compat: "png" };
}
/** Reads the first {@link IMAGE_HEAD_BYTES} of the file at `path`. */
async function readHead(path: string): Promise<Uint8Array | null> {
let file: Deno.FsFile;
try {
file = await Deno.open(path, { read: true });
} catch {
return null;
}
try {
const head = new Uint8Array(IMAGE_HEAD_BYTES);
let filled = 0;
while (filled < head.length) {
const n = await file.read(head.subarray(filled));
if (n === null) break;
filled += n;
}
return head.subarray(0, filled);
} finally {
file.close();
}
}
/** The type each format of a scaled copy is served as. */
const SCALED_MIMES: Record<ScaledFormat, string> = {
webp: "image/webp",
jpg: "image/jpeg",
png: "image/png",
};
/**
* Returns the copy of the image at `path` scaled to `width`, made now unless
* it was already, as its path and type, or null when there is none to serve:
* the image can't be scaled ({@link scalableImage}), or ffmpeg is missing or
* failed on it. The copy is a WebP, or with `compat` the JPEG or PNG a reader
* without WebP takes.
*/
async function scaledCopy(
path: string,
width: ImageWidth,
compat: boolean,
): Promise<{ path: string; mime: string } | null> {
// The other format depends on the image, so it is read before the copy is
// looked for; a WebP's path is known without.
let how = compat ? await describe(path) : undefined;
if (how === null) return null;
const format: ScaledFormat = how ? how.compat : "webp";
const scaledPath = scaledImagePath(path, width, format);
const made = await ensureGenerated(scaledPath, async () => {
how ??= await describe(path);
if (!how) throw new Error("Not an image that can be scaled");
await scaleImage(path, scaledPath, width, how, format);
}, path);
return made ? { path: scaledPath, mime: SCALED_MIMES[format] } : null;
}
/** Returns how to scale the image at `path`: see {@link scalableImage}. */
async function describe(path: string): Promise<ScalableImage | null> {
const head = await readHead(path);
return head && scalableImage(head);
}
/**
* Sends the image at `path`, of type `mime`, with `cacheControl`: a WebP copy
* at most as wide as the request's `?w=` names (`shared/image.ts`), or as
* `defaultWidth` without one, and the image as it is when the request names
* no width or the copy can't be made. With `?f=compat`, the copy is a JPEG,
* or a PNG for an image that may be transparent, which the digest's emails
* ask for, since not every mail client reads WebP. Every route that answers
* one of the site's own images ends here, so each takes both. It resolves
* false, having sent nothing, when the file is missing.
*/
export async function serveImage(
ctx: Context,
path: string,
mime: string,
cacheControl: string,
defaultWidth?: ImageWidth,
): Promise<boolean> {
// The browser must take the checked type, never guess another from the
// bytes.
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const width = parseImageWidth(ctx.request.url.searchParams.get("w")) ??
defaultWidth;
const compat = ctx.request.url.searchParams.get("f") === "compat";
const scaled = width ? await scaledCopy(path, width, compat) : null;
if (
scaled &&
await serveFile(ctx, scaled.path, { mime: scaled.mime, cacheControl })
) return true;
return await serveFile(ctx, path, { mime, cacheControl });
}
/**
* Serves an uploaded image inline as `mime`, or scaled down as
* {@link serveImage} does, cached for `cacheMaxAge` seconds, and only by the
* browser when `isPrivate`. It answers 404 and resolves false when the file
* is missing. The caller checks first that the viewer may see it.
*/
export async function serveUploadedFile(
ctx: Context,
@@ -73,16 +248,18 @@ export async function serveUploadedFile(
mime: string,
cacheMaxAge = 3600,
isPrivate = false,
defaultWidth?: ImageWidth,
): Promise<boolean> {
ctx.response.headers.set("Content-Disposition", "inline");
// The browser must take the checked type, never guess another from the
// bytes, and a frontend on another origin may still show the image.
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
// A frontend on another origin may still show the image.
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
const served = await serveFile(ctx, filePath, {
const served = await serveImage(
ctx,
filePath,
mime,
cacheControl: `${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
});
`${isPrivate ? "private" : "public"}, max-age=${cacheMaxAge}`,
defaultWidth,
);
if (!served) ctx.response.status = 404;
return served;
}

View File

@@ -191,7 +191,7 @@ const RECIPES: Record<StyleName, Recipe> = {
},
},
melange: {
webFontAxes: "opsz,wght@9..144,400;9..144,600;9..144,700",
webFontAxes: "wght@400;600",
scale: 1,
avatarRadius: 16,
title: { size: 32, weight: 600 },

View File

@@ -3,7 +3,8 @@
* jobs and listens on {@link LISTEN_HOST}:{@link PORT}, having first signed
* the users out of other instances if they may no longer sign in there
* (`FEDERATION.outgoing`). A request goes
* through the error middleware, the OG tags and CORS, then the routers, one
* through the error middleware, the compression of what is answered, the OG
* tags and CORS, then the routers, one
* per resource of the API (`API.md` lists them), and last the built frontend,
* which answers every path no router did.
*
@@ -46,11 +47,13 @@ import {
PORT,
PUBLIC_URL,
} from "./config.ts";
import { compressMiddleware } from "./middleware/compress.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts";
import { startJobs } from "./lib/scheduler.ts";
import { rateLimitSweepJob } from "./lib/rate-limit.ts";
import { generatedMediaSweepJob } from "./lib/ffmpeg.ts";
import { digestJob } from "./services/digest-service.ts";
import { communityJob } from "./services/community-service.ts";
import { pingJob, typingExpiryJob } from "./services/ws-service.ts";
@@ -68,6 +71,7 @@ const app: Application = new Application();
const cors = oakCors({ origin: ALLOWED_ORIGINS });
app.use(errorMiddleware);
app.use(compressMiddleware);
app.use(ogMiddleware);
// oakCors calls next() without awaiting it when there is no origin, which
// corrupts the response in Oak's async chain. So it only sees requests from an
@@ -138,6 +142,7 @@ if (import.meta.main) {
pingJob,
typingExpiryJob,
rateLimitSweepJob,
generatedMediaSweepJob,
outboxJob,
]);
await app.listen({ hostname: LISTEN_HOST, port: PORT });

108
api/middleware/compress.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Compresses what the routes answer in JSON, and the HTML page, for the
* browsers that accept it: a page of the feed is some twenty kilobytes as it
* is, and a fifth of that compressed. main.ts installs it after the error
* middleware, around everything that sets a body. The built frontend's files
* aren't its concern: lib/static.ts sends the copies compressed at build time.
*
* @module
*/
import type { Context, Next } from "@oak/oak";
import { promisify } from "node:util";
import { brotliCompress, constants as zlib, gzip } from "node:zlib";
const brotli = promisify(brotliCompress);
const gz = promisify(gzip);
/**
* Below this, the bytes saved aren't worth the work; it is the threshold the
* build compresses the frontend's files from (vite.config.ts).
*/
const MIN_BYTES = 1024;
/**
* Brotli's quality for a response made on the spot. Past 5 it takes several
* times longer for a few percent; the frontend's files, compressed once, are
* at 11.
*/
const BROTLI_QUALITY = 5;
/**
* Returns the coding to answer with for an `Accept-Encoding` header, brotli
* before gzip, or null when the client takes neither. A coding refused with
* `q=0` isn't taken.
*/
function pickCoding(header: string | null): "br" | "gzip" | null {
if (!header) return null;
const accepted = new Set<string>();
for (const part of header.split(",")) {
const [name, ...params] = part.trim().toLowerCase().split(";");
const q = params.map((p) => p.trim().match(/^q=([\d.]+)$/)?.[1])
.find((v) => v !== undefined);
if (q === undefined || Number(q) > 0) accepted.add(name.trim());
}
return accepted.has("br") ? "br" : accepted.has("gzip") ? "gzip" : null;
}
/**
* Returns the text of a body this middleware compresses, or null for any
* other: an object or array, which Oak would send as JSON, or a string whose
* type its route has set. Files, streams and bytes are left alone, as they
* are images, media and files compressed already.
*/
function textOf(ctx: Context): string | null {
const body = ctx.response.body;
if (typeof body === "string") {
// Oak guesses an untyped string's type from the string itself, which it
// couldn't from its compressed bytes.
return ctx.response.type || ctx.response.headers.has("Content-Type")
? body
: null;
}
if (body === null || typeof body !== "object") return null;
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && !Array.isArray(body)) return null;
ctx.response.type = "json";
return JSON.stringify(body);
}
/**
* Sends the response's JSON or text compressed, in brotli or gzip as the
* request accepts, when it is at least {@link MIN_BYTES} long. A response
* already encoded, a `HEAD`, and one marked `no-transform` go out as they are.
*/
export async function compressMiddleware(
ctx: Context,
next: Next,
): Promise<void> {
await next();
const headers = ctx.response.headers;
if (
ctx.request.method === "HEAD" || headers.has("Content-Encoding") ||
headers.get("Cache-Control")?.includes("no-transform")
) return;
const text = textOf(ctx);
if (text === null) return;
const raw = new TextEncoder().encode(text);
if (raw.length < MIN_BYTES) return;
// A cache must not hand the compressed copy to a client that can't read it.
headers.append("Vary", "Accept-Encoding");
const coding = pickCoding(ctx.request.headers.get("Accept-Encoding"));
if (!coding) return;
const compressed = coding === "br"
? await brotli(raw, {
params: {
[zlib.BROTLI_PARAM_MODE]: zlib.BROTLI_MODE_TEXT,
[zlib.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
[zlib.BROTLI_PARAM_SIZE_HINT]: raw.length,
},
})
: await gz(raw);
headers.set("Content-Encoding", coding);
ctx.response.body = new Uint8Array(compressed);
}

View File

@@ -3,12 +3,15 @@
* http, which an https page would block, and those behind hotlink protection.
* It fetches and caches the images `GET /api/remote/image` serves, which the
* frontend routes a thumbnail through when it is on http or when loading it
* directly failed.
* directly failed, and scales them down as the site's own are.
*
* @module
*/
import { fetchWithTimeout, readBytesCapped } from "./fetch.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { scaleImageBytes } from "../lib/ffmpeg.ts";
import { scalableImage } from "../lib/upload.ts";
/**
* Returned to `GET /api/remote/image` by {@link fetchProxiedImage}, which sends
@@ -34,9 +37,9 @@ const PROXYABLE_IMAGE_TYPES = new Set([
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
/**
* Proxied images, by URL, since every viewer of a feed asks for the same
* thumbnails. The least recently used go first, and the Map's order is that
* of use.
* Proxied images, by URL and by the width they were scaled to, since every
* viewer of a feed asks for the same thumbnails. The least recently used go
* first, and the Map's order is that of use.
*/
const imageCache = new Map<
string,
@@ -89,15 +92,33 @@ export function clearImageCache(): void {
/**
* Returns the image at `url`, from the cache or else from its host; null when
* the host answers with an error, or with anything but a raster image of 5 MB
* at most. The route
* checks `url` with `isValidHttpUrl` first.
* at most. With `width`, it comes as a WebP copy scaled down to it, or as it
* is when no copy can be made (`scalableImage`, and ffmpeg). The route checks
* `url` with `isValidHttpUrl` first.
*
* @throws {Error} When the host can't be reached, as
* {@link fetchWithTimeout} reports it.
*/
export async function fetchProxiedImage(
url: string,
width?: ImageWidth | null,
): Promise<ProxiedImage | null> {
if (!width) return await fetchOriginal(url);
const key = `${url}#w${width}`;
const cached = readImageCache(key);
if (cached) return cached;
const original = await fetchOriginal(url);
if (!original) return null;
const how = scalableImage(original.bytes);
const bytes = how && await scaleImageBytes(original.bytes, width, how);
if (!bytes) return original;
writeImageCache(key, bytes, "image/webp");
return { bytes, contentType: "image/webp" };
}
/** Returns the image at `url` as its host sends it: see above. */
async function fetchOriginal(url: string): Promise<ProxiedImage | null> {
const cached = readImageCache(url);
if (cached) return cached;

View File

@@ -4,9 +4,10 @@
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id.
* - `GET /api/attachments/:attachmentId?token=` — public, but an image in a
* private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image.
* - `GET /api/attachments/:attachmentId?w=&token=` — public, but an image in
* a private dump or playlist, or in a comment on a private dump, needs its
* owner's media `token`. Serves the image, scaled down to `w`
* (`shared/image.ts`) when it names one.
*
* @module
*/

View File

@@ -1,14 +1,14 @@
/**
* Stores and serves the users' avatars. The profile page uploads the avatar,
* and the client's `Avatar` component loads it, with the user's `updatedAt` as
* a cache-busting version.
* and the client's `Avatar` component loads it at the size it shows, with the
* user's `updatedAt` as a cache-busting version.
*
* - `PUT /api/me/avatar` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
* caller's user. 400 for an account from another instance, whose avatar
* comes from there. The instances the caller signed in to are told.
* - `GET /api/users/:key/avatar` — public. Serves a user's avatar; 404 without
* one.
* - `GET /api/users/:key/avatar?w=` — public. Serves a user's avatar, scaled
* down to `w` (`shared/image.ts`) when it names one; 404 without one.
*
* @module
*/

View File

@@ -1,20 +1,25 @@
/**
* Serves the figures of the client's `/community` page, which
* `community-service.ts` recomputes on a timer.
* `community-service.ts` recomputes on a timer, and the community feed.
*
* - `GET /api/community?period=weekly|monthly` — signed in. Answers the
* site's figures over the last week (by default) or month.
* - `GET /api/community/feed` — signed in. Answers the latest things done in
* public and the week's figures.
*
* @module
*/
import { Router } from "@oak/oak";
import type { APIResponse } from "../../shared/api.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type { CommunityFeed, CommunityStats } from "../../shared/community.ts";
import { isDigestCadence } from "../../shared/settings.ts";
import { APIException } from "../lib/api-exception.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { getCommunityStats } from "../services/community-service.ts";
import {
getCommunityFeed,
getCommunityStats,
} from "../services/community-service.ts";
/**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
@@ -34,4 +39,12 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
ctx.response.body = responseBody;
});
router.get("/feed", authMiddleware, (ctx: AuthContext) => {
const responseBody: APIResponse<CommunityFeed> = {
success: true,
data: getCommunityFeed(),
};
ctx.response.body = responseBody;
});
export default router;

View File

@@ -26,9 +26,9 @@
* or with `playlist:moderate`. Removes a dump.
* - `PUT /api/playlists/:playlistId/cover` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/cover?token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, or 404s
* without one.
* - `GET /api/playlists/:playlistId/cover?w=&token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover, scaled
* down to `w` (`shared/image.ts`) when it names one, or 404s without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it.
*

View File

@@ -6,9 +6,9 @@
*
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
* and whether its page could be reached.
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
* through this site; 400 when it isn't one, 502 when its host can't be
* reached.
* - `GET /api/remote/image?url=&w=` — public. Serves a remote raster image
* through this site, scaled down to `w` (`shared/image.ts`) when it names
* one; 400 when it isn't an image, 502 when its host can't be reached.
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
* page's tracks and their stream URLs, for the native player; `force=1`
* skips the server's cache.
@@ -19,6 +19,7 @@
*/
import { Router } from "@oak/oak";
import { parseImageWidth } from "../../shared/image.ts";
import { isValidHttpUrl } from "../remote/fetch.ts";
import { fetchProxiedImage } from "../remote/image-proxy.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
@@ -88,7 +89,10 @@ router.get("/image", async (ctx) => {
}
try {
const image = await fetchProxiedImage(url);
const image = await fetchProxiedImage(
url,
parseImageWidth(ctx.request.url.searchParams.get("w")),
);
if (!image) {
ctx.response.status = 400;
return;

View File

@@ -5,11 +5,12 @@
* from an upload are generated on first request by lib/ffmpeg.ts, which keeps
* them.
*
* - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
* - `GET /api/dumps/:dumpId/thumbnail?v=&w=&token=` — public; a private
* dump's thumbnail needs its poster's media `token`. Serves the dump's
* custom thumbnail, else one made from its upload: a still of a video, or
* its image. An image comes scaled down to `w` (`shared/image.ts`), or to
* the widest of those widths without one, and as it is when no copy can be
* made; 404 when there is neither.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
@@ -32,18 +33,14 @@ import {
import { serveFile } from "../lib/serve-file.ts";
import {
replaceUploadedFile,
serveImage,
serveUploadedFile,
validateImageUpload,
} from "../lib/upload.ts";
import { DUMPS_DIR } from "../config.ts";
import { MAX_IMAGE_WIDTH } from "../../shared/image.ts";
import { resolveMediaDump } from "../lib/dump-media.ts";
import {
ensureGenerated,
generatedPaths,
imagePreview,
PREVIEWABLE_IMAGES,
videoStill,
} from "../lib/ffmpeg.ts";
import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.ts";
import { can } from "../lib/permissions.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
@@ -69,6 +66,7 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
dump.thumbnailMime,
0,
dump.isPrivate,
MAX_IMAGE_WIDTH,
);
if (served) return;
}
@@ -92,29 +90,12 @@ router.get("/:dumpId/thumbnail", async (ctx) => {
) return;
}
if (mime.startsWith("image/")) {
// A scaled-down copy for the feed; the original when there's none to make
// (a GIF, no ffmpeg, an image ffmpeg can't read).
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
const ext = PREVIEWABLE_IMAGES[mime];
if (ext) {
const previewPath = generated.preview[ext];
if (
await ensureGenerated(
previewPath,
() => imagePreview(srcPath, previewPath),
) &&
await serveFile(ctx, previewPath, {
mime: ext === "png" ? "image/png" : "image/jpeg",
cacheControl: generatedCache,
})
) return;
}
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache })
) return;
}
// A scaled-down copy for the feed; the original when there's none to make
// (no ffmpeg, an image it can't read).
if (
/^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) &&
await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH)
) return;
throw new APIException(404, "No thumbnail for this dump");
});

View File

@@ -5,18 +5,28 @@
* the month ending at the last refresh, which {@link communityJob} redoes
* every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query.
*
* It also gathers the community feed ({@link getCommunityFeed}), the latest
* things done in public, which is read from the tables on each request: it
* has to show a dump the moment it is posted.
*
* @module
*/
import { db } from "../db/connection.ts";
import { COMMUNITY_REFRESH_MINUTES } from "../config.ts";
import type { CommunityStats } from "../../shared/community.ts";
import type {
CommunityEvent,
CommunityFeed,
CommunityItem,
CommunityStats,
} from "../../shared/community.ts";
import {
DIGEST_CADENCES,
type DigestCadence,
PERIOD_MS,
} from "../../shared/settings.ts";
import type { Job } from "../lib/scheduler.ts";
import { parseRows } from "../model/rows.ts";
import { listTopDumps } from "./dump-service.ts";
import { listNewPublicPlaylists } from "./playlist-service.ts";
import { listNewUsers } from "./user-service.ts";
@@ -91,3 +101,148 @@ export const communityJob: Job = {
firstAfterMs: 0,
run: () => refreshCommunityStats(),
};
/** How many events the feed holds, and how many of each kind it reads. */
const FEED_MAX = 12;
/**
* A row of {@link listCommunityEvents}'s query, whose five parts share these
* columns. `title`, `slug` and `owner_username` name the dump or the playlist
* of every event but a `user_joined`.
*/
interface EventRow {
type: CommunityEvent["type"];
/** The dump, the playlist or the new user. */
subject_id: string;
at: string;
username: string;
title: string | null;
slug: string | null;
owner_username: string | null;
comment_id: string | null;
invited_by: string | null;
}
/** Checks `row`'s columns that every event has. */
function isEventRow(row: unknown): row is EventRow {
const r = row as Partial<EventRow> | null;
return !!r && typeof r.type === "string" &&
typeof r.subject_id === "string" && typeof r.at === "string" &&
typeof r.username === "string" &&
(r.type === "user_joined" ||
(typeof r.title === "string" && typeof r.slug === "string" &&
typeof r.owner_username === "string"));
}
function eventOf(row: EventRow): CommunityEvent {
const base = {
id: `${row.type}:${row.subject_id}`,
at: new Date(row.at),
username: row.username,
};
// `isEventRow` has checked the three on every row that reads `item`.
const item: CommunityItem = {
title: row.title!,
slug: row.slug!,
ownerUsername: row.owner_username!,
};
switch (row.type) {
case "dump_posted":
case "dump_upvoted":
return { ...base, type: row.type, dump: item };
case "dump_commented":
return {
...base,
type: row.type,
dump: item,
commentId: row.comment_id!,
};
case "playlist_created":
return { ...base, type: row.type, playlist: item };
case "user_joined":
return {
...base,
type: row.type,
invitedByUsername: row.invited_by ?? undefined,
};
}
}
/**
* Returns the latest things done in public, newest first, {@link FEED_MAX} at
* most: the public dumps posted and playlists created, the comments and
* upvotes on public dumps, and who joined. A dump has one comment event and
* one upvote event at most, its latest, so that a thread or a popular dump
* doesn't fill the feed.
*
* Each part reads its {@link FEED_MAX} latest, which is all the whole may
* keep of it. The comments and the upvotes are grouped by dump first, and
* with `MAX()` SQLite fills a group's other columns from the row that holds
* the maximum: the latest comment's author and id, the latest voter.
*
* @throws {APIException} 500 when a row doesn't parse as an event.
*/
export function listCommunityEvents(): CommunityEvent[] {
const rows = db.prepare(
`SELECT * FROM (
SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at,
u.username AS username, d.title AS title, d.slug AS slug,
u.username AS owner_username, NULL AS comment_id,
NULL AS invited_by
FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0
ORDER BY d.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'playlist_created', p.id, p.created_at, u.username, p.title,
p.slug, u.username, NULL, NULL
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username,
d.title, d.slug, ou.username, c.id, NULL
FROM comments c
JOIN dumps d ON d.id = c.dump_id
JOIN users cu ON cu.id = c.user_id
JOIN users ou ON ou.id = d.user_id
WHERE c.deleted = 0 AND d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username,
d.title, d.slug, ou.username, NULL, NULL
FROM votes v
JOIN dumps d ON d.id = v.dump_id
JOIN users vu ON vu.id = v.user_id
JOIN users ou ON ou.id = d.user_id
WHERE d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL,
NULL, i.username
FROM users u LEFT JOIN users i ON i.id = u.invited_by
ORDER BY u.created_at DESC LIMIT ${FEED_MAX})
ORDER BY at DESC LIMIT ${FEED_MAX};`,
).all();
return parseRows(rows, isEventRow, eventOf);
}
/**
* Returns the community feed for `GET /api/community/feed`: the latest
* events, read now, and the last week's figures as of the last refresh.
*/
export function getCommunityFeed(): CommunityFeed {
const { totals, chatMessages, top } = getCommunityStats("weekly");
const [best] = top;
return {
events: listCommunityEvents(),
week: {
totals,
chatMessages,
top: best && {
title: best.title,
slug: best.slug,
ownerUsername: best.ownerUsername,
},
},
};
}

View File

@@ -25,6 +25,7 @@ import {
} from "../../shared/settings.ts";
import type { CommunityStats } from "../../shared/community.ts";
import { type Dump, dumpFileVersion } from "../../shared/dump.ts";
import type { ImageWidth } from "../../shared/image.ts";
import { siteHost } from "../../shared/url.ts";
import { markdownToPlainText } from "../../shared/markdown.ts";
import {
@@ -101,11 +102,19 @@ function quoteOf(markdown: string | undefined | null): string | undefined {
: flat;
}
/**
* The width the emails' thumbnails are asked at: the widest an email shows
* one, on a dense screen.
*/
const EMAIL_IMAGE_WIDTH: ImageWidth = 640;
/**
* Returns the image a mail client can load for `dump`, which must be public:
* the thumbnails route for a custom thumbnail or an image or video upload,
* else the link preview's image, through the image proxy when it is plain
* http, which a mail client may refuse.
* scaled to the email's width as a JPEG or PNG (`f=compat`), since not every
* mail client reads the WebP a browser gets; else the link preview's image,
* through the image proxy when it is plain http, which a mail client may
* refuse.
*/
function thumbnailOf(dump: Dump): string | null {
const isMedia = dump.kind === "file" &&
@@ -113,7 +122,7 @@ function thumbnailOf(dump: Dump): string | null {
if (dump.thumbnailMime || isMedia) {
return `${PUBLIC_URL}/api/dumps/${dump.id}/thumbnail?v=${
dumpFileVersion(dump)
}`;
}&w=${EMAIL_IMAGE_WIDTH}&f=compat`;
}
const thumbnailUrl = dump.richContent?.thumbnailUrl;
if (typeof thumbnailUrl !== "string") return null;

View File

@@ -45,7 +45,7 @@ import {
} from "./notification-service.ts";
import { changeSlug, newSlug, resolveSlug } from "./slug-service.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { removeGeneratedMedia } from "../lib/ffmpeg.ts";
import { removeGeneratedMedia, removeScaledImages } from "../lib/ffmpeg.ts";
import {
DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES,
@@ -854,6 +854,13 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
return updatedDump;
}
/** Deletes the file of dump `dumpId`'s custom thumbnail, and its copies. */
async function removeCustomThumbnail(dumpId: string): Promise<void> {
const path = customThumbnailPath(dumpId);
await Deno.remove(path).catch(() => {});
await removeScaledImages(path);
}
/**
* Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
@@ -864,7 +871,7 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId);
db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`)
.run(dump.id);
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
await removeCustomThumbnail(dump.id);
const updatedDump: Dump = { ...dump, thumbnailMime: undefined };
announceDumpChange(dump, updatedDump);
return updatedDump;
@@ -891,9 +898,7 @@ export async function deleteDump(dumpId: string): Promise<void> {
await Deno.remove(`${DUMPS_DIR}/${dump.id}`).catch(() => {});
await removeGeneratedMedia(dump.id);
}
if (dump.thumbnailMime) {
await Deno.remove(customThumbnailPath(dump.id)).catch(() => {});
}
if (dump.thumbnailMime) await removeCustomThumbnail(dump.id);
announceDumpChange(dump, null, holders);
}

View File

@@ -388,6 +388,60 @@ export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
(c) => c.charCodeAt(0),
);
/** Whether ffmpeg runs here; the tests of what it makes are skipped without. */
export async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
/**
* Makes a real image with ffmpeg, a `width`×`height` field of `color`, as a
* PNG, or as a GIF of three frames.
*/
export async function makeImage(
width: number,
height: number,
color = "red",
format: "png" | "gif" = "png",
): Promise<Uint8Array<ArrayBuffer>> {
const { stdout } = await new Deno.Command("ffmpeg", {
args: [
"-f",
"lavfi",
"-i",
`color=c=${color}:size=${width}x${height}:rate=3`,
"-frames:v",
format === "gif" ? "3" : "1",
"-f",
format === "gif" ? "gif" : "image2pipe",
...(format === "gif" ? [] : ["-c:v", "png"]),
"-",
],
stdout: "piped",
stderr: "null",
}).output();
return new Uint8Array(stdout);
}
/** Returns the width, in pixels, a WebP file's header states. */
export function webpWidth(bytes: Uint8Array): number {
const kind = new TextDecoder().decode(bytes.subarray(12, 16));
// Lossy: the frame's start code, then 14 bits of width.
if (kind === "VP8 ") return (bytes[26] | (bytes[27] << 8)) & 0x3FFF;
// Lossless: a signature byte, then 14 bits of width minus one.
if (kind === "VP8L") return ((bytes[21] | (bytes[22] << 8)) & 0x3FFF) + 1;
// Extended, as an animation or an image with transparency: the canvas's
// width minus one, on 24 bits.
return (bytes[24] | (bytes[25] << 8) | (bytes[26] << 16)) + 1;
}
export function formWithFile(
bytes: Uint8Array<ArrayBuffer>,
name = "file.png",

View File

@@ -0,0 +1,62 @@
import { TEST_DIR } from "../setup.ts";
import { assertEquals } from "@std/assert";
import { sweepGeneratedMedia } from "../../lib/ffmpeg.ts";
const UPLOADS = `${TEST_DIR}/uploads`;
async function write(path: string, ageMs = 0): Promise<void> {
const full = `${UPLOADS}/${path}`;
await Deno.mkdir(full.slice(0, full.lastIndexOf("/")), { recursive: true });
await Deno.writeTextFile(full, "x");
if (ageMs > 0) {
const at = new Date(Date.now() - ageMs);
await Deno.utime(full, at, at);
}
}
const exists = (path: string) =>
Deno.stat(`${UPLOADS}/${path}`).then(() => true, () => false);
Deno.test("sweepGeneratedMedia: takes the copies of gone uploads, the old previews and abandoned temporary files", async () => {
const HOURS = 60 * 60_000;
const kept = [
// An upload and its copies, in each format.
"avatars/u1",
"scaled/avatars/u1.w128.webp",
"scaled/avatars/u1.w640.png",
// A custom thumbnail is an upload; a still and a waveform go with their
// dump, not here.
"thumbnails/d1-custom",
"scaled/thumbnails/d1-custom.w960.webp",
"thumbnails/d1.jpg",
"thumbnails/d1.waveform.json",
// Being written right now.
"scaled/avatars/u1.w320.webp.1234.tmp",
"scaled/tmp/fresh",
"thumbnails/d1.jpg.1234.tmp",
];
const swept = [
// Their upload is gone.
"scaled/avatars/u2.w128.webp",
"scaled/dumps/d2.w960.jpg",
// The name the previews of image dumps had.
"thumbnails/d1.preview.jpg",
"thumbnails/d3.preview.png",
];
const abandoned = [
"scaled/avatars/u1.w960.webp.5678.tmp",
"scaled/tmp/stale",
"scaled/tmp/stale.webp",
"thumbnails/d1.waveform.json.5678.tmp",
];
for (const path of [...kept, ...swept]) await write(path);
for (const path of abandoned) await write(path, 2 * HOURS);
assertEquals(await sweepGeneratedMedia(), swept.length + abandoned.length);
for (const path of kept) assertEquals(await exists(path), true, path);
for (const path of [...swept, ...abandoned]) {
assertEquals(await exists(path), false, path);
}
// Nothing left to take.
assertEquals(await sweepGeneratedMedia(), 0);
});

View File

@@ -1,7 +1,11 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import {
detectImageMime,
scalableImage,
validateImageUpload,
} from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
@@ -40,3 +44,77 @@ Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () =
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});
/** A JPEG's first bytes: SOI, then an EXIF segment stating `orientation`. */
function jpegWithOrientation(orientation: number, little: boolean): Uint8Array {
const u16 = (n: number) => little ? [n & 0xff, n >> 8] : [n >> 8, n & 0xff];
const u32 = (n: number) => little ? [...u16(n), 0, 0] : [0, 0, ...u16(n)];
const tiff = [
...(little ? [0x49, 0x49] : [0x4d, 0x4d]),
...u16(42),
...u32(8),
...u16(1),
...u16(0x0112),
...u16(3),
...u32(1),
...u16(orientation),
...[0, 0],
...u32(0),
];
const exif = [0x45, 0x78, 0x69, 0x66, 0, 0, ...tiff];
const length = exif.length + 2;
return new Uint8Array([
...[0xff, 0xd8],
// A JFIF segment first, as a camera's file may have.
...[0xff, 0xe0, 0x00, 0x04, 0x00, 0x00],
...[0xff, 0xe1, length >> 8, length & 0xff],
...exif,
...[0xff, 0xda],
]);
}
Deno.test("scalableImage: a JPEG's EXIF orientation, upright without one", () => {
const turn = (data: Uint8Array) => scalableImage(data)?.orientation;
assertEquals(turn(jpegWithOrientation(6, false)), 6);
assertEquals(turn(jpegWithOrientation(8, true)), 8);
assertEquals(turn(jpegWithOrientation(42, true)), 1);
assertEquals(turn(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2])), 1);
// Cut short of what it announces: upright, not an error.
assertEquals(turn(jpegWithOrientation(6, false).subarray(0, 20)), 1);
assertEquals(turn(PNG), 1);
});
Deno.test("scalableImage: an animated WebP and what isn't an image are served as they are", () => {
const webp = (flags: number) =>
new Uint8Array([
...new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8X"),
...[10, 0, 0, 0],
flags,
]);
assertEquals(scalableImage(webp(0x02)), null);
// A reader without WebP gets a PNG of what may be transparent, a JPEG of
// the rest.
const still = { orientation: 1, animated: false };
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
assertEquals(
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
{ ...still, compat: "png" },
);
assertEquals(scalableImage(PNG), { ...still, compat: "png" });
assertEquals(scalableImage(jpegWithOrientation(1, true)), {
...still,
compat: "jpg",
});
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
...still,
compat: "jpg",
});
// Only a GIF's frames are an animation to keep.
assertEquals(scalableImage(new TextEncoder().encode("GIF89a")), {
orientation: 1,
animated: true,
compat: "png",
});
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
assertEquals(scalableImage(new Uint8Array()), null);
});

View File

@@ -32,13 +32,14 @@ Deno.test("readTokens: a forced scheme's block overrides the theme's", () => {
Deno.test("readTokens: var() follows tokens, color-mix() is computed", () => {
const melange = readTokens("melange", "light");
// The spice starts with var(--color-accent) and drops its stops' positions.
// The spice holds var()s, the accent among them, and drops its stops'
// positions.
assertEquals(melange.stops("--mel-spice"), [
"#a06d00",
"#bc5c00",
"#c77b8b",
"#be79bb",
"#6e9b72",
"#ea6a45",
"#e2568f",
"#8262d8",
"#3a8fd4",
"#27a583",
]);
// 18% of the muted text over the card, as a badge shows on the site.
const smooth = readTokens("smooth", "light");

View File

@@ -0,0 +1,90 @@
import { BASE_URL } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { Application } from "@oak/oak";
import { brotliDecompressSync, gunzipSync } from "node:zlib";
import { compressMiddleware } from "../../middleware/compress.ts";
const BIG = { success: true, data: { items: Array(200).fill("dump title") } };
function makeApp(): Application {
const app = new Application();
app.use(compressMiddleware);
app.use((ctx) => {
const path = ctx.request.url.pathname;
if (path === "/big") ctx.response.body = BIG;
if (path === "/small") ctx.response.body = { success: true };
if (path === "/html") {
ctx.response.type = "text/html";
ctx.response.body = `<!doctype html>${"<p>gerbeur</p>".repeat(200)}`;
}
if (path === "/bytes") ctx.response.body = new Uint8Array(4096);
if (path === "/encoded") {
ctx.response.headers.set("Content-Encoding", "br");
ctx.response.type = "text/css";
ctx.response.body = "x".repeat(4096);
}
});
return app;
}
async function get(path: string, acceptEncoding?: string) {
const res = await makeApp().handle(
new Request(`${BASE_URL}${path}`, {
headers: acceptEncoding ? { "Accept-Encoding": acceptEncoding } : {},
}),
);
if (!res) throw new Error("no response");
return { res, bytes: new Uint8Array(await res.arrayBuffer()) };
}
const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
Deno.test("compressMiddleware: JSON goes out in brotli, or gzip for a client without it", async () => {
const br = await get("/big", "gzip, deflate, br");
assertEquals(br.res.headers.get("Content-Encoding"), "br");
assertEquals(
br.res.headers.get("Content-Type"),
"application/json; charset=UTF-8",
);
assertEquals(br.res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(brotliDecompressSync(br.bytes))), BIG);
const gzip = await get("/big", "gzip");
assertEquals(gzip.res.headers.get("Content-Encoding"), "gzip");
assertEquals(JSON.parse(text(gunzipSync(gzip.bytes))), BIG);
const refused = await get("/big", "br;q=0, gzip;q=0.5");
assertEquals(refused.res.headers.get("Content-Encoding"), "gzip");
});
Deno.test("compressMiddleware: a client that accepts no coding gets the JSON as it is", async () => {
for (const accept of [undefined, "identity", "br;q=0"]) {
const { res, bytes } = await get("/big", accept);
assertEquals(res.headers.get("Content-Encoding"), null);
// A cache still has to tell this answer from the compressed one.
assertEquals(res.headers.get("Vary"), "Accept-Encoding");
assertEquals(JSON.parse(text(bytes)), BIG);
}
});
Deno.test("compressMiddleware: the HTML page is compressed too", async () => {
const { res, bytes } = await get("/html", "br");
assertEquals(res.headers.get("Content-Encoding"), "br");
assertEquals(res.headers.get("Content-Type"), "text/html; charset=UTF-8");
assertEquals(text(brotliDecompressSync(bytes)).startsWith("<!doctype"), true);
});
Deno.test("compressMiddleware: short answers, bytes and encoded files are left alone", async () => {
const small = await get("/small", "br");
assertEquals(small.res.headers.get("Content-Encoding"), null);
assertEquals(small.res.headers.get("Vary"), null);
assertEquals(JSON.parse(text(small.bytes)), { success: true });
const bytes = await get("/bytes", "br");
assertEquals(bytes.res.headers.get("Content-Encoding"), null);
assertEquals(bytes.bytes.length, 4096);
const encoded = await get("/encoded", "br");
assertEquals(encoded.res.headers.get("Content-Encoding"), "br");
assertEquals(text(encoded.bytes), "x".repeat(4096));
});

View File

@@ -1,4 +1,14 @@
import { createUser, formWithFile, get, PNG, put, test } from "../helpers.ts";
import {
createUser,
formWithFile,
get,
hasFfmpeg,
makeImage,
PNG,
put,
test,
webpWidth,
} from "../helpers.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { assertEquals, assertFalse } from "@std/assert";
@@ -27,3 +37,53 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
400,
);
});
test("avatars: a still holding a second image is scaled to its first alone", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
// As a phone's photo carries a gain map after it, which ffmpeg reads as a
// second frame.
const photo = await makeImage(600, 400, "red");
const extra = await makeImage(150, 100, "white");
const both = new Uint8Array(photo.length + extra.length);
both.set(photo);
both.set(extra, photo.length);
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(both) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertFalse(new TextDecoder().decode(small.bytes).includes("ANMF"));
});
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
if (!await hasFfmpeg()) return;
const user = await createUser();
const red = await makeImage(600, 400, "red");
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(red) });
const small = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(small.headers.get("cache-control"), "public, max-age=3600");
assertEquals(webpWidth(small.bytes), 128);
// Never wider than the upload itself.
assertEquals(
webpWidth((await get(`/api/users/${user.id}/avatar?w=960`)).bytes),
600,
);
// Without a width, or with one that isn't a number, the upload goes out.
assertEquals((await get(`/api/users/${user.id}/avatar`)).bytes, red);
assertEquals((await get(`/api/users/${user.id}/avatar?w=big`)).bytes, red);
await put("/api/me/avatar", {
auth: user.auth,
form: formWithFile(await makeImage(600, 400, "blue")),
});
const again = await get(`/api/users/${user.id}/avatar?w=128`);
assertEquals(webpWidth(again.bytes), 128);
assertFalse(
again.bytes.length === small.bytes.length &&
again.bytes.every((b, i) => b === small.bytes[i]),
);
});

View File

@@ -1,6 +1,9 @@
import { createUser, db, get, insertDump, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import type { CommunityStats } from "../../../shared/community.ts";
import type {
CommunityFeed,
CommunityStats,
} from "../../../shared/community.ts";
import { refreshCommunityStats } from "../../services/community-service.ts";
function vote(dumpId: string, n: number) {
@@ -33,3 +36,25 @@ test("GET /api/community: signed in only, for a week or a month", async () => {
});
assertEquals(month.data.totals.dumps, 1);
});
test("GET /api/community/feed: signed in only, the latest events and the week's figures", async () => {
const ann = await createUser();
insertDump({
userId: ann.id,
title: "Hit",
createdAt: new Date(Date.now() - 1000).toISOString(),
});
refreshCommunityStats();
assertEquals((await get("/api/community/feed")).status, 401);
const feed = await get<CommunityFeed>("/api/community/feed", {
auth: ann.auth,
});
assertEquals(feed.status, 200);
assertEquals(feed.data.events.map((e) => e.type), [
"user_joined",
"dump_posted",
]);
assertEquals(feed.data.week.totals.dumps, 1);
assertEquals(feed.data.week.top, undefined);
});

View File

@@ -5,6 +5,7 @@ import {
del,
formWithFile,
get,
hasFfmpeg,
html,
insertCategory,
insertComment,
@@ -767,16 +768,27 @@ test("custom thumbnails: set, served, removed", async () => {
});
assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png");
// A scaled copy by default, as for an upload's own thumbnail; the image
// itself where no copy can be made.
const thumb = await get(`/api/dumps/${id}/thumbnail`);
assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
if (await hasFfmpeg()) {
assertEquals(thumb.headers.get("content-type"), "image/webp");
assertEquals(
readFile(`scaled/thumbnails/${id}-custom.w960.webp`) !== null,
true,
);
} else {
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
}
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
assertEquals(readFile(`scaled/thumbnails/${id}-custom.w960.webp`), null);
// A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/dumps/${id}/thumbnail`)).status, 404);
});

View File

@@ -1,4 +1,12 @@
import { get, html, PNG, stubNetwork } from "../helpers.ts";
import {
get,
hasFfmpeg,
html,
makeImage,
PNG,
stubNetwork,
webpWidth,
} from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { Tralbum, YouTubePlaylist } from "../../../shared/player.ts";
@@ -29,6 +37,25 @@ Deno.test("GET /api/remote/image: rasters only, fetched once for everyone", asyn
assertEquals(svg.status, 400);
});
Deno.test("GET /api/remote/image: `?w=` scales the image, fetched once for every width", async () => {
if (!await hasFfmpeg()) return;
const wide = await makeImage(800, 200);
using net = stubNetwork(() =>
new Response(wide, { headers: { "content-type": "image/png" } })
);
const path = `/api/remote/image?url=${
encodeURIComponent("https://cdn.example.com/wide.png")
}`;
const small = await get(`${path}&w=128`);
assertEquals(small.status, 200);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
assertEquals(webpWidth((await get(`${path}&w=320`)).bytes), 320);
assertEquals((await get(`${path}&w=128`)).bytes, small.bytes);
assertEquals((await get(path)).bytes, wide);
assertEquals(net.calls.length, 1);
});
Deno.test("GET /api/remote/image: an error answer is never served nor cached", async () => {
let failing = true;
using net = stubNetwork(() =>

View File

@@ -1,34 +1,24 @@
import {
createUser,
db,
del,
get,
hasFfmpeg,
insertDump,
makeImage,
PNG,
test,
TEST_DIR,
webpWidth,
} from "../helpers.ts";
import { assertEquals } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789");
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
function readDir(path: string): string[] {
try {
return [...Deno.readDirSync(path)].map((e) => e.name);
@@ -81,54 +71,87 @@ test("GET /api/dumps/:id/thumbnail: serves a cached video still, with privacy",
assertEquals((await get(`/api/dumps/nope/thumbnail`)).status, 400);
});
test("GET /api/dumps/:id/thumbnail: an image dump gets a preview, a GIF itself", async () => {
test("GET /api/dumps/:id/thumbnail: an image dump gets a scaled copy, or itself when none can be made", async () => {
const owner = await createUser();
const scaled = (id: string) =>
readDir(`${TEST_DIR}/uploads/scaled/dumps`).filter((n) => n.startsWith(id))
.sort();
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({
const broken = insertDump({
userId: owner.id,
fileName: "a.gif",
fileMime: "image/gif",
fileData: gif,
});
const res = await get(`/api/dumps/${animated.id}/thumbnail`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif);
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/gif");
assertEquals(fallback.bytes, gif);
assertEquals(scaled(broken.id), []);
// A real PNG is scaled into a preview, kept beside the video stills.
if (!await hasFfmpeg()) {
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
});
const itself = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(itself.headers.get("content-type"), "image/png");
assertEquals(itself.bytes, PNG);
return;
}
// A real image is scaled to the widest width without `w`, and to the width
// asked for with it, each copy kept under the upload's own path.
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
fileData: await makeImage(1400, 300),
});
const preview = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) {
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(photo.id)
),
[`${photo.id}.preview.png`],
);
}
const widest = await get(`/api/dumps/${photo.id}/thumbnail`);
assertEquals(widest.status, 200);
assertEquals(widest.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(widest.bytes), 960);
const small = await get(`/api/dumps/${photo.id}/thumbnail?w=128`);
assertEquals(small.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(small.bytes), 128);
// A width between two of the list's takes the one above.
const between = await get(`/api/dumps/${photo.id}/thumbnail?w=200`);
assertEquals(webpWidth(between.bytes), 320);
assertEquals(scaled(photo.id), [
`${photo.id}.w128.webp`,
`${photo.id}.w320.webp`,
`${photo.id}.w960.webp`,
]);
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const broken = insertDump({
// A reader without WebP, as a mail client, asks for a PNG or a JPEG.
const compat = await get(`/api/dumps/${photo.id}/thumbnail?w=640&f=compat`);
assertEquals(compat.status, 200);
assertEquals(compat.headers.get("content-type"), "image/png");
// Its header states the width, on four bytes from the sixteenth.
assertEquals(new DataView(compat.bytes.buffer).getUint32(16), 640);
assertEquals(scaled(photo.id).includes(`${photo.id}.w640.png`), true);
// An animation keeps its frames.
const animated = insertDump({
userId: owner.id,
fileName: "b.png",
fileMime: "image/png",
fileData: BYTES,
fileName: "anim.gif",
fileMime: "image/gif",
fileData: await makeImage(400, 300, "blue", "gif"),
});
const fallback = await get(`/api/dumps/${broken.id}/thumbnail`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);
const moving = await get(`/api/dumps/${animated.id}/thumbnail?w=128`);
assertEquals(moving.headers.get("content-type"), "image/webp");
assertEquals(webpWidth(moving.bytes), 128);
assertEquals(new TextDecoder().decode(moving.bytes).includes("ANMF"), true);
// The copies go with the dump.
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(broken.id)
),
[],
(await del(`/api/dumps/${photo.id}`, { auth: owner.auth })).status,
200,
);
assertEquals(scaled(photo.id), []);
});

View File

@@ -9,7 +9,9 @@ import {
import { assertEquals } from "@std/assert";
import {
communityStatsOf,
getCommunityFeed,
getCommunityStats,
listCommunityEvents,
refreshCommunityStats,
} from "../../services/community-service.ts";
@@ -102,3 +104,109 @@ test("getCommunityStats: serves the last refresh, of the week or the month", asy
"Later",
]);
});
function castVote(dumpId: string, userId: string, at: string) {
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(dumpId, userId, at);
}
test("listCommunityEvents: what was done in public, newest first, a dump's latest comment and upvote alone", async () => {
const ann = await createUser({ createdAt: ago(9 * DAY) });
const bob = await createUser({ invitedBy: ann.id, createdAt: ago(8 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(7 * DAY),
});
const secret = insertDump({
userId: ann.id,
title: "Secret",
isPrivate: true,
});
insertComment({ dumpId: hit.id, userId: ann.id, createdAt: ago(6 * DAY) });
const last = insertComment({
dumpId: hit.id,
userId: bob.id,
createdAt: ago(5 * DAY),
});
const gone = insertComment({ dumpId: hit.id, userId: ann.id });
db.prepare(`UPDATE comments SET deleted = 1 WHERE id = ?;`).run(gone);
insertComment({ dumpId: secret.id, userId: ann.id });
castVote(hit.id, bob.id, ago(4 * DAY));
castVote(hit.id, ann.id, ago(3 * DAY));
castVote(secret.id, ann.id, ago(0));
const mix = insertPlaylist({ userId: bob.id, title: "Mix" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
const events = listCommunityEvents();
assertEquals(
events.map((e) => [e.type, e.username]),
[
["playlist_created", bob.username],
["dump_upvoted", ann.username],
["dump_commented", bob.username],
["dump_posted", ann.username],
["user_joined", bob.username],
["user_joined", ann.username],
],
);
const [playlist, upvote, comment, , joined] = events;
assertEquals(playlist.type === "playlist_created" && playlist.playlist, {
title: "Mix",
slug: mix.slug,
ownerUsername: bob.username,
});
assertEquals(upvote.type === "dump_upvoted" && upvote.dump, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
assertEquals(upvote.id, `dump_upvoted:${hit.id}`);
assertEquals(comment.type === "dump_commented" && comment.commentId, last);
assertEquals(
joined.type === "user_joined" && joined.invitedByUsername,
ann.username,
);
});
test("listCommunityEvents: keeps the 12 latest", async () => {
const ann = await createUser({ createdAt: ago(DAY) });
for (let i = 0; i < 14; i++) {
insertDump({
userId: ann.id,
title: `Dump ${i}`,
createdAt: ago(i * 1000),
});
}
const events = listCommunityEvents();
assertEquals(events.length, 12);
assertEquals(
events.map((e) => e.type === "dump_posted" && e.dump.title),
Array.from({ length: 12 }, (_, i) => `Dump ${i}`),
);
});
test("getCommunityFeed: the events as they are now, the week's figures as of the last refresh", async () => {
const ann = await createUser({ createdAt: ago(20 * DAY) });
const hit = insertDump({
userId: ann.id,
title: "Hit",
createdAt: ago(1000),
});
vote(hit.id, 3);
refreshCommunityStats();
insertDump({ userId: ann.id, title: "Later" });
const feed = getCommunityFeed();
assertEquals(
feed.events.map((e) => e.type === "dump_posted" && e.dump.title),
["Later", "Hit", false],
);
assertEquals(feed.week.totals.dumps, 1);
assertEquals(feed.week.top, {
title: "Hit",
slug: hit.slug,
ownerUsername: ann.username,
});
});

94
deno.lock generated
View File

@@ -29,34 +29,38 @@
"jsr:@std/path@^1.1.6": "1.1.6",
"jsr:@std/testing@^1.0.21": "1.0.21",
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:@fontsource-variable/fraunces@5.3.0": "5.3.0",
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.11.0_jiti@2.7.0",
"npm:@fontsource-variable/bricolage-grotesque@5.3.0": "5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0": "5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0": "5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0": "5.3.0",
"npm:@fontsource-variable/saira@5.3.0": "5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0": "5.3.0",
"npm:@fontsource/comic-neue@5.3.0": "5.3.0",
"npm:@fontsource/instrument-serif@5.3.0": "5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0": "5.3.0",
"npm:@lingui/cli@*": "6.8.0",
"npm:@lingui/cli@6.8.0": "6.8.0",
"npm:@lingui/conf@6.8.0": "6.8.0",
"npm:@lingui/core@6.8.0": "6.8.0_@babel+core@7.29.7",
"npm:@lingui/format-po@6.8.0": "6.8.0",
"npm:@lingui/react@6.8.0": "6.8.0_react@19.3.0_@babel+core@7.29.7",
"npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7",
"npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0",
"npm:@lingui/swc-plugin@6.7.0": "6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7",
"npm:@lingui/vite-plugin@6.8.0": "6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@resvg/resvg-wasm@2.6.2": "2.6.2",
"npm:@resvg/resvg-wasm@^2.6.2": "2.6.2",
"npm:@testing-library/dom@^10.4.2": "10.4.2",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0",
"npm:@testing-library/jest-dom@^7.0.1": "7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2",
"npm:@testing-library/react@^16.3.3": "16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0",
"npm:@testing-library/user-event@^14.6.7": "14.6.7_@testing-library+dom@10.4.2",
"npm:@types/node@^26.6.3": "26.6.3",
"npm:@types/react-dom@^19.3.0": "19.3.0_@types+react@19.3.0",
"npm:@types/react@^19.3.0": "19.3.0",
"npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0",
"npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0",
"npm:@vitejs/plugin-react-swc@^4.3.3": "4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:@vitest/coverage-v8@^5.0.2": "5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1",
"npm:emojibase-data@17.0.0": "17.0.0_emojibase@17.0.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0",
"npm:eslint-plugin-react-hooks@^7.1.1": "7.1.1_eslint@10.11.0_jiti@2.7.0",
"npm:eslint-plugin-react-refresh@~0.5.7": "0.5.7_eslint@10.11.0_jiti@2.7.0",
"npm:eslint@^10.11.0": "10.11.0_jiti@2.7.0",
"npm:frimousse@0.4": "0.4.0_react@19.3.0_typescript@6.0.3",
"npm:globals@^17.12.0": "17.12.0",
@@ -68,15 +72,15 @@
"npm:react-dom@^19.3.0": "19.3.0_react@19.3.0",
"npm:react-hook-form@^7.89.0": "7.89.0_@types+react@19.3.0_react@19.3.0",
"npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.3.0_react@19.3.0",
"npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0",
"npm:react-router@^8.4.0": "8.4.0_react@19.3.0_react-dom@19.3.0",
"npm:react@^19.3.0": "19.3.0",
"npm:remark-gfm@^4.0.1": "4.0.1",
"npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0",
"npm:typescript-eslint@^8.70.1": "8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0",
"npm:typescript@~6.0.3": "6.0.3",
"npm:vite@*": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vite@^8.3.1": "8.3.1_@types+node@26.6.3_jiti@2.7.0",
"npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0"
"npm:vitest@*": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1",
"npm:vitest@^5.0.2": "5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1"
},
"jsr": {
"@db/sqlite@0.13.0": {
@@ -382,14 +386,14 @@
"@csstools/color-helpers@6.1.2": {
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="
},
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"@csstools/css-calc@3.4.1_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
"dependencies": [
"@csstools/css-parser-algorithms",
"@csstools/css-tokenizer"
]
},
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1__@csstools+css-tokenizer@4.0.2_@csstools+css-tokenizer@4.0.2": {
"@csstools/css-color-parser@4.2.4_@csstools+css-parser-algorithms@4.0.1_@csstools+css-tokenizer@4.0.2": {
"integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
"dependencies": [
"@csstools/color-helpers",
@@ -416,7 +420,7 @@
"@csstools/css-tokenizer@4.0.2": {
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="
},
"@eslint-community/eslint-utils@4.9.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"@eslint-community/eslint-utils@4.9.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dependencies": [
"eslint",
@@ -446,7 +450,7 @@
"@types/json-schema"
]
},
"@eslint/js@10.0.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"@eslint/js@10.0.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dependencies": [
"eslint"
@@ -468,21 +472,33 @@
"@exodus/bytes@1.16.0": {
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="
},
"@fontsource-variable/fraunces@5.3.0": {
"integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA=="
"@fontsource-variable/bricolage-grotesque@5.3.0": {
"integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA=="
},
"@fontsource-variable/jetbrains-mono@5.3.0": {
"integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="
},
"@fontsource-variable/libre-franklin@5.3.0": {
"integrity": "sha512-PEQemCKVRtkLZJNpfvR4wG7x87c8iG05XJuEaX59ogMcAQpdtCZz/NXZUrXLJyvyx3/HD70W4LUppWLBdEdU2g=="
},
"@fontsource-variable/newsreader@5.3.0": {
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw=="
},
"@fontsource-variable/saira@5.3.0": {
"integrity": "sha512-C1rm7n1OuEx4Se/SAB/5EeUuCviGFFnYTr5vxuQQm2YIOfgbQWx/P6qqhdXlVCBRm/1UzNWCtDK5jNbVmO6W2w=="
},
"@fontsource-variable/space-grotesk@5.3.0": {
"integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw=="
},
"@fontsource/comic-neue@5.3.0": {
"integrity": "sha512-uZsyUTExw8tCySNhL65dN6rEUhASMPGintSS5Kw/ZkrQTK1vcAgramzOLT/mZKtZ+EXr03MHj0s5osVVJITCow=="
},
"@fontsource/instrument-serif@5.3.0": {
"integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ=="
},
"@fontsource/manufacturing-consent@5.3.0": {
"integrity": "sha512-p/omJ13hFgV6+THlT370nyHjlMkrZWkKPiMSZHROcyFoylVvazo19Rp9KJhwudWqcbkClgzjEryvC330y4Pr6Q=="
},
"@humanfs/core@0.19.2": {
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dependencies": [
@@ -647,14 +663,14 @@
"use-sync-external-store"
]
},
"@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0__@babel+core@7.29.7_@babel+core@7.29.7": {
"@lingui/swc-plugin@6.7.0_@lingui+core@6.8.0_@babel+core@7.29.7": {
"integrity": "sha512-TRLHfeQT/q9JXy0cBGOtbfcEWJFF1szjPCVM4AT1HdZyfGj+dQ0r8hHq+08c4iXZLzlsYilzw+bZJ3TahG5g5w==",
"dependencies": [
"@lingui/conf",
"@lingui/core"
]
},
"@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@lingui/vite-plugin@6.8.0_@babel+core@7.29.7_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-pyOGwY8llaXzBBDrbZpAV5a0z7ekz0zJxU+EGXxyqL/gLneqHgLhPCv8uNB+MyIGwrw0PC9CMtCpHVlS3oWguA==",
"dependencies": [
"@babel/core",
@@ -869,7 +885,7 @@
"pretty-format@27.5.1"
]
},
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2__@types+node@26.6.3__@vitest+coverage-v8@5.0.2__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0_@vitest+coverage-v8@5.0.2__vitest@5.0.2__@types+node@26.6.3__jsdom@30.1.1__vite@8.3.1___@types+node@26.6.3___jiti@2.7.0": {
"@testing-library/jest-dom@7.0.1_@testing-library+dom@10.4.2_vitest@5.0.2_@vitest+coverage-v8@5.0.2": {
"integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
"dependencies": [
"@adobe/css-tools",
@@ -885,7 +901,7 @@
"vitest"
]
},
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0__@types+react@19.3.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": {
"@testing-library/react@16.3.3_@testing-library+dom@10.4.2_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dependencies": [
"@babel/runtime",
@@ -1003,7 +1019,7 @@
"@types/yargs-parser"
]
},
"@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1__eslint@10.11.0___jiti@2.7.0__typescript@6.0.3_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/eslint-plugin@8.70.1_@typescript-eslint+parser@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==",
"dependencies": [
"@eslint-community/regexpp",
@@ -1019,7 +1035,7 @@
"typescript"
]
},
"@typescript-eslint/parser@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3": {
"@typescript-eslint/parser@8.70.1_eslint@10.11.0_typescript@6.0.3": {
"integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==",
"dependencies": [
"@typescript-eslint/scope-manager",
@@ -1053,7 +1069,7 @@
"typescript"
]
},
"@typescript-eslint/type-utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/type-utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==",
"dependencies": [
"@typescript-eslint/types",
@@ -1083,7 +1099,7 @@
"typescript"
]
},
"@typescript-eslint/utils@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"@typescript-eslint/utils@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==",
"dependencies": [
"@eslint-community/eslint-utils",
@@ -1105,7 +1121,7 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"deprecated": true
},
"@vitejs/plugin-react-swc@4.3.3_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@vitejs/plugin-react-swc@4.3.3_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==",
"dependencies": [
"@rolldown/pluginutils",
@@ -1113,7 +1129,7 @@
"vite"
]
},
"@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": {
"@vitest/coverage-v8@5.0.2_vitest@5.0.2_@types+node@26.6.3_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-3ffHBEi8DOOBLwIGBhOBZbRfYFYWjMUuxZicONdhuFEFEG5AVsMOSrVeuROskqnqpbOmEJwxM2eTVZ9N3a1t+A==",
"dependencies": [
"@bcoe/v8-coverage",
@@ -1136,7 +1152,7 @@
"@vitest/istanbul-lib-coverage"
]
},
"@vitest/mocker@5.0.2_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0_@types+node@26.6.3_jiti@2.7.0": {
"@vitest/mocker@5.0.2_vite@8.3.1_@types+node@26.6.3_jiti@2.7.0": {
"integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
"dependencies": [
"@jridgewell/trace-mapping",
@@ -1428,7 +1444,7 @@
"escape-string-regexp@5.0.0": {
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
},
"eslint-plugin-react-hooks@7.1.1_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"eslint-plugin-react-hooks@7.1.1_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dependencies": [
"@babel/core",
@@ -1439,7 +1455,7 @@
"zod-validation-error"
]
},
"eslint-plugin-react-refresh@0.5.7_eslint@10.11.0__jiti@2.7.0_jiti@2.7.0": {
"eslint-plugin-react-refresh@0.5.7_eslint@10.11.0_jiti@2.7.0": {
"integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==",
"dependencies": [
"eslint"
@@ -2569,7 +2585,7 @@
"vfile"
]
},
"react-router@8.4.0_react@19.3.0_react-dom@19.3.0__react@19.3.0": {
"react-router@8.4.0_react@19.3.0_react-dom@19.3.0": {
"integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==",
"dependencies": [
"@remix-run/route-pattern",
@@ -2834,7 +2850,7 @@
"prelude-ls"
]
},
"typescript-eslint@8.70.1_eslint@10.11.0__jiti@2.7.0_typescript@6.0.3_jiti@2.7.0": {
"typescript-eslint@8.70.1_eslint@10.11.0_typescript@6.0.3_jiti@2.7.0": {
"integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
@@ -2955,7 +2971,7 @@
],
"bin": true
},
"vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1__@types+node@26.6.3__jiti@2.7.0": {
"vitest@5.0.2_@types+node@26.6.3_@vitest+coverage-v8@5.0.2_jsdom@30.1.1_vite@8.3.1": {
"integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
"dependencies": [
"@types/chai",
@@ -3069,11 +3085,15 @@
"packageJson": {
"dependencies": [
"npm:@eslint/js@^10.0.1",
"npm:@fontsource-variable/fraunces@5.3.0",
"npm:@fontsource-variable/bricolage-grotesque@5.3.0",
"npm:@fontsource-variable/jetbrains-mono@5.3.0",
"npm:@fontsource-variable/libre-franklin@5.3.0",
"npm:@fontsource-variable/newsreader@5.3.0",
"npm:@fontsource-variable/saira@5.3.0",
"npm:@fontsource-variable/space-grotesk@5.3.0",
"npm:@fontsource/comic-neue@5.3.0",
"npm:@fontsource/instrument-serif@5.3.0",
"npm:@fontsource/manufacturing-consent@5.3.0",
"npm:@lingui/cli@6.8.0",
"npm:@lingui/conf@6.8.0",
"npm:@lingui/core@6.8.0",

View File

@@ -9,6 +9,11 @@
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png?v=__ICON_VERSION__" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192x192.png?v=__ICON_VERSION__" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Keeps the Dark Reader extension off the page, which has dark palettes
of its own: it rewrites the stylesheet's colors, and its rewrite of
the fallback the build gives every light-dark() leaves them all
invalid under a dark system theme, so no background is painted. -->
<meta name="darkreader-lock" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#01152a" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#faf5e9" />
<meta name="site-name" content="__SITE_NAME__" />

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/comment/CommentNode.tsx:140
#: src/components/comment/CommentNode.tsx:137
msgid "[deleted]"
msgstr "[deleted]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
#: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:131
#: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -206,14 +206,14 @@ msgstr "# Welcome to {site}!\n"
"Your account has been created successfully. Welcome aboard!"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -226,22 +226,42 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:474
#: src/pages/Dump.tsx:451
msgid "+ Playlist"
msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> commented on <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> created the playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> joined"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> joined, invited by <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> posted <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> started following you"
#. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> upvoted <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>Online now:</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>This week:</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>Top this week:</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 new message"
@@ -305,7 +344,7 @@ msgstr "a post"
msgid "Account"
msgstr "Account"
#: src/components/profile/ProfileHeader.tsx:144
#: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>"
msgstr "Account on <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Account on <0>{host}</0>"
msgid "Add a comment…"
msgstr "Add a comment…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Add a description…"
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Add category"
#: src/components/profile/ProfileHeader.tsx:173
#: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…"
msgstr "Add email…"
@@ -427,16 +471,13 @@ msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/comment/CommentForm.tsx:125
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Cancel"
@@ -450,7 +491,7 @@ msgstr "Cancel removal"
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/AppHeader.tsx:182
msgid "Cancel search"
msgstr "Cancel search"
@@ -462,7 +503,7 @@ msgstr "Categories"
msgid "Category"
msgstr "Category"
#: src/components/profile/ProfileHeader.tsx:102
#: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar"
msgstr "Change avatar"
@@ -521,7 +562,7 @@ msgstr "Close anyway"
msgid "Close emoji picker"
msgstr "Close emoji picker"
#: src/components/player/GlobalPlayer.tsx:262
#: src/components/player/GlobalPlayer.tsx:264
msgid "Close player"
msgstr "Close player"
@@ -529,7 +570,7 @@ msgstr "Close player"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player"
msgstr "Collapse player"
@@ -628,7 +669,7 @@ msgstr "Create"
msgid "Create & Add"
msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}"
msgstr "created {ago}"
@@ -652,9 +693,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
#: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34
msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Delete dump"
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
#: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist"
msgstr "Delete playlist"
#: src/components/comment/CommentNode.tsx:270
#: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?"
msgstr "Delete this comment?"
@@ -694,12 +735,12 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/chat/ChatMessageItem.tsx:219
#: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
#: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Done"
#. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226
#: src/components/dump/FilePreview.tsx:233
msgid "Download {0}"
msgstr "Download {0}"
@@ -753,7 +794,7 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/layout/AppHeader.tsx:200
#: src/components/layout/AppHeader.tsx:201
msgid "Dump"
msgstr "Dump"
@@ -787,9 +828,9 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:623
#: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556
msgid "Edit"
msgstr "Edit"
@@ -798,16 +839,22 @@ msgstr "Edit"
msgid "Edit {0}"
msgstr "Edit {0}"
#: src/components/profile/ProfileDescription.tsx:58
#: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description"
msgstr "Edit description"
#: src/components/profile/ProfileHeader.tsx:171
#: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email"
msgstr "Edit email"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Edit note"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533
#: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title"
msgstr "Edit title"
@@ -819,9 +866,9 @@ msgstr "edited"
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:576
#: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492
msgid "edited {0}"
msgstr "edited {0}"
@@ -829,9 +876,9 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:574
#: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490
msgid "Edited {0}"
msgstr "Edited {0}"
@@ -856,7 +903,7 @@ msgstr "Email address"
msgid "Enter a query to search."
msgstr "Enter a query to search."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player"
msgstr "Expand player"
@@ -890,15 +937,13 @@ msgstr "Failed to post"
msgid "Failed to post comment"
msgstr "Failed to post comment"
#: src/components/comment/CommentNode.tsx:291
#: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Failed to save"
@@ -906,11 +951,7 @@ msgstr "Failed to save"
msgid "Failed to save category"
msgstr "Failed to save category"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:182
#: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
@@ -1025,7 +1066,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:643
#: src/pages/Dump.tsx:576
msgid "In collections"
msgstr "In collections"
@@ -1056,7 +1097,7 @@ msgstr "Invalid link"
msgid "Invite link"
msgstr "Invite link"
#: src/components/profile/ProfileHeader.tsx:126
#: src/components/profile/ProfileHeader.tsx:133
msgid "invited by"
msgstr "invited by"
@@ -1114,7 +1155,7 @@ msgstr "Link or file"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/layout/AppHeader.tsx:233
#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -1126,7 +1167,7 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:359
#: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -1139,7 +1180,7 @@ msgstr "Loading messages…"
msgid "Loading more…"
msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:82
#: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…"
msgstr "Loading playlist…"
@@ -1159,7 +1200,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:207
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1273,7 +1314,7 @@ msgstr "New public playlists"
msgid "Next →"
msgstr "Next →"
#: src/components/player/GlobalPlayer.tsx:244
#: src/components/player/GlobalPlayer.tsx:246
msgid "Next track"
msgstr "Next track"
@@ -1388,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Open {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
#: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}"
msgstr "Open on {0}"
#: src/components/dump/RichContentCard.tsx:123
#: src/components/dump/RichContentCard.tsx:125
msgid "Open source"
msgstr "Open source"
@@ -1453,9 +1494,9 @@ msgstr "Passwords do not match"
msgid "Paste a link…"
msgstr "Paste a link…"
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276
msgid "Pause"
msgstr "Pause"
@@ -1464,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/RichContentCard.tsx:150
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276
msgid "Play"
msgstr "Play"
@@ -1477,7 +1518,7 @@ msgid "Playlist categories"
msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
@@ -1492,26 +1533,26 @@ msgstr "Playlists"
msgid "Post comment"
msgstr "Post comment"
#: src/components/comment/CommentNode.tsx:289
#: src/components/comment/CommentNode.tsx:283
msgid "Post reply"
msgstr "Post reply"
#: src/components/comment/CommentNode.tsx:290
#: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Posting…"
#: src/components/player/GlobalPlayer.tsx:228
#: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track"
msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582
#: src/pages/Dump.tsx:498
msgid "private"
msgstr "private"
@@ -1519,8 +1560,8 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:140
msgid "public"
msgstr "public"
@@ -1532,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata"
msgstr "Refresh metadata"
#: src/components/player/GlobalPlayer.tsx:324
#: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
@@ -1554,7 +1595,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:634
#: src/pages/Dump.tsx:567
msgid "Related"
msgstr "Related"
@@ -1594,7 +1635,7 @@ msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
#: src/components/comment/CommentNode.tsx:240
msgid "Reply"
msgstr "Reply"
@@ -1641,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393
#: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Retry"
@@ -1651,27 +1692,20 @@ msgid "Role"
msgstr "Role"
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:310
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Save"
#: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106
msgid "Search"
@@ -1719,7 +1753,7 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/layout/AppHeader.tsx:197
#: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1776,7 +1810,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
#: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1836,11 +1870,11 @@ msgstr "The month on {site}, {range}"
msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again."
#: src/components/comment/CommentNode.tsx:282
#: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:368
#: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private."
@@ -1911,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Top this week"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
#: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}"
msgstr "Track {0}"
@@ -1985,7 +2019,7 @@ msgstr "Unsubscribed"
msgid "Updated"
msgstr "Updated"
#: src/components/playlist/PlaylistCard.tsx:141
#: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}"
msgstr "updated {ago}"
@@ -1994,7 +2028,7 @@ msgstr "updated {ago}"
msgid "email.digest.upload"
msgstr "upload"
#: src/components/profile/ProfileHeader.tsx:79
#: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed"
msgstr "Upload failed"
@@ -2097,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Welcome to {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
#: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79
#: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?"
msgstr "Who am I?"
@@ -2119,7 +2155,7 @@ msgstr "Why & where"
msgid "Why?"
msgstr "Why?"
#: src/components/comment/CommentNode.tsx:293
#: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…"
msgstr "Write a reply…"

File diff suppressed because one or more lines are too long

View File

@@ -13,18 +13,18 @@ msgstr ""
"Last-Translator: \n"
"Language-Team: \n"
#: src/components/comment/CommentNode.tsx:140
#: src/components/comment/CommentNode.tsx:137
msgid "[deleted]"
msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount
#: src/components/dump/DumpCard.tsx:135
#: src/components/dump/JournalCard.tsx:136
#: src/components/dump/JournalCard.tsx:137
msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:131
#: src/components/playlist/PlaylistCard.tsx:141
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -206,14 +206,14 @@ msgstr "# Bienvenue sur {site} !\n"
"Votre compte a bien été créé. Bienvenue à bord !"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/PlaylistDetail.tsx:99
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/Dump.tsx:374
#: src/pages/Dump.tsx:399
#: src/pages/Dump.tsx:560
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -226,22 +226,42 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:474
#: src/pages/Dump.tsx:451
msgid "+ Playlist"
msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:114
#: src/pages/Notifications.tsx:239
msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
#. placeholder {0}: event.username
#. placeholder {1}: event.playlist.title
#: src/components/social/CommunityFeed.tsx:127
msgid "<0>{0}</0> created the playlist <1>{1}</1>"
msgstr "<0>{0}</0> a créé la playlist <1>{1}</1>"
#. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: event.username
#: src/components/social/CommunityFeed.tsx:145
msgid "<0>{0}</0> joined"
msgstr "<0>{0}</0> a rejoint le site"
#. placeholder {0}: event.username
#. placeholder {1}: event.invitedByUsername
#: src/components/social/CommunityFeed.tsx:138
msgid "<0>{0}</0> joined, invited by <1>{1}</1>"
msgstr "<0>{0}</0> a rejoint le site, sur invitation de <1>{1}</1>"
#. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:249
@@ -259,7 +279,10 @@ msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
#. placeholder {0}: d.dumperUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:94
#: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> a publié <1>{1}</1>"
@@ -270,7 +293,10 @@ msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername
#. placeholder {0}: event.username
#. placeholder {1}: d.dumpTitle
#. placeholder {1}: event.dump.title
#: src/components/social/CommunityFeed.tsx:104
#: src/pages/Notifications.tsx:229
msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> a validé <1>{1}</1>"
@@ -285,6 +311,19 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
msgid "<0>{name}</0> ({host}) asks to sign you in with your account here, <1>@{username}</1>. It will receive your username, your email address and your avatar."
msgstr "<0>{name}</0> ({host}) demande à vous connecter avec votre compte d'ici, <1>@{username}</1>. Il recevra votre nom d'utilisateur, votre adresse e-mail et votre avatar."
#: src/components/social/CommunityFeed.tsx:159
msgid "<0>Online now:</0> {names}"
msgstr "<0>En ligne :</0> {names}"
#: src/components/social/CommunityFeed.tsx:166
msgid "<0>This week:</0> <1>{figures}</1>"
msgstr "<0>Cette semaine :</0> <1>{figures}</1>"
#. placeholder {0}: top.title
#: src/components/social/CommunityFeed.tsx:174
msgid "<0>Top this week:</0> <1>{0}</1>"
msgstr "<0>En tête cette semaine :</0> <1>{0}</1>"
#: src/components/chat/ChatModal.tsx:778
msgid "1 new message"
msgstr "1 nouveau message"
@@ -305,7 +344,7 @@ msgstr "une publication"
msgid "Account"
msgstr "Compte"
#: src/components/profile/ProfileHeader.tsx:144
#: src/components/profile/ProfileHeader.tsx:151
msgid "Account on <0>{host}</0>"
msgstr "Compte sur <0>{host}</0>"
@@ -313,12 +352,17 @@ msgstr "Compte sur <0>{host}</0>"
msgid "Add a comment…"
msgstr "Ajouter un commentaire…"
#: src/components/playlist/PlaylistHeader.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:128
msgid "Add a description…"
msgstr "Ajouter une description…"
#: src/components/category/CategoryManager.tsx:115
#: src/components/category/CategoryManager.tsx:116
msgid "Add category"
msgstr "Ajouter une catégorie"
#: src/components/profile/ProfileHeader.tsx:173
#: src/components/profile/ProfileHeader.tsx:168
msgid "Add email…"
msgstr "Ajouter un e-mail…"
@@ -427,16 +471,13 @@ msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92
#: src/components/comment/CommentForm.tsx:127
#: src/components/comment/CommentForm.tsx:125
#: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Annuler"
@@ -450,7 +491,7 @@ msgstr "Annuler la suppression"
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/AppHeader.tsx:182
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -462,7 +503,7 @@ msgstr "Catégories"
msgid "Category"
msgstr "Catégorie"
#: src/components/profile/ProfileHeader.tsx:102
#: src/components/profile/ProfileHeader.tsx:109
msgid "Change avatar"
msgstr "Changer l'avatar"
@@ -521,7 +562,7 @@ msgstr "Fermer quand même"
msgid "Close emoji picker"
msgstr "Fermer le sélecteur d'emoji"
#: src/components/player/GlobalPlayer.tsx:262
#: src/components/player/GlobalPlayer.tsx:264
msgid "Close player"
msgstr "Fermer le lecteur"
@@ -529,7 +570,7 @@ msgstr "Fermer le lecteur"
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Collapse player"
msgstr "Réduire le lecteur"
@@ -628,7 +669,7 @@ msgstr "Créer"
msgid "Create & Add"
msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:142
#: src/components/playlist/PlaylistCard.tsx:152
msgid "created {ago}"
msgstr "créée {ago}"
@@ -652,9 +693,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220
#: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34
msgid "Delete"
@@ -679,14 +720,14 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistCard.tsx:172
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
#: src/pages/PlaylistDetail.tsx:174
msgid "Delete playlist"
msgstr "Supprimer la collection"
#: src/components/comment/CommentNode.tsx:270
#: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?"
@@ -694,12 +735,12 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/chat/ChatMessageItem.tsx:219
#: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
#: src/pages/PlaylistDetail.tsx:173
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -737,7 +778,7 @@ msgid "Done"
msgstr "Terminé"
#. placeholder {0}: dump.fileName
#: src/components/dump/FilePreview.tsx:226
#: src/components/dump/FilePreview.tsx:233
msgid "Download {0}"
msgstr "Télécharger {0}"
@@ -753,7 +794,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/layout/AppHeader.tsx:200
#: src/components/layout/AppHeader.tsx:201
msgid "Dump"
msgstr "Reco"
@@ -787,9 +828,9 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:623
#: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556
msgid "Edit"
msgstr "Modifier"
@@ -798,16 +839,22 @@ msgstr "Modifier"
msgid "Edit {0}"
msgstr "Modifier {0}"
#: src/components/profile/ProfileDescription.tsx:58
#: src/components/playlist/PlaylistHeader.tsx:113
#: src/components/profile/ProfileDescription.tsx:45
msgid "Edit description"
msgstr "Modifier la description"
#: src/components/profile/ProfileHeader.tsx:171
#: src/components/profile/ProfileHeader.tsx:161
msgid "Edit email"
msgstr "Modifier l'e-mail"
#: src/pages/Dump.tsx:510
msgid "Edit note"
msgstr "Modifier la note"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:533
#: src/components/playlist/PlaylistHeader.tsx:84
#: src/pages/Dump.tsx:459
msgid "Edit title"
msgstr "Modifier le titre"
@@ -819,9 +866,9 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:576
#: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492
msgid "edited {0}"
msgstr "modifié {0}"
@@ -829,9 +876,9 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:574
#: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490
msgid "Edited {0}"
msgstr "Modifié le {0}"
@@ -856,7 +903,7 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
#: src/components/player/GlobalPlayer.tsx:254
#: src/components/player/GlobalPlayer.tsx:256
msgid "Expand player"
msgstr "Agrandir le lecteur"
@@ -890,15 +937,13 @@ msgstr "Publication échouée"
msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire"
#: src/components/comment/CommentNode.tsx:291
#: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/components/federation/GrantList.tsx:98
#: src/components/federation/PeerManager.tsx:158
#: src/components/playlist/PlaylistEditForm.tsx:207
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86
msgid "Failed to save"
msgstr "Enregistrement échoué"
@@ -906,11 +951,7 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:182
#: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
@@ -1025,7 +1066,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:643
#: src/pages/Dump.tsx:576
msgid "In collections"
msgstr "Dans les collections"
@@ -1056,7 +1097,7 @@ msgstr "Lien invalide"
msgid "Invite link"
msgstr "Lien d'invitation"
#: src/components/profile/ProfileHeader.tsx:126
#: src/components/profile/ProfileHeader.tsx:133
msgid "invited by"
msgstr "invité par"
@@ -1114,7 +1155,7 @@ msgstr "Lien ou fichier"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/layout/AppHeader.tsx:233
#: src/components/layout/AppHeader.tsx:236
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -1126,7 +1167,7 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:359
#: src/pages/Dump.tsx:358
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -1139,7 +1180,7 @@ msgstr "Chargement des messages…"
msgid "Loading more…"
msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:82
#: src/pages/PlaylistDetail.tsx:83
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
@@ -1159,7 +1200,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:207
#: src/components/layout/AppHeader.tsx:208
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1273,7 +1314,7 @@ msgstr "Nouvelles collections publiques"
msgid "Next →"
msgstr "Suivant →"
#: src/components/player/GlobalPlayer.tsx:244
#: src/components/player/GlobalPlayer.tsx:246
msgid "Next track"
msgstr "Piste suivante"
@@ -1388,11 +1429,11 @@ msgid "email.welcome.button"
msgstr "Ouvrir {site}"
#. placeholder {0}: richContent.siteName
#: src/components/dump/RichContentCard.tsx:122
#: src/components/dump/RichContentCard.tsx:124
msgid "Open on {0}"
msgstr "Ouvrir sur {0}"
#: src/components/dump/RichContentCard.tsx:123
#: src/components/dump/RichContentCard.tsx:125
msgid "Open source"
msgstr "Ouvrir la source"
@@ -1453,9 +1494,9 @@ msgstr "Les mots de passe ne correspondent pas"
msgid "Paste a link…"
msgstr "Collez un lien…"
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/player/MediaPlayer.tsx:276
msgid "Pause"
msgstr "Pause"
@@ -1464,10 +1505,10 @@ msgstr "Pause"
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/components/dump/FilePreview.tsx:70
#: src/components/dump/FilePreview.tsx:184
#: src/components/dump/RichContentCard.tsx:103
#: src/components/dump/RichContentCard.tsx:150
#: src/components/dump/FilePreview.tsx:72
#: src/components/dump/FilePreview.tsx:191
#: src/components/dump/RichContentCard.tsx:105
#: src/components/dump/RichContentCard.tsx:153
#: src/components/player/MediaPlayer.tsx:276
msgid "Play"
msgstr "Lecture"
@@ -1477,7 +1518,7 @@ msgid "Playlist categories"
msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/AppHeader.tsx:174
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
@@ -1492,26 +1533,26 @@ msgstr "Collections"
msgid "Post comment"
msgstr "Publier le commentaire"
#: src/components/comment/CommentNode.tsx:289
#: src/components/comment/CommentNode.tsx:283
msgid "Post reply"
msgstr "Publier la réponse"
#: src/components/comment/CommentNode.tsx:290
#: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…"
msgstr "Publication…"
#: src/components/player/GlobalPlayer.tsx:228
#: src/components/player/GlobalPlayer.tsx:230
msgid "Previous track"
msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/dump/JournalCard.tsx:146
#: src/components/playlist/PlaylistCard.tsx:127
#: src/components/playlist/PlaylistHeader.tsx:140
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:582
#: src/pages/Dump.tsx:498
msgid "private"
msgstr "privé"
@@ -1519,8 +1560,8 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistCard.tsx:126
#: src/components/playlist/PlaylistHeader.tsx:140
msgid "public"
msgstr "public"
@@ -1532,7 +1573,7 @@ msgstr "Public"
msgid "Refresh metadata"
msgstr "Actualiser les métadonnées"
#: src/components/player/GlobalPlayer.tsx:324
#: src/components/player/GlobalPlayer.tsx:326
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
@@ -1554,7 +1595,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:634
#: src/pages/Dump.tsx:567
msgid "Related"
msgstr "Connexe"
@@ -1594,7 +1635,7 @@ msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244
#: src/components/comment/CommentNode.tsx:240
msgid "Reply"
msgstr "Répondre"
@@ -1641,7 +1682,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:393
#: src/pages/Dump.tsx:392
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Réessayer"
@@ -1651,27 +1692,20 @@ msgid "Role"
msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:310
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217
#: src/components/form/InlineEdit.tsx:307
#: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106
msgid "Search"
@@ -1719,7 +1753,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/layout/AppHeader.tsx:197
#: src/components/layout/AppHeader.tsx:198
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1776,7 +1810,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
#: src/pages/PlaylistDetail.tsx:157
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1836,11 +1870,11 @@ msgstr "Le mois sur {site}, {range}"
msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
#: src/components/comment/CommentNode.tsx:282
#: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:368
#: src/pages/Dump.tsx:367
msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1911,7 +1945,7 @@ msgid "digest.topWeek"
msgstr "Les plus validées de la semaine"
#. placeholder {0}: i + 1
#: src/components/player/GlobalPlayer.tsx:338
#: src/components/player/GlobalPlayer.tsx:340
msgid "Track {0}"
msgstr "Piste {0}"
@@ -1985,7 +2019,7 @@ msgstr "Désabonné"
msgid "Updated"
msgstr "Mises à jour"
#: src/components/playlist/PlaylistCard.tsx:141
#: src/components/playlist/PlaylistCard.tsx:151
msgid "updated {ago}"
msgstr "mise à jour {ago}"
@@ -1994,7 +2028,7 @@ msgstr "mise à jour {ago}"
msgid "email.digest.upload"
msgstr "fichier"
#: src/components/profile/ProfileHeader.tsx:79
#: src/components/profile/ProfileHeader.tsx:80
msgid "Upload failed"
msgstr "Envoi échoué"
@@ -2097,12 +2131,14 @@ msgid "email.welcome.subject"
msgstr "Bienvenue sur {site}"
#: src/components/dump/create/DumpCreateModal.tsx:705
#: src/pages/Dump.tsx:514
#: src/pages/Dump.tsx:521
#: src/pages/DumpEdit.tsx:539
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
#: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79
#: src/components/profile/ProfileDescription.tsx:62
#: src/components/profile/ProfileDescription.tsx:72
msgid "Who am I?"
msgstr "Qui suis-je ?"
@@ -2119,7 +2155,7 @@ msgstr "Pourquoi & où"
msgid "Why?"
msgstr "Pourquoi ?"
#: src/components/comment/CommentNode.tsx:293
#: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…"
msgstr "Écrire une réponse…"

View File

@@ -10,11 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/fraunces": "5.3.0",
"@fontsource-variable/bricolage-grotesque": "5.3.0",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource-variable/saira": "5.3.0",
"@fontsource-variable/space-grotesk": "5.3.0",
"@fontsource-variable/libre-franklin": "5.3.0",
"@fontsource-variable/newsreader": "5.3.0",
"@fontsource/comic-neue": "5.3.0",
"@fontsource/instrument-serif": "5.3.0",
"@fontsource/manufacturing-consent": "5.3.0",
"@lingui/cli": "6.8.0",
"@lingui/conf": "6.8.0",
"@lingui/core": "6.8.0",

View File

@@ -1,14 +1,16 @@
/**
* What the whole site did over a period, one of the digests' cadences: its
* most upvoted dumps, who joined, the new public playlists and the totals.
* This module types it for the `/community` page, which shows it as it was at
* the server's last refresh; the digests gather the same figures for each
* reader's own period.
* What the whole site did, told two ways. Over a period, one of the digests'
* cadences: its most upvoted dumps, who joined, the new public playlists and
* the totals, which the `/community` page shows as they were at the server's
* last refresh and the digests gather for each reader's own period. And as it
* happens: the community feed, the latest things its members did in public,
* which the GeoCities style's marquee scrolls.
*
* @module
*/
import type { Dump } from "./dump.ts";
import type { OwnedItem } from "./paths.ts";
import type { Playlist } from "./playlist.ts";
import type { PublicUser } from "./user.ts";
@@ -30,3 +32,86 @@ export interface CommunityStats {
/** Public dumps posted, upvotes cast and comments written in it. */
totals: { dumps: number; upvotes: number; comments: number };
}
/**
* Names a dump or a playlist in a {@link CommunityEvent}: its title, and what
* `dumpPath` or `playlistPath` builds its page's path from.
*/
export interface CommunityItem extends OwnedItem {
title: string;
}
/** What every {@link CommunityEvent} carries. */
interface CommunityEventBase {
/**
* The event's kind and what it is about, `dump_upvoted:<dump id>`: unique in
* a feed, where a dump has one event of each kind at most.
*/
id: string;
at: Date;
/** Who did it, by their current name. */
username: string;
}
/** A public dump was posted. A dump made public later keeps its date. */
export interface DumpPostedEvent extends CommunityEventBase {
type: "dump_posted";
dump: CommunityItem;
}
/** A public playlist was created. */
export interface PlaylistCreatedEvent extends CommunityEventBase {
type: "playlist_created";
playlist: CommunityItem;
}
/**
* A public dump got a comment. However many it got, the feed tells of its
* latest alone.
*/
export interface DumpCommentedEvent extends CommunityEventBase {
type: "dump_commented";
dump: CommunityItem;
/** For the `#comment-<id>` anchor on the dump's page. */
commentId: string;
}
/** A public dump got an upvote, told as {@link DumpCommentedEvent} is. */
export interface DumpUpvotedEvent extends CommunityEventBase {
type: "dump_upvoted";
dump: CommunityItem;
}
/** Someone joined the site: `username` is theirs. */
export interface UserJoinedEvent extends CommunityEventBase {
type: "user_joined";
invitedByUsername?: string;
}
/** One line of the community feed, told apart by `type`. */
export type CommunityEvent =
| DumpPostedEvent
| PlaylistCreatedEvent
| DumpCommentedEvent
| DumpUpvotedEvent
| UserJoinedEvent;
/**
* Returned by `GET /api/community/feed` to `CommunityFeedProvider`, which
* asks again as the socket tells of changes, and rendered by the
* `CommunityFeed` component with who is online, which the client knows from
* its socket. `community-service.ts` gathers it. Everything in it is public:
* every reader gets the same feed.
*/
export interface CommunityFeed {
/** The latest events, newest first, 12 at most. */
events: CommunityEvent[];
/**
* The last week's figures, as `/community` shows them: as of the server's
* last refresh.
*/
week: Pick<CommunityStats, "totals" | "chatMessages"> & {
/** The week's most upvoted dump, when one was upvoted. */
top?: CommunityItem;
};
}

42
shared/image.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Names the widths the site's own images come in. Every image the API serves
* (avatars, covers, thumbnails, attachments, proxied images) takes `?w=` and
* answers a copy scaled down to it, so a card or an avatar doesn't load the
* upload at full size. The frontend builds those URLs from this list
* (`imageSrcSet` in `src/utils/shared/urls.ts`) and the server makes no other
* width (`serveImage` in `api/lib/upload.ts`), which bounds what it keeps.
*
* @module
*/
/**
* The widths, in pixels, smallest first: an avatar on a dense screen, a small
* cover, a card on a phone, and the widest feed card on a dense screen.
*/
export const IMAGE_WIDTHS = [128, 320, 640, 960] as const;
/** One of {@link IMAGE_WIDTHS}. */
export type ImageWidth = typeof IMAGE_WIDTHS[number];
/** The largest of {@link IMAGE_WIDTHS}, which a thumbnail never exceeds. */
export const MAX_IMAGE_WIDTH: ImageWidth =
IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
/**
* Returns the narrowest of {@link IMAGE_WIDTHS} that covers `width` pixels, or
* the widest when none does.
*/
export function snapImageWidth(width: number): ImageWidth {
return IMAGE_WIDTHS.find((w) => w >= width) ?? MAX_IMAGE_WIDTH;
}
/**
* Returns the width a `?w=` value asks for, snapped to {@link IMAGE_WIDTHS},
* or null when it is absent or not a positive number, which asks for the image
* as it is.
*/
export function parseImageWidth(raw: string | null): ImageWidth | null {
if (raw === null || !/^\d{1,5}$/.test(raw)) return null;
const width = Number(raw);
return width > 0 ? snapImageWidth(width) : null;
}

22
shared/test/image.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { assertEquals } from "@std/assert";
import { parseImageWidth, snapImageWidth } from "../image.ts";
Deno.test("snapImageWidth: the narrowest width that covers, the widest past them all", () => {
assertEquals(snapImageWidth(1), 128);
assertEquals(snapImageWidth(128), 128);
assertEquals(snapImageWidth(129), 320);
assertEquals(snapImageWidth(700), 960);
assertEquals(snapImageWidth(5000), 960);
});
Deno.test("parseImageWidth: a positive number, or no width at all", () => {
assertEquals(parseImageWidth("320"), 320);
assertEquals(parseImageWidth("200"), 320);
assertEquals(parseImageWidth(null), null);
assertEquals(parseImageWidth(""), null);
assertEquals(parseImageWidth("0"), null);
assertEquals(parseImageWidth("-64"), null);
assertEquals(parseImageWidth("12.5"), null);
assertEquals(parseImageWidth("wide"), null);
assertEquals(parseImageWidth("999999999"), null);
});

View File

@@ -223,67 +223,151 @@
word-break: break-word;
}
.dump-title-view {
/* ── Inline edit ── */
/* Text edited where it stands (InlineEdit.tsx). Hovered, the text sits on a
faint plate with a ✎ after it; opened, the field takes the plate's place
and the text's type, so that nothing jumps but the buttons that come under
it. The plate spills past the text, drawn under it by a pseudo-element: the
text's own box keeps its size, which a row measuring it (a playlist's title
beside its buttons) depends on. */
.inline-edit {
/* Tinted from the text, as it sits on a card or on the page alike. */
--inline-edit-plate: color-mix(in srgb, var(--color-text) 7%, transparent);
--inline-edit-pad-y: 0.1rem;
--inline-edit-pad-x: 0.45rem;
min-width: 0;
max-width: 100%;
}
.inline-edit--block {
--inline-edit-pad-y: 0.45rem;
--inline-edit-pad-x: 0.6rem;
}
.inline-edit-view {
position: relative;
isolation: isolate;
max-width: 100%;
cursor: text;
}
.inline-edit-view::before {
content: "";
position: absolute;
inset: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
z-index: -1;
border-radius: var(--radius-md);
transition: background-color var(--dur-fast);
}
.inline-edit--line .inline-edit-view {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: var(--radius-md);
max-width: 100%;
/* Padding gives the hover box breathing room (incl. right of the pencil);
the matching negative margin keeps the title's visual position unchanged. */
padding: 0.1rem 0.45rem;
margin: -0.1rem -0.45rem;
gap: 0.4em;
}
.dump-title-view--editable {
cursor: pointer;
/* Room on the right for the ✎, which sits in the corner. */
.inline-edit--block .inline-edit-view {
display: block;
padding-right: 1.5rem;
}
.dump-title-view--editable:hover {
background: var(--color-surface);
.inline-edit-view:hover::before {
background: var(--inline-edit-plate);
}
.dump-title-edit-btn {
font-size: var(--text-sm);
line-height: 1;
.inline-edit-mark {
flex-shrink: 0;
padding: 0.15em;
border: none;
border-radius: var(--radius-sm);
background: none;
font-family: inherit;
font-size: var(--text-sm);
font-weight: 400;
line-height: 1;
color: var(--color-text-muted);
cursor: pointer;
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.dump-title-view--editable:hover .dump-title-edit-btn,
.dump-title-view--editable:focus-visible .dump-title-edit-btn {
.inline-edit--block .inline-edit-mark {
position: absolute;
top: 0;
right: 0;
}
.inline-edit-view:hover .inline-edit-mark,
.inline-edit-mark:focus-visible {
opacity: 1;
}
.dump-title-editor {
/* No pointer to hover with: the mark stays, faintly, to say it can be
tapped. */
@media (hover: none) {
.inline-edit-mark {
opacity: 0.45;
}
}
/* What an empty text says to the one who may fill it. */
.inline-edit-empty {
margin: 0;
color: var(--color-text-muted);
font-style: italic;
}
.inline-edit-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
/* Typed as the text it replaces: the type is inherited at no specificity, so
that the class of that text (`fieldClassName`) and a style's rules for it
both set it over this. */
:where(.inline-edit-input) {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.inline-edit-input {
box-sizing: border-box;
width: calc(100% + 2 * var(--inline-edit-pad-x));
min-width: 0;
padding: calc(var(--inline-edit-pad-y) - var(--border-width))
calc(var(--inline-edit-pad-x) - var(--border-width));
margin: calc(-1 * var(--inline-edit-pad-y))
calc(-1 * var(--inline-edit-pad-x));
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
background: var(--inline-edit-plate);
transition: border-color var(--dur-base), box-shadow var(--dur-base);
}
/* As the Markdown editor's box takes the focus. */
.inline-edit-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.inline-edit-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
gap: 0.4rem;
}
/* Shared chrome for inline-edit inputs (the editors that live outside `.form`).
Each rule below adds only its own size / weight / width deltas. */
.dump-title-input,
.profile-email-input,
.username-field-input {
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input:focus,
.profile-email-input:focus,
.username-field-input:focus {
border-color: var(--color-accent);
.inline-edit-error {
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
}
/* A name and its "Rename" button, the hint and errors below. */
@@ -304,28 +388,18 @@
flex: 1 1 10rem;
min-width: 0;
padding: 0.4rem 0.6rem;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
color: var(--color-text);
font-family: inherit;
font-size: var(--text-base);
outline: none;
transition: border-color var(--dur-slow);
}
.dump-title-input {
flex: 1;
min-width: 12rem;
padding: 0.4rem 0.6rem;
font-size: var(--text-xl);
font-weight: 700;
}
/* Save and Cancel wrap together, under the input on a phone. */
.dump-title-actions {
display: flex;
gap: 0.5rem;
}
.dump-title-error {
width: 100%;
margin: 0;
font-size: var(--text-sm);
color: var(--color-danger);
.username-field-input:focus {
border-color: var(--color-accent);
}
/* On the narrowest viewports the fixed-width action column squeezes the title
@@ -2357,23 +2431,9 @@ body.has-player .chat-fab {
/* No margins of its own: the page's gap already sets it apart. */
.profile-description {
width: 100%;
}
.profile-description-view {
position: relative;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
font-size: var(--text-md);
}
.profile-description-view--editable {
cursor: pointer;
}
.profile-description-view--editable:hover {
background: var(--color-surface);
}
.profile-description-text {
white-space: pre-wrap;
overflow-wrap: break-word;
@@ -2381,80 +2441,16 @@ body.has-player .chat-fab {
line-height: 1.6;
}
.profile-description-empty {
color: var(--color-muted);
font-style: italic;
}
.profile-description-edit-btn {
position: absolute;
top: 0.55rem;
right: 0.5rem;
font-size: var(--text-sm);
color: var(--color-muted);
opacity: 0;
transition: opacity var(--dur-fast);
pointer-events: none;
}
.profile-description-view--editable:hover .profile-description-edit-btn,
.profile-description-view--editable:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-description-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.profile-email-display {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 0.35rem;
.profile-email {
margin: 0.1rem 0 0.4rem;
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0.1rem 0 0.4rem;
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0.1rem 0.2rem;
margin-left: -0.2rem;
}
.profile-email-display:hover {
background: var(--color-surface);
}
.profile-email-display:hover .profile-description-edit-btn,
.profile-email-display:focus-visible .profile-description-edit-btn {
opacity: 1;
}
.profile-email-editor {
margin: 0.2rem 0 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.profile-email-input {
width: min(260px, 100%);
padding: 0.35rem 0.6rem;
border-radius: var(--radius-md);
/* An address is typed a size up from the line it shows on. */
.profile-email .inline-edit-input {
max-width: 22rem;
font-size: var(--text-sm);
line-height: 1.5;
}
.profile-email-actions {
display: flex;
gap: 0.4rem;
}
.profile-description-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.profile-invited-by {
@@ -2731,7 +2727,11 @@ body.has-player .chat-fab {
display: flex;
align-items: center;
gap: 0.5rem;
overflow-wrap: anywhere;
/* A word stays whole unless it alone is wider than the line. Breaking
anywhere lets the text be squeezed under the width of a word, and Firefox
sizes a title set with negative letter-spacing a hair narrower than it
lays it out, which dropped the last letter to a line of its own. */
overflow-wrap: break-word;
}
.page-title-icon {
@@ -2769,6 +2769,12 @@ body.has-player .chat-fab {
padding: 1rem;
}
/* A card that holds one line of text, such as a list with nothing in it yet:
the card's padding spaces it, not the paragraph's margins as well. */
.page-card > p:only-child {
margin: 0;
}
/* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */
.page-group .dump-feed {
@@ -2855,6 +2861,13 @@ body.has-player .chat-fab {
flex-shrink: 0;
color: var(--color-text);
text-decoration: none;
/* It answers the pointer as a card's title does (.dump-card-title): the
accent, eased in. A theme that colors the name restates the hover. */
transition: color var(--dur-base);
}
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-center {
@@ -2929,7 +2942,7 @@ body.has-player .chat-fab {
/* When the search bar is expanded, immediately clear the rest of the center —
the expanded input needs the full column width */
.app-header-center:has(.search-bar--expanded) .index-presence,
.app-header-center:has(.search-bar--expanded) .feed-sort {
.app-header-center:has(.search-bar--expanded) .feed-sort-scroller {
display: none;
}
@@ -2942,10 +2955,6 @@ body.has-player .chat-fab {
min-width: 0;
}
.app-header-brand:hover {
color: var(--color-accent);
}
.app-header-nav {
display: flex;
align-items: center;
@@ -3792,90 +3801,54 @@ body.has-player .chat-fab {
/* ── Feed sort scroller + buttons ── */
/* Wrapper: constrains width, clips scroll overflow, and carries the gradient
hint. --tab-row-bg is what the row sits on, which the fade and the pinned
leading content paint so the tabs vanish under them. */
/* Wrapper: the leading content (the scope switcher), then the row of tabs,
which scrolls on its own beside it. Nothing here paints the ground the row
sits on: a theme's page may be a gradient or a picture, which a patch of
flat color would show against. */
.feed-sort-scroller {
position: relative;
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
--tab-row-bg: var(--color-bg);
}
.app-header .feed-sort-scroller {
--tab-row-bg: var(--header-bg);
}
/* Right-edge fade, over the scroll container whatever its scroll position.
It shows only while tabs are hidden past that edge (TabBar's
--more), so it never dims the last tab once it is scrolled to or fits. */
.feed-sort-scroller::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
background: linear-gradient(to right, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more::after {
opacity: 1;
}
/* Its twin on the left edge once the row has scrolled, unless leading content
is pinned there, which draws its own (.feed-sort-leading::after). */
.feed-sort-scroller::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 1;
width: 2rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
}
.feed-sort-scroller--more-left:not(:has(.feed-sort-leading))::before {
opacity: 1;
}
/* The leading content (the scope switcher) stays at the row's start while the
tabs scroll under it. Its padding hides them right after its divider, and
the matching negative margin keeps the gap to the first tab unchanged. */
.feed-sort-leading {
position: sticky;
left: 0;
z-index: 1;
display: flex;
flex-shrink: 0;
padding-right: 0.4rem;
margin-right: -0.4rem;
background: var(--tab-row-bg);
}
/* Once tabs have scrolled under it, a short fade softens their cut edge. */
.feed-sort-leading::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 100%;
width: 0.75rem;
background: linear-gradient(to left, transparent, var(--tab-row-bg));
pointer-events: none;
opacity: 0;
transition: opacity var(--dur-base);
/* The row fades out at an edge while tabs are hidden past it (TabBar's
--more and --more-left), so a fade never dims a tab that is scrolled to or
fits. The fade is a mask, the tabs thinning out over whatever is behind
them. The two lengths are registered so that they can ease in. */
@property --tab-fade-start {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.feed-sort-scroller--more-left .feed-sort-leading::after {
opacity: 1;
@property --tab-fade-end {
syntax: "<length>";
inherits: true;
initial-value: 0px;
}
.feed-sort-scroller {
transition: --tab-fade-start var(--dur-base), --tab-fade-end var(--dur-base);
}
.feed-sort-scroller--more {
--tab-fade-end: 2rem;
}
.feed-sort-scroller--more-left {
--tab-fade-start: 2rem;
}
/* Beside leading content the tabs are cut at its divider, and a shorter fade
softens the cut. */
.feed-sort-scroller--more-left:has(.feed-sort-leading) {
--tab-fade-start: 0.75rem;
}
/* In the index below-header the scroller should fill the available row width */
@@ -3889,6 +3862,13 @@ body.has-player .chat-fab {
gap: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
mask-image: linear-gradient(
to right,
transparent,
#000 var(--tab-fade-start),
#000 calc(100% - var(--tab-fade-end)),
transparent
);
/* Symmetric, so the tabs stay centred against their row (the header's
brand and buttons), with room on both sides for the focus ring that
overflow-x would otherwise clip. */
@@ -5129,19 +5109,10 @@ body.has-player .chat-fab {
color: var(--color-danger);
}
.chat-edit {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
/* The input rests on one line, as tall as the Send button beside it: both
take --chat-composer-height, which any style's line of text fits in. */
.chat-composer {
--chat-composer-height: 2.75rem;
display: flex;
gap: 0.5rem;
align-items: flex-end;
@@ -5170,6 +5141,10 @@ body.has-player .chat-fab {
above the input, in the gap over the composer: inside the textarea, the text
ran under them, and in a row below they would knock the Send button out of
alignment. */
.chat-composer .chat-input {
min-height: var(--chat-composer-height);
}
.chat-composer .text-editor-footer {
position: absolute;
left: 0.25rem;
@@ -5184,9 +5159,9 @@ body.has-player .chat-fab {
.chat-send-btn {
flex-shrink: 0;
/* Match the input's resting height so the two line up exactly (both edges),
while the input is free to grow taller as you type. */
height: 2.4rem;
/* The input's resting height, so that the two line up at both edges, while
the input is free to grow taller as you type. */
height: var(--chat-composer-height);
box-sizing: border-box;
}
@@ -5534,6 +5509,23 @@ body.has-player .chat-fab {
flex-wrap: wrap;
}
/* The open title field takes the room its buttons leave beside it, which
line up with its top rather than with its Save and Cancel. */
.playlist-detail-title-row:has(> .inline-edit--editing) {
align-items: flex-start;
}
.playlist-detail-title-row > .inline-edit--editing {
flex: 1 1 16rem;
/* Clear of the buttons by the gap, past the field's spill. */
margin-right: var(--inline-edit-pad-x);
}
/* The title on the left, Follow and Edit together at the row's end. */
.playlist-detail-title-row > :first-child:not(.inline-edit--editing) {
margin-right: auto;
}
.playlist-detail-title {
margin: 0;
font-size: var(--text-2xl);
@@ -7543,7 +7535,7 @@ body.has-player .chat-fab {
}
/* Vertical rule separating the scope selector from the sort tabs. */
.feed-sort .category-switcher::after {
.feed-sort-leading .category-switcher::after {
content: "";
width: 2px;
height: 1.05rem;
@@ -7636,7 +7628,7 @@ body.has-player .chat-fab {
.category-link {
padding: 0.1rem 0.5rem;
border-radius: var(--radius-full);
background: var(--color-header-user-bg);
background: var(--color-badge-bg);
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--text-xs);
@@ -7849,3 +7841,12 @@ body.has-player .chat-fab {
grid-template-columns: repeat(2, 1fr);
}
}
/* ── Community feed ── */
/* A bare list: the style that shows the feed lays it out, as GeoCities does
along its marquee. */
.community-feed {
list-style: none;
margin: 0;
padding: 0;
}

View File

@@ -62,6 +62,7 @@ import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
import { WSProvider } from "./contexts/ws/WSProvider.tsx";
import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
import { CommunityFeedProvider } from "./contexts/social/CommunityFeedProvider.tsx";
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
@@ -203,15 +204,22 @@ function HandleRoutes() {
: <Feed categorySlug={handle} />;
}
/** Renders the route table, and the floating buttons over every page. */
function AppRoutes() {
/**
* Renders the route table, and the floating buttons over every page. Exported
* for the tests, which reach a page through it as a visit does.
*/
export function AppRoutes(): React.JSX.Element {
return (
<>
<Suspense>
<Routes>
<Route path="/" element={<Feed />} />
{
/* The splat takes the `follow` tab's view, which `Feed` reads from
the path; without it `/follow/playlists` is category `follow`. */
}
{FEED_TABS.map((tab) => (
<Route key={tab} path={`/${tab}`} element={<Feed />} />
<Route key={tab} path={`/${tab}/*`} element={<Feed />} />
))}
<Route path="/search" element={<Search />} />
<Route path="/search/:searchTab" element={<Search />} />
@@ -298,14 +306,16 @@ export function App(): React.JSX.Element {
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<BrowserRouter>
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
<CommunityFeedProvider>
<CategoriesProvider>
<BrowserRouter>
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
</CommunityFeedProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>

View File

@@ -5,39 +5,33 @@
* @module
*/
import { useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { useEffect, useRef } from "react";
import { InlineEditField } from "../form/InlineEdit.tsx";
import { COUNT_FROM } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts";
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
export interface ChatEditBoxProps {
initial: string;
/**
* Receives the trimmed text, only when it can be sent. It may be unchanged:
* the caller decides whether to send an edit.
*/
/** Receives the trimmed text, only when it changed and can be sent. */
onSave: (body: string) => void;
/** Also called on Escape. */
onCancel: () => void;
/** Called once the text is saved, or left, Escape included. */
onClose: () => void;
}
/**
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and
* Shift+Enter starts a new line, as in the composer.
* Renders the field every text edited in place gets, `InlineEditField`, with
* Enter saving and Shift+Enter starting a new line, as in the composer. It
* scrolls itself into the list's view.
*/
export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps,
{ initial, onSave, onClose }: ChatEditBoxProps,
): React.JSX.Element {
const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null);
// The message is still easing to its new height, so a scroll on focus would
// land short. Scroll once the transition is over, Save button included.
// The message is still easing to its new height, so a scroll now would land
// short. Scroll once the transition is over, Save button included.
useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const box = boxRef.current;
if (!box) return;
const settle = parseFloat(
@@ -50,48 +44,19 @@ export function ChatEditBox(
return () => clearTimeout(id);
}, []);
const save = () => {
if (!sendable(draft)) return;
onSave(draft.trim());
};
return (
<div className="chat-edit animate-in" ref={boxRef}>
<TextEditor
ref={editorRef}
value={draft}
onChange={setDraft}
rows={2}
autoResize
<div className="chat-edit" ref={boxRef}>
<InlineEditField
value={initial}
onSave={onSave}
onClose={onClose}
kind="markdown"
required
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input"
onSubmit={save}
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}}
fieldClassName="chat-input"
enterSaves
/>
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary btn-sm"
onClick={save}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
</div>
</div>
);
}

View File

@@ -194,11 +194,8 @@ export function ChatMessageItem(
? (
<ChatEditBox
initial={message.body}
onSave={(body) => {
if (body !== message.body) onEdit(message.id, body);
onEndEdit();
}}
onCancel={onEndEdit}
onSave={(body) => onEdit(message.id, body)}
onClose={onEndEdit}
/>
)
: (

View File

@@ -850,7 +850,7 @@ export function ChatModal(
value={draft}
onChange={handleDraftChange}
placeholder={t`Type a message…`}
rows={2}
rows={1}
autoResize
maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM}

View File

@@ -1,6 +1,6 @@
/**
* Renders the form of a dump's comment thread that posts a comment, a reply,
* or an edit.
* Renders the form of a dump's comment thread that posts a comment or a
* reply. An edit opens `InlineEditField` in the comment's place.
*
* @module
*/
@@ -15,7 +15,7 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
/**
* Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by
* `CommentNode`, for a reply or an edit.
* `CommentNode`, for a reply.
*/
export interface CommentFormProps {
/**
@@ -28,7 +28,6 @@ export interface CommentFormProps {
pendingLabel: React.ReactNode;
errorTitle: string;
placeholder?: string;
defaultValue?: string;
/** Without it, the form has no Cancel button. */
onCancel?: () => void;
/**
@@ -39,7 +38,7 @@ export interface CommentFormProps {
editorRef?: React.Ref<TextEditorHandle>;
/**
* `top` puts `leading` beside the editor, for the new-comment form. `inline`
* is for replies and edits, inside a comment.
* is for replies, inside a comment.
*/
layout?: "inline" | "top";
leading?: React.ReactNode;
@@ -57,7 +56,6 @@ export function CommentForm({
pendingLabel,
errorTitle,
placeholder,
defaultValue = "",
onCancel,
cancelWhenEmpty,
editorRef,
@@ -66,7 +64,7 @@ export function CommentForm({
className,
}: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue },
defaultValues: { body: "" },
});
const body = form.watch("body");
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
@@ -91,8 +89,8 @@ export function CommentForm({
value={field.value}
onChange={field.onChange}
onSubmit={() => void submit()}
// Escape closes a reply or an edit. The new-comment form has
// nothing to close.
// Escape closes a reply. The new-comment form has nothing to
// close.
onCancel={onCancel && !cancelWhenEmpty
? () => {
form.reset({ body: "" });

View File

@@ -30,6 +30,8 @@ import { useWS } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx";
import { InlineEditField } from "../form/InlineEdit.tsx";
import { VALIDATION } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts";
/**
@@ -75,7 +77,7 @@ export function CommentNode({
);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
const editButtonRef = useRef<HTMLButtonElement>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth();
@@ -105,18 +107,13 @@ export function CommentNode({
}
}
// Not called with the text unchanged, which would only mark it "edited".
async function handleEditSave(body: string) {
// Saving it unchanged would only mark it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest),
});
onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res)));
setEditOpen(false);
}
const canModerate = can(currentUser, "comment:moderate");
@@ -209,15 +206,14 @@ export function CommentNode({
</div>
{editOpen
? (
<CommentForm
editorRef={editEditorRef}
defaultValue={comment.body}
onSubmit={handleEditSave}
submitLabel={<Trans>Save</Trans>}
pendingLabel={<Trans>Saving…</Trans>}
errorTitle={t`Failed to save edit`}
className="animate-in"
onCancel={() => setEditOpen(false)}
<InlineEditField
value={comment.body}
onSave={handleEditSave}
onClose={() => setEditOpen(false)}
returnFocusTo={editButtonRef}
kind="markdown"
required
maxLength={VALIDATION.COMMENT_BODY_MAX}
/>
)
: <Markdown className="comment-body">{comment.body}</Markdown>}
@@ -246,12 +242,10 @@ export function CommentNode({
)}
{canEdit && !editOpen && (
<button
ref={editButtonRef}
type="button"
className="comment-action-btn"
onClick={() => {
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
onClick={() => setEditOpen(true)}
>
<Trans>Edit</Trans>
</button>

View File

@@ -12,7 +12,9 @@ import {
dumpFileUrl,
dumpThumbnailUrl,
dumpWaveformUrl,
imageProps,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import {
fileArtworkUrl,
@@ -72,6 +74,7 @@ function FilePlayButton({ dump }: { dump: Dump }) {
<Thumbnail
src={fileArtworkUrl(dump, token)}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{ seed: dump.fileName ?? dump.id, glyph: mimeIcon(mime) }}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -141,7 +144,10 @@ export function FilePreview(
// A card needs the server's scaled-down copy, not the full upload.
return (
<img
src={dumpThumbnailUrl(dump, token)}
{...imageProps(
dumpThumbnailUrl(dump, token),
IMAGE_SIZES.compactThumbnail,
)}
alt={dump.fileName}
className="rich-content-compact-thumbnail"
loading="lazy"
@@ -158,6 +164,7 @@ export function FilePreview(
<Thumbnail
src={artworkUrl}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={{
seed: dump.fileName ?? dump.id,
glyph: mimeIcon(mime),

View File

@@ -10,6 +10,7 @@ import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../../../shared/dump.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpCardLink } from "../../hooks/dump/useDumpCardLink.ts";
import {
@@ -195,6 +196,7 @@ export const JournalCard: React.MemoExoticComponent<
<div className="journal-card-image">
<Thumbnail
src={thumbnailUrl}
sizes={IMAGE_SIZES.journal[shape]}
placeholder={dump.kind === "file"
// A video still fails without ffmpeg on the server, and the MIME
// glyph says more than an initial taken from the file name.

View File

@@ -14,6 +14,7 @@ import {
usePlayRichContent,
} from "../../hooks/player/usePlayRichContent.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
/**
* Passed to {@link RichContentCard} by `DumpCard` (`compact`), `Dump`,
@@ -68,6 +69,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
sizes={IMAGE_SIZES.compactThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-compact-thumbnail"
/>
@@ -135,6 +137,7 @@ export function RichContentCard(
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
sizes={IMAGE_SIZES.richThumbnail}
placeholder={placeholder}
placeholderClassName="rich-content-thumbnail"
/>

View File

@@ -0,0 +1,340 @@
/**
* Renders the editing of a text in place, with no page or form of its own.
* {@link InlineEdit} shows the text and opens the field over it: a dump's
* title and note, a playlist's title and description, a profile's description
* and email. {@link InlineEditField} is that field alone, for a text that
* opens it with an action of its own: a comment's Edit, a chat message's ✎.
*
* @module
*/
import { type ReactNode, useEffect, useRef, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider, useFormState } from "react-hook-form";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
import { SubmitButton } from "./SubmitButton.tsx";
/**
* Passed to {@link InlineEdit} by the component showing the text, which
* renders it as `children` and saves it in `onSave`.
*/
export interface InlineEditProps {
/** The text as saved, which the field opens on. */
value: string;
/** Without it, `children` show alone. */
editable: boolean;
/** Names the action, such as "Edit title": the text's tooltip. */
label: string;
/**
* Saves the trimmed text, and shows it: the field closes once it resolves.
* A thrown error, such as `expectOk`'s, stays open under the field. A text
* left as it was closes the field without calling it.
*/
onSave: (value: string) => Promise<void>;
/**
* A one-line field (`text`, `email`), where Enter saves, or the Markdown
* editor (`markdown`), where Ctrl/Cmd+Enter does. Escape cancels in both.
*/
kind?: "text" | "email" | "markdown";
/** Refuses to save an empty text. */
required?: boolean;
maxLength?: number;
placeholder?: string;
/** Added to the wrapper, which the text and the field both sit in. */
className?: string;
/** Added to the field: see {@link InlineEditFieldProps}. */
fieldClassName?: string;
children: ReactNode;
}
/**
* Renders `children`, which a click on them, or the ✎ button after them,
* swaps for a field holding `value`, with Save and Cancel under it. The ✎
* shows on hover and on keyboard focus. A click on a link in the text, or one
* that ends a selection, follows the link or keeps the selection instead.
* Closing the field gives the focus back to the ✎ when the field had it.
*/
export function InlineEdit(
{
value,
editable,
label,
onSave,
kind = "text",
required = false,
maxLength,
placeholder,
className,
fieldClassName,
children,
}: InlineEditProps,
): React.JSX.Element {
const [editing, setEditing] = useState(false);
const rootRef = useHeightTransition<HTMLDivElement>(editing);
const markRef = useRef<HTMLButtonElement>(null);
if (!editable) return <>{children}</>;
return (
<div
ref={rootRef}
className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${
className ? ` ${className}` : ""
}`}
>
{editing
? (
<InlineEditField
value={value}
onSave={onSave}
onClose={() => setEditing(false)}
returnFocusTo={markRef}
kind={kind}
required={required}
maxLength={maxLength}
placeholder={placeholder}
fieldClassName={fieldClassName}
label={label}
/>
)
: (
<div
className="inline-edit-view"
title={label}
// The text opens the field to the pointer; the keyboard has the ✎.
// Neither is a button around the text, which keeps its own role:
// a heading, Markdown with its links.
onClick={(e) => {
if ((e.target as Element).closest("a")) return;
if (getSelection()?.type === "Range") return;
setEditing(true);
}}
>
{children}
<button
ref={markRef}
type="button"
className="inline-edit-mark"
aria-label={label}
// Opens the field whatever is selected on the page.
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
✎
</button>
</div>
)}
</div>
);
}
/**
* Passed to {@link InlineEditField} by {@link InlineEdit}, and by the
* components that open it with an action of their own, `CommentNode` and
* `ChatMessageItem`, in place of the text while it is edited.
*/
export interface InlineEditFieldProps extends
Pick<
InlineEditProps,
"value" | "kind" | "required" | "maxLength" | "placeholder"
> {
/**
* Saves the trimmed text. The field closes once it is done, and stays open
* under the error it throws. A text left as it was isn't passed to it.
*/
onSave: (value: string) => Promise<void> | void;
/** Called once the text is saved, or left: the field is to unmount. */
onClose: () => void;
/** Names a one-line field, such as "Edit title". */
label?: string;
/**
* Added to the field: for a one-line one, the class of the text it edits,
* such as `dump-title`, so that it takes on its type in every style.
*/
fieldClassName?: string;
/**
* In the Markdown editor, saves on Enter, a line breaking on Shift+Enter, as
* the chat's composer does. Ctrl/Cmd+Enter saves in either case.
*/
enterSaves?: boolean;
/** Where the Markdown editor's counter starts, near `maxLength`. */
countFrom?: number;
/**
* Takes the focus once the field is gone, if it had it and no one has
* taken it since: the button that opened it, back in its place.
*/
returnFocusTo?: React.RefObject<HTMLElement | null>;
}
/**
* Renders the field holding `value`, focused with the caret at its end, and
* Save and Cancel under it. Enter saves a line, Ctrl/Cmd+Enter a Markdown
* text, Escape leaves. Saving is refused while the text is empty and
* `required`, or longer than `maxLength`. It is mounted afresh on each
* opening, so that it starts from `value`.
*/
export function InlineEditField(
{
value,
onSave,
onClose,
kind = "text",
required,
maxLength,
placeholder,
fieldClassName,
label,
enterSaves,
countFrom,
returnFocusTo,
}: InlineEditFieldProps,
): React.JSX.Element {
const form = useApiForm<{ text: string }>({
defaultValues: { text: value },
});
const text = form.watch("text");
const submitting = form.formState.isSubmitting;
const editorRef = useRef<TextEditorHandle>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Runs once the field is gone and the button that opened it is back. The
// focus fell to the page with the field that had it, unless the caller has
// put it somewhere already.
useEffect(() => () => {
const active = document.activeElement;
if (!active || active === document.body) {
returnFocusTo?.current?.focus({ preventScroll: true });
}
}, [returnFocusTo]);
// The caret goes after the text, where an edit most often begins.
useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
// An email field has no selection to set.
if (input.type === "text") {
input.setSelectionRange(input.value.length, input.value.length);
}
}, []);
const tooLong = maxLength !== undefined && text.length > maxLength;
const blank = required && !text.trim();
const onSubmit = form.submit(async ({ text }) => {
const trimmed = text.trim();
if (trimmed !== value.trim()) await onSave(trimmed);
onClose();
});
const save = () => {
if (!tooLong && !blank) void onSubmit();
};
const cancel = () => {
if (!submitting) onClose();
};
const { ref: registerRef, ...registration } = form.register("text");
return (
<FormProvider {...form}>
<form className="inline-edit-form animate-in" onSubmit={onSubmit}>
{kind === "markdown"
? (
<Controller
name="text"
control={form.control}
render={({ field }) => (
<TextEditor
ref={editorRef}
className={`comment-reply-textarea${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
value={field.value}
onChange={field.onChange}
onSubmit={save}
onCancel={cancel}
onKeyDown={enterSaves
? (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}
: undefined}
placeholder={placeholder}
// As tall as the text it opens on, growing as it is typed.
rows={1}
autoResize
maxLength={maxLength}
countFrom={countFrom}
disabled={submitting}
/>
)}
/>
)
: (
<input
{...registration}
ref={(el) => {
registerRef(el);
inputRef.current = el;
}}
type={kind}
className={`inline-edit-input${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
aria-label={label}
placeholder={placeholder}
maxLength={maxLength}
disabled={submitting}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
/>
)}
<div className="inline-edit-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={tooLong || blank}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={cancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<InlineEditError />
</div>
</form>
</FormProvider>
);
}
/**
* Renders why the save failed, as one line beside the buttons: an
* `ErrorCard` would outweigh the field it is about.
*/
function InlineEditError(): React.JSX.Element | null {
const { errors } = useFormState();
const message = errors.root?.message;
if (!message) return null;
return (
<p className="inline-edit-error" role="alert">
{message}
</p>
);
}

View File

@@ -15,6 +15,7 @@ import { NotificationBell } from "./NotificationBell.tsx";
import { ChatButton } from "./ChatButton.tsx";
import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { Marquee } from "./Marquee.tsx";
import { IconClose, IconSearch } from "./NavIcons.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
import { playlistsPath, searchPath } from "../../../shared/paths.ts";
@@ -227,6 +228,8 @@ export function AppHeader(
</div>
</div>
<Marquee />
{wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert">
<strong>

View File

@@ -0,0 +1,158 @@
/**
* Renders the GeoCities style's marquee: the community feed crawling across
* the page under the header, as a `<marquee>` did on a homepage of that era.
* `AppHeader` renders it on every page. It draws nothing in any other style,
* nor for a visitor, the feed being for signed-in users.
*
* @module
*/
import {
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { CommunityFeed as CommunityFeedData } from "../../../shared/community.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCommunityFeed } from "../../hooks/social/useCommunityFeed.ts";
import { useTheme } from "../../hooks/theme/useTheme.ts";
import { useWSPresence } from "../../hooks/ws/useWS.ts";
import { CommunityFeed } from "../social/CommunityFeed.tsx";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function onMotionChange(callback: () => void): () => void {
const query = matchMedia(REDUCED_MOTION);
query.addEventListener("change", callback);
return () => query.removeEventListener("change", callback);
}
// Where the crawl had got to, and what its loop showed, when the marquee was
// last on screen. They are kept out of the component, which every page mounts
// anew with its header: the next page's marquee carries on from that point of
// that loop, rather than from the start. A crawl that was running has gone on
// since; one paused under the pointer, which has just clicked a link in it,
// hasn't.
let lastPosition:
| { elapsed: number; at: number; running: boolean }
| undefined;
let lastLoop: Loop | null = null;
/** What one loop of the crawl shows, from its start to its end. */
interface Loop {
feed: CommunityFeedData;
online: OnlineUser[];
/** Counts the loops, and keys the track: a new loop is a new animation. */
turn: number;
}
/**
* Renders the feed twice, side by side: geocities.css slides the pair left by
* the width of one, so the second copy arrives where the first began and the
* loop has no seam. The second copy is hidden from assistive technology and
* from the Tab key.
*
* A loop shows what the feed held when it began. Text that changed mid-crawl
* would jump, the track's width being what the slide is measured by, so what
* came meanwhile waits for the next loop, which also brings the lines' times
* up to date. The crawl's speed is the style's: this sets `--marquee-length`,
* one copy's width in pixels, from which the stylesheet works out how long a
* loop lasts.
*
* For a reader who asked for reduced motion nothing moves or repeats: the
* line shows once, and follows the feed as it changes.
*/
function FeedMarquee(): React.JSX.Element {
const feed = useCommunityFeed();
const { onlineUsers: online } = useWSPresence();
const still = useSyncExternalStore(
onMotionChange,
() => matchMedia(REDUCED_MOTION).matches,
);
// A marquee with no feed yet is a first one, or follows a sign-out.
const [loop, setLoop] = useState<Loop | null>(() => feed && lastLoop);
if (!loop && feed) setLoop({ feed, online, turn: 0 });
useEffect(() => {
lastLoop = loop;
}, [loop]);
const shown = still ? feed && { feed, online } : loop;
const trackRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const track = trackRef.current;
const length = track?.firstElementChild?.getBoundingClientRect().width;
if (!track || !length) return;
track.style.setProperty("--marquee-length", String(Math.round(length)));
const last = lastPosition;
lastPosition = undefined;
const elapsed = !last
? 0
: last.elapsed + (last.running ? performance.now() - last.at : 0);
// A negative delay starts the animation that far in.
track.style.animationDelay = `${-elapsed / 1000}s`;
return () => {
// The track's own animation: the focus can have stopped it.
const animation = track.getAnimations()[0];
const timing = animation?.effect?.getComputedTiming();
if (!timing?.progress || typeof timing.duration !== "number") return;
lastPosition = {
elapsed: timing.progress * timing.duration,
at: performance.now(),
running: animation.playState === "running",
};
};
}, [loop?.turn, still]);
return (
<div
className="site-marquee"
// A link focused from the keyboard stops the crawl, and the browser
// scrolls the line to show it: the line goes back to its start once the
// focus has left.
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
e.currentTarget.scrollLeft = 0;
}
}}
>
{shown && (
<div
key={loop?.turn}
ref={trackRef}
className="site-marquee-track"
onAnimationIteration={(e) => {
// Not a child's animation ending a round of its own.
if (e.target !== e.currentTarget || !feed || !loop) return;
setLoop({ feed, online, turn: loop.turn + 1 });
}}
>
<CommunityFeed feed={shown.feed} online={shown.online} />
{!still && (
<div aria-hidden="true">
<CommunityFeed
feed={shown.feed}
online={shown.online}
tabIndex={-1}
/>
</div>
)}
</div>
)}
</div>
);
}
/**
* Renders the marquee for a signed-in user in the GeoCities style, an empty
* band until the feed is loaded, so that the page under it doesn't move when
* it comes.
*/
export function Marquee(): React.JSX.Element | null {
const { style } = useTheme();
const { user } = useAuth();
if (style !== "geocities" || !user) return null;
return <FeedMarquee />;
}

View File

@@ -17,6 +17,7 @@ import {
import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../../utils/player/duration.ts";
import { Thumbnail } from "../shared/Thumbnail.tsx";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
@@ -201,6 +202,7 @@ export function GlobalPlayer(): React.JSX.Element | null {
<Thumbnail
src={current.artworkUrl}
className="global-player-artwork"
sizes={IMAGE_SIZES.playerArtwork}
placeholder={{ siteName: current.subtitle }}
placeholderClassName="global-player-artwork"
loading="eager"

View File

@@ -10,7 +10,12 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import {
imageProps,
playlistImageUrl,
playlistUrl,
} from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
import {
@@ -40,6 +45,8 @@ export interface PlaylistCardProps {
* public badges on every card there would say nothing.
*/
privateBadgeOnly?: boolean;
/** Set for a tile of the playlists page's grid, whose cover is larger. */
grid?: boolean;
}
/**
@@ -51,7 +58,7 @@ export interface PlaylistCardProps {
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
{ playlist, onDelete, isOwner, privateBadgeOnly, grid }: PlaylistCardProps,
): React.JSX.Element {
const { mediaToken } = useAuth();
const { cardProps } = useCardLink(
@@ -75,7 +82,10 @@ export function PlaylistCard(
{playlist.imageMime
? (
<img
src={playlistImageUrl(playlist, mediaToken)}
{...imageProps(
playlistImageUrl(playlist, mediaToken),
grid ? IMAGE_SIZES.playlistTile : IMAGE_SIZES.playlistThumb,
)}
alt=""
className="playlist-card-img"
loading="lazy"

View File

@@ -40,6 +40,7 @@ export function PlaylistCardList(
isOwner={!!user && user.id === p.userId}
onDelete={onDelete && (() => onDelete(p))}
privateBadgeOnly={privateBadgeOnly}
grid={grid}
/>
))}
</ul>

View File

@@ -8,11 +8,22 @@
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
import { playlistImageUrl } from "../../utils/shared/urls.ts";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../../shared/playlist.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
@@ -21,30 +32,63 @@ import { userPath } from "../../../shared/paths.ts";
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
export interface PlaylistHeaderProps {
playlist: PlaylistWithDumps;
/** Given to those who may edit it, for whom it adds an "Edit" button. */
/**
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
* which opens `PlaylistEditForm`.
*/
onEdit?: () => void;
/**
* Called with the playlist as the server has it once its title or
* description is edited in place, which it lets its editors do.
*/
onSaved?: (playlist: Playlist) => void;
}
/**
* Renders the playlist's details, with a follow button for anyone but its
* owner. `PlaylistEditForm` takes its place while it is edited.
* owner. Its editors change the title and the description in place, saved to
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
* takes the header's place while it is open.
*/
export function PlaylistHeader(
{ playlist, onEdit }: PlaylistHeaderProps,
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
): React.JSX.Element {
const { user, mediaToken } = useAuth();
const { user, mediaToken, authFetch } = useAuth();
const save = async (changes: UpdatePlaylistRequest) => {
const updated = await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes),
}),
);
onSaved?.(deserializePlaylist(updated));
};
return (
<>
{playlist.imageMime && (
<img
src={playlistImageUrl(playlist, mediaToken)}
{...imageProps(
playlistImageUrl(playlist, mediaToken),
IMAGE_SIZES.playlistBanner,
)}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
<InlineEdit
value={playlist.title}
editable={!!onSaved}
label={t`Edit title`}
onSave={(title) => save({ title })}
required
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
fieldClassName="playlist-detail-title"
>
<h1 className="playlist-detail-title">{playlist.title}</h1>
</InlineEdit>
{user?.id !== playlist.userId && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
@@ -62,10 +106,29 @@ export function PlaylistHeader(
)}
</div>
{playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
{(playlist.description || onSaved) && (
<InlineEdit
value={playlist.description ?? ""}
editable={!!onSaved}
label={t`Edit description`}
onSave={(description) => save({ description: description || null })}
kind="markdown"
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
placeholder={t`Add a description…`}
>
{playlist.description
? (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)
: (
<p className="inline-edit-empty">
<Trans>Add a description…</Trans>
</p>
)}
</InlineEdit>
)}
<div className="playlist-detail-meta">

View File

@@ -1,104 +0,0 @@
/**
* Renders the inline editor of the signed-in user's description, under their
* profile's header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { TextEditor } from "../editor/TextEditor.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
* in place of the description while editing.
*/
export interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description's Markdown editor, saved to `PATCH /api/me`.
* Other browsers see the change through the socket's `user_updated` event.
*/
export function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ description: string }>({
defaultValues: { description: initialDescription },
});
const description = form.watch("description");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim();
// Sent even when empty, since the server keeps the old one when it is left
// out.
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: trimmed } satisfies UpdateUserRequest,
),
}),
);
onSaved(trimmed || undefined);
});
return (
<FormProvider {...form}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -1,90 +0,0 @@
/**
* Renders the inline editor of the signed-in user's email, in their profile's
* header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
* the email while editing.
*/
export interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
/**
* Renders the email field, saved to `PATCH /api/me`. Escape cancels.
*/
export function EmailEditor(
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
): React.JSX.Element {
const { authFetch } = useAuth();
const form = useApiForm<{ email: string }>({
defaultValues: { email: initialEmail },
});
const email = form.watch("email");
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ email }) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: email.trim() } satisfies UpdateUserRequest,
),
}),
);
onSaved(email.trim());
});
return (
<FormProvider {...form}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="btn-primary btn-sm"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}

View File

@@ -5,12 +5,16 @@
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { Markdown } from "../editor/Markdown.tsx";
import { DescriptionEditor } from "./DescriptionEditor.tsx";
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
export interface ProfileDescriptionProps {
@@ -22,70 +26,53 @@ export interface ProfileDescriptionProps {
/**
* Renders the description in Markdown, or nothing when there is none. On
* their own profile, users get a "Who am I?" placeholder instead, and a click
* or Enter swaps in {@link DescriptionEditor}.
* their own profile, users get a "Who am I?" placeholder instead, and edit it
* in place, saved to `PATCH /api/me`. Other browsers see the change through
* the socket's `user_updated` event.
*/
export function ProfileDescription(
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
): React.JSX.Element | null {
const [editing, setEditing] = useState(false);
const ref = useHeightTransition<HTMLDivElement>(editing);
const { authFetch } = useAuth();
if (!description && !isOwnProfile) return null;
return (
<div className="profile-description" ref={ref}>
{editing
? (
<DescriptionEditor
initialDescription={description ?? ""}
onCancel={() => setEditing(false)}
onSaved={(saved) => {
onSaved(saved);
setEditing(false);
}}
/>
)
: (
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile ? () => setEditing(true) : undefined}
// Focusable rather than a button, so the Markdown inside keeps its
// own structure and links.
tabIndex={isOwnProfile ? 0 : undefined}
title={isOwnProfile ? t`Edit description` : undefined}
onKeyDown={isOwnProfile
? (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
e.preventDefault();
setEditing(true);
}
}
: undefined}
>
{description
? (
<Markdown className="profile-description-text">
{description}
</Markdown>
)
: (
<div className="profile-description-empty">
<Trans>Who am I?</Trans>
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
<div className="profile-description">
<InlineEdit
value={description ?? ""}
editable={isOwnProfile}
label={t`Edit description`}
onSave={async (saved) => {
// Sent even when empty, since the server keeps the old one when it
// is left out.
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ description: saved } satisfies UpdateUserRequest,
),
}),
);
onSaved(saved || undefined);
}}
kind="markdown"
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
placeholder={t`Who am I?`}
>
{description
? (
<Markdown className="profile-description-text">
{description}
</Markdown>
)
: (
<p className="inline-edit-empty">
<Trans>Who am I?</Trans>
</p>
)}
</InlineEdit>
</div>
);
}

View File

@@ -11,15 +11,18 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, SelfUser } from "../../../shared/user.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts";
import type {
PublicUser,
SelfUser,
UpdateUserRequest,
} from "../../../shared/user.ts";
import { expectOk, parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx";
import { EmailEditor } from "./EmailEditor.tsx";
import { InlineEdit } from "../form/InlineEdit.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx";
import { userPath } from "../../../shared/paths.ts";
@@ -38,7 +41,7 @@ export interface ProfileHeaderProps {
* Renders the avatar, username, role and inviter, and for a user from another
* instance, a link to their profile there. Visitors get a follow button. On
* their own profile, users change their avatar (`PUT /api/me/avatar`) and
* email, unless their account comes from another instance, and invite
* email (`PATCH /api/me`), unless their account comes from another instance, and invite
* someone; their settings, the site's tools and logging out are in the
* header's avatar menu.
*/
@@ -49,8 +52,6 @@ export function ProfileHeader(
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const { name, host } = splitHandle(user.username);
// A user from another instance changes their avatar and email there.
const editable = isOwnProfile && !host;
@@ -83,9 +84,15 @@ export function ProfileHeader(
}
};
const handleEmailSaved = (email: string) => {
const handleEmailSave = async (email: string) => {
await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email } satisfies UpdateUserRequest),
}),
);
if (me) updateSession({ user: { ...me, email } });
setEmailEditing(false);
};
return (
@@ -112,7 +119,7 @@ export function ProfileHeader(
</label>
)}
</div>
<div className="profile-info" ref={infoRef}>
<div className="profile-info">
<div className="profile-username-row">
<h1 className="profile-username">
{name}
@@ -148,34 +155,19 @@ export function ProfileHeader(
</p>
)}
{editable && (
emailEditing
? (
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
<InlineEdit
value={me?.email ?? ""}
editable
label={t`Edit email`}
onSave={handleEmailSave}
kind="email"
required
className="profile-email"
>
<span className="profile-email-text">
{me?.email ?? t`Add email…`}
</span>
</InlineEdit>
)}
{avatarError && (
<ErrorCard

View File

@@ -6,7 +6,7 @@
*/
import { useContext, useState } from "react";
import { userApiUrl } from "../../utils/shared/urls.ts";
import { imageProps, userApiUrl } from "../../utils/shared/urls.ts";
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
/**
@@ -25,8 +25,9 @@ export interface AvatarProps {
}
/**
* Renders the image from `GET /api/users/:userId/avatar`, or the user's initial
* when they have none or it fails to load. An avatar added, changed or removed
* Renders the image from `GET /api/users/:userId/avatar`, scaled by the server
* to the size it is shown at, or the user's initial when they have none or it
* fails to load. An avatar added, changed or removed
* since the caller's data was fetched shows at once, through the
* `user_updated` messages that `WSProvider` puts in `AvatarOverrideContext`.
*/
@@ -55,7 +56,7 @@ export function Avatar(
: userApiUrl({ id: userId }, "avatar");
return (
<img
src={src}
{...imageProps(src, size)}
alt={username}
title={username}
style={sizeStyle}

View File

@@ -27,9 +27,9 @@ export interface TabBarProps<T extends string> {
/** An extra class for the row. */
innerClassName?: string;
/**
* Optional content rendered inside the row, before the tabs (e.g. a scope
* selector). Sits in the same flex line, aligned with them, and stays pinned
* at the row's start while they scroll under it.
* Optional content rendered before the tabs (e.g. a scope selector). Sits
* in the same line, aligned with them, and stays where it is while they
* scroll: it is outside the row that scrolls, which clips them at its edge.
*/
leading?: ReactNode;
}
@@ -43,7 +43,6 @@ export function TabBar<T extends string>(
TabBarProps<T>,
): React.JSX.Element {
const rowRef = useRef<HTMLDivElement>(null);
const leadingRef = useRef<HTMLDivElement>(null);
const [overflowing, setOverflowing] = useState(false);
// Tabs hidden past either edge, which the stylesheet fades only then, so a
// fade never dims a tab that is fully in view.
@@ -69,19 +68,17 @@ export function TabBar<T extends string>(
};
}, [tabs.length]);
// When they don't all fit, keep the active tab in view, between the leading
// content pinned over the row's left edge and the fade over its right edge,
// scrolling the row alone, never the page.
// When they don't all fit, keep the active tab in view, clear of the fade
// over the row's right edge, scrolling the row alone, never the page.
useLayoutEffect(() => {
const row = rowRef.current;
const active = row?.querySelector<HTMLElement>(".feed-sort-btn.active");
if (!row || !active || !overflowing) return;
// The fade is 2rem wide (.feed-sort-scroller::after).
// The fade is 2rem wide (--tab-fade-end on .feed-sort).
const fade =
parseFloat(getComputedStyle(document.documentElement).fontSize) * 2;
const r = row.getBoundingClientRect(), b = active.getBoundingClientRect();
const left = r.left + (leadingRef.current?.offsetWidth ?? 0);
if (b.left < left) row.scrollLeft -= left - b.left;
if (b.left < r.left) row.scrollLeft -= r.left - b.left;
else if (b.right > r.right - fade) {
row.scrollLeft += b.right - r.right + fade;
}
@@ -95,13 +92,11 @@ export function TabBar<T extends string>(
className ? ` ${className}` : ""
}`}
>
{leading && <div className="feed-sort-leading">{leading}</div>}
<div
ref={rowRef}
className={`feed-sort${innerClassName ? ` ${innerClassName}` : ""}`}
>
{leading && (
<div ref={leadingRef} className="feed-sort-leading">{leading}</div>
)}
{tabs.map(({ key, label }) => (
<button
key={key}

View File

@@ -7,6 +7,7 @@
import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
import { imageProps, isApiImageUrl } from "../../utils/shared/urls.ts";
import {
ThumbnailPlaceholder,
type ThumbnailPlaceholderProps,
@@ -23,6 +24,12 @@ export interface ThumbnailProps {
/** Applies to the image only. */
className?: string;
loading?: "lazy" | "eager";
/**
* The width the image is laid out at, as `imageProps` takes it. An image
* the API serves, the site's own or a proxied one, then loads no larger
* than that; another site's loads as that site sends it.
*/
sizes: number | string;
/** Shown when there's no `src` or it fails to load. */
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
placeholderClassName?: string;
@@ -42,6 +49,7 @@ export function Thumbnail(
alt = "",
className,
loading = "lazy",
sizes,
placeholder,
placeholderClassName,
}: ThumbnailProps,
@@ -64,11 +72,10 @@ export function Thumbnail(
);
}
const url = proxyImageUrl(src, { force: attempt === "proxied" });
return (
<img
src={attempt === "proxied"
? proxyImageUrl(src, { force: true })
: proxyImageUrl(src)}
{...(isApiImageUrl(url) ? imageProps(url, sizes) : { src: url })}
alt={alt}
className={className}
loading={loading}

View File

@@ -0,0 +1,196 @@
/**
* Renders the community feed: who is online, the week's figures, and the
* latest things the site's members did in public, each a line with links to
* who and what it tells of. The GeoCities style's `Marquee` scrolls it, and
* any style may show it: it is a plain list, which the style lays out.
*
* @module
*/
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import { useLingui } from "@lingui/react";
import type {
CommunityEvent,
CommunityFeed as CommunityFeedData,
} from "../../../shared/community.ts";
import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import {
communityPath,
dumpPath,
playlistPath,
userPath,
} from "../../../shared/paths.ts";
import type { OnlineUser } from "../../../shared/ws.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
// Past this many, the online line counts the others: "+3".
const ONLINE_MAX = 8;
/** Passed to {@link CommunityFeed} by whatever shows the feed. */
export interface CommunityFeedProps {
/** From `useCommunityFeed`. */
feed: CommunityFeedData;
/** From `useWSPresence`: the feed opens on them. */
online: OnlineUser[];
/**
* Given to every link. `-1` takes them out of the tab order, for a second
* copy of the list such as the one the marquee's loop needs.
*/
tabIndex?: number;
}
/**
* Links a name to its profile. It wraps the name, rather than take it as a
* prop, so that a sentence reads `<0>{0}</0> posted <1>{1}</1>` to Lingui, as
* the same sentence of the notifications page does, and shares its
* translation.
*/
function User(
{ children, tabIndex }: { children: string; tabIndex?: number },
) {
return <Link to={userPath(children)} tabIndex={tabIndex}>{children}</Link>;
}
/**
* Renders the feed as a `<ul>`: a line for who is online, one for the week's
* figures, which links to `/community`, one for its most upvoted dump, then
* one per event, newest first, ending on how long ago it was. A line with
* nothing to say is left out. The times are computed at render.
*/
export function CommunityFeed(
{ feed, online, tabIndex }: CommunityFeedProps,
): React.JSX.Element {
const { i18n } = useLingui();
const { totals, chatMessages, top } = feed.week;
const figures = ([
[totals.dumps, DIGEST_MESSAGES.statDumps],
[totals.upvotes, DIGEST_MESSAGES.countUpvotes],
[totals.comments, DIGEST_MESSAGES.countComments],
[chatMessages, DIGEST_MESSAGES.statChat],
] as const)
.filter(([n]) => n > 0)
.map(([n, label]) =>
`${i18n.number(n)} ${i18n._({ ...label, values: { n } })}`
)
.join(", ");
const names = (
<>
{online.slice(0, ONLINE_MAX).map((u, i) => (
<span key={u.userId}>
{i > 0 && ", "}
<User tabIndex={tabIndex}>{u.username}</User>
</span>
))}
{online.length > ONLINE_MAX && ` +${online.length - ONLINE_MAX}`}
</>
);
function sentence(event: CommunityEvent) {
switch (event.type) {
case "dump_posted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" posted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_upvoted":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" upvoted "}
<Link to={dumpPath(event.dump)} tabIndex={tabIndex}>
{event.dump.title}
</Link>
</Trans>
);
case "dump_commented":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" commented on "}
<Link
to={`${dumpPath(event.dump)}#comment-${event.commentId}`}
tabIndex={tabIndex}
>
{event.dump.title}
</Link>
</Trans>
);
case "playlist_created":
return (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" created the playlist "}
<Link to={playlistPath(event.playlist)} tabIndex={tabIndex}>
{event.playlist.title}
</Link>
</Trans>
);
case "user_joined":
return event.invitedByUsername
? (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User>
{" joined, invited by "}
<User tabIndex={tabIndex}>{event.invitedByUsername}</User>
</Trans>
)
: (
<Trans>
<User tabIndex={tabIndex}>{event.username}</User> joined
</Trans>
);
}
}
return (
<ul
className="community-feed"
aria-label={i18n._(DIGEST_MESSAGES.community)}
>
{online.length > 0 && (
<li className="community-feed-item community-feed-item--online">
<Trans>
<span className="community-feed-label">Online now:</span> {names}
</Trans>
</li>
)}
{figures && (
<li className="community-feed-item community-feed-item--week">
<Trans>
<span className="community-feed-label">This week:</span>{" "}
<Link to={communityPath()} tabIndex={tabIndex}>{figures}</Link>
</Trans>
</li>
)}
{top && (
<li className="community-feed-item community-feed-item--top">
<Trans>
<span className="community-feed-label">Top this week:</span>{" "}
<Link to={dumpPath(top)} tabIndex={tabIndex}>{top.title}</Link>
</Trans>
</li>
)}
{feed.events.map((event) => (
<li
key={event.id}
className={`community-feed-item community-feed-item--${event.type}`}
>
{sentence(event)}{" "}
<time
className="community-feed-time"
dateTime={event.at.toISOString()}
>
{relativeTime(event.at)}
</time>
</li>
))}
</ul>
);
}

View File

@@ -36,6 +36,8 @@ export const WS_URL: string = API_URL
/** The community endpoint, for the community page. */
export const COMMUNITY_URL = `${API_URL}/api/community`;
/** The community feed's endpoint, for `CommunityFeedProvider`. */
export const COMMUNITY_FEED_URL = `${COMMUNITY_URL}/feed`;
/** The search endpoint, for the search page. */
export const SEARCH_URL = `${API_URL}/api/search`;
/** How many items a page of a feed or list asks the API for. */

44
src/config/imageSizes.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* States the width each of the site's images is laid out at, which App.css
* sets, so that `imageProps` asks the server for a copy no larger. A number
* is a fixed width in CSS pixels, a string a `sizes` attribute for a width
* that follows the viewport's. The components that show a thumbnail, a cover
* or a player's artwork take theirs from here; an avatar's is its `size`.
*
* @module
*/
import type { JournalShape } from "../utils/feed/journalLayout.ts";
/** A tile of the journal's grid spanning one of its columns. */
const JOURNAL_COLUMN =
"(max-width: 460px) 100vw, (max-width: 680px) 50vw, (max-width: 980px) 33vw, 290px";
/** A tile of the journal's grid spanning two of its columns. */
const JOURNAL_TWO_COLUMNS =
"(max-width: 680px) 100vw, (max-width: 980px) 66vw, 590px";
/** The width of each image, by the place it is shown. */
export const IMAGE_SIZES = {
/**
* A compact thumbnail at its widest, in a dump card's preview; the rows
* that show it smaller take the same copy.
*/
compactThumbnail: 128,
/** A link's thumbnail beside its text, above it on a narrow screen. */
richThumbnail: "(max-width: 512px) 100vw, 180px",
/** A journal tile's image, by the tile's shape. */
journal: {
feature: JOURNAL_TWO_COLUMNS,
wide: JOURNAL_TWO_COLUMNS,
tall: JOURNAL_COLUMN,
square: JOURNAL_COLUMN,
} satisfies Record<JournalShape, string>,
/** A playlist's cover in a row of a list. */
playlistThumb: 80,
/** A playlist's cover as a tile of the grid, two to a row when narrow. */
playlistTile: "(max-width: 512px) 50vw, 250px",
/** A playlist's cover as the banner of its page. */
playlistBanner: "(max-width: 860px) 100vw, 860px",
/** The artwork in the global player's header. */
playerArtwork: 36,
} as const;

View File

@@ -0,0 +1,36 @@
/**
* Hands the community feed, the latest things the site's members did in
* public, to whatever shows it: the GeoCities style's marquee, for now.
* `CommunityFeedProvider` provides this context and `useCommunityFeed` reads
* it.
*
* @module
*/
import { createContext } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
/** Provided by `CommunityFeedProvider` through {@link CommunityFeedContext}. */
export interface CommunityFeedContextValue {
/**
* Null until it is fetched, which waits for a first {@link watch}, and
* always for a visitor: the feed is for signed-in users.
*/
feed: CommunityFeed | null;
/**
* Says a component is showing the feed, until the returned call. The feed
* is fetched, and kept up to date, only while one is: most styles show it
* nowhere. `useCommunityFeed` calls it.
*/
watch: () => () => void;
}
/** Given to a consumer outside `CommunityFeedProvider`: no feed. */
export const defaultCommunityFeedValue: CommunityFeedContextValue = {
feed: null,
watch: () => () => {},
};
/** Provided by `CommunityFeedProvider`, and read with `useCommunityFeed`. */
export const CommunityFeedContext: React.Context<CommunityFeedContextValue> =
createContext(defaultCommunityFeedValue);

View File

@@ -0,0 +1,118 @@
/**
* Keeps the community feed for the components that show it, loaded from
* `GET /api/community/feed` and again whenever the socket tells of something
* the feed may hold. `App` mounts it around every page, so that the feed
* outlives the header it is shown in, which every page renders anew.
*
* @module
*/
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { Wire } from "../../../shared/api.ts";
import type { CommunityFeed } from "../../../shared/community.ts";
import { COMMUNITY_FEED_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useFetchState } from "../../hooks/shared/useFetchState.ts";
import { useOnReconnect, useWS, useWSEvent } from "../../hooks/ws/useWS.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializeCommunityFeed } from "../../utils/shared/deserialize.ts";
import {
CommunityFeedContext,
type CommunityFeedContextValue,
} from "./CommunityFeedContext.ts";
/** Passed to {@link CommunityFeedProvider} by `App`. */
export interface CommunityFeedProviderProps {
children: ReactNode;
}
// The feed is fetched again this long after the last change the socket told
// of, so that a run of upvotes costs one request.
const RELOAD_MS = 2000;
/**
* Provides {@link CommunityFeedContext}. It fetches the feed once a component
* watches it, for the signed-in user alone. The socket's messages don't carry
* a feed's lines, an upvote's naming neither its dump nor its voter, so each
* one that may change the feed has it fetched again, and the server's query
* stays the one place that decides what the feed holds. A change that comes
* while nothing watches is caught up with on the next watch.
*/
export function CommunityFeedProvider(
{ children }: CommunityFeedProviderProps,
): React.JSX.Element {
const { user, authFetch } = useAuth();
const [watched, setWatched] = useState(false);
const load = useCallback(
async (signal: AbortSignal) =>
deserializeCommunityFeed(
await expectOk<Wire<CommunityFeed>>(
await authFetch(COMMUNITY_FEED_URL, { signal }),
),
),
[authFetch],
);
const { state, reload } = useFetchState(user?.id, load, {
enabled: watched,
});
const watchers = useRef(0);
// A change came that the feed hasn't been fetched again for.
const due = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const refresh = useCallback(() => {
if (!due.current || watchers.current === 0) return;
due.current = false;
reload();
}, [reload]);
const changed = useCallback(() => {
due.current = true;
clearTimeout(timer.current);
timer.current = setTimeout(refresh, RELOAD_MS);
}, [refresh]);
useEffect(() => () => clearTimeout(timer.current), []);
useWSEvent("dump", changed);
useWSEvent("vote", changed);
useWSEvent("comment", (e) => e.type !== "updated" && changed());
useWSEvent("playlist", (e) => e.type !== "dumps_updated" && changed());
// A rename changes the names the feed writes.
useWSEvent("user", changed);
useOnReconnect(changed);
// A deleted dump comes as no event, only as a change of this set.
const { deletedDumpIds } = useWS();
const seenDeleted = useRef(deletedDumpIds);
useEffect(() => {
if (seenDeleted.current === deletedDumpIds) return;
seenDeleted.current = deletedDumpIds;
changed();
}, [deletedDumpIds, changed]);
const watch = useCallback(() => {
watchers.current++;
setWatched(true);
refresh();
return () => {
watchers.current--;
};
}, [refresh]);
const feed = state.status === "loaded" ? state.data : null;
const value: CommunityFeedContextValue = useMemo(
() => ({ feed, watch }),
[feed, watch],
);
return (
<CommunityFeedContext.Provider value={value}>
{children}
</CommunityFeedContext.Provider>
);
}

View File

@@ -62,7 +62,48 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* nyt */
/* geocities: what stands in for Comic Sans where the system has none (Linux,
Android), so it comes after it in the theme's stack and is only downloaded
there. The package has no latin-ext subset. */
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 400;
src: url(@fontsource/comic-neue/files/comic-neue-latin-400-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: normal;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Comic Neue';
font-style: italic;
font-display: swap;
font-weight: 700;
src: url(@fontsource/comic-neue/files/comic-neue-latin-700-italic.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* nyt: the headlines' serif, the text's (opsz as well as wght), the
interface's sans, and the nameplate's blackletter */
@font-face {
font-family: 'Instrument Serif';
font-style: normal;
@@ -99,6 +140,96 @@
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-ext-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Newsreader Variable';
font-style: italic;
font-display: swap;
font-weight: 200 800;
src: url(@fontsource-variable/newsreader/files/newsreader-latin-opsz-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-ext-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Libre Franklin Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/libre-franklin/files/libre-franklin-latin-wght-italic.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-ext-400-normal.woff2)
format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Manufacturing Consent';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(@fontsource/manufacturing-consent/files/manufacturing-consent-latin-400-normal.woff2)
format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* kanagawa */
@font-face {
@@ -138,40 +269,23 @@
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* melange: opsz, SOFT and WONK axes as well as wght */
/* melange: the weight axis alone, which leaves the face at its text optical
size for a headline too. The family has no italic. */
@font-face {
font-family: 'Fraunces Variable';
font-family: 'Bricolage Grotesque Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-normal.woff2)
font-weight: 200 800;
src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-ext-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces Variable';
font-family: 'Bricolage Grotesque Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-ext-full-italic.woff2)
format('woff2-variations');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces Variable';
font-style: italic;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/fraunces/files/fraunces-latin-full-italic.woff2)
font-weight: 200 800;
src: url(@fontsource-variable/bricolage-grotesque/files/bricolage-grotesque-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}

View File

@@ -12,6 +12,7 @@ import { AuthContext } from "../../contexts/auth/AuthContext.ts";
import type { AuthResponse, SelfUser } from "../../../shared/user.ts";
import type { UserSettings } from "../../../shared/settings.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { takeEarly } from "../../utils/shared/earlyFetch.ts";
import {
forgetStoredSession,
isTokenExpired,
@@ -113,7 +114,13 @@ export function useAuth(): UseAuthResult {
const isFormData = init.body instanceof FormData;
const res = await fetch(input, {
// The page a visit lands on finds its first request already sent, as the
// app booted (utils/shared/earlyFetch.ts).
const early = typeof input === "string" &&
(init.method ?? "GET") === "GET"
? takeEarly(input, token)
: undefined;
const res = await (early ?? fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
@@ -121,7 +128,7 @@ export function useAuth(): UseAuthResult {
// The browser sets FormData's Content-Type, with its boundary.
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
});
}));
if (res.status === 401) {
logout();

View File

@@ -51,7 +51,8 @@ export function useRoomBeside(
// The tabs are observed too, since their width changes without their
// box's (a web font arriving, a tab added at sign-in), and re-observed as
// they come and go.
// they come and go. They are the children of a child, the row that
// scrolls them, when a scope switcher comes before it.
const resize = new ResizeObserver(measure);
const mutation = new MutationObserver(observe);
function observe() {
@@ -60,8 +61,13 @@ export function useRoomBeside(
resize.observe(container!);
const kept = container!.querySelector<HTMLElement>(beside!);
if (kept) {
for (const child of kept.children) resize.observe(child);
mutation.observe(kept, { childList: true });
for (const child of kept.children) {
resize.observe(child);
if (scrolls(child)) {
for (const inner of child.children) resize.observe(inner);
}
}
mutation.observe(kept, { childList: true, subtree: true });
}
measure();
}
@@ -75,17 +81,26 @@ export function useRoomBeside(
return room;
}
/** Whether `el` scrolls its content sideways, as the row of tabs does. */
function scrolls(el: Element): boolean {
const overflow = getComputedStyle(el).overflowX;
return overflow === "auto" || overflow === "scroll";
}
/**
* Returns how wide the content of `el` runs, from its first child to its last,
* plus its padding. Its box won't do, since a flex row may squeeze it (the tabs
* then scroll) or stretch it, and neither will its scrollWidth, which is at
* least the box.
* least the box. A last child that scrolls counts the same way, at its own
* content's width: it is the one squeezed when a scope switcher sits before
* the tabs, outside the row that scrolls them.
*/
function contentWidth(el: HTMLElement): number {
function contentWidth(el: Element): number {
const first = el.firstElementChild, last = el.lastElementChild;
if (!first || !last) return 0;
const cs = getComputedStyle(el);
return last.getBoundingClientRect().right -
const end = last.getBoundingClientRect();
return (scrolls(last) ? end.left + contentWidth(last) : end.right) -
first.getBoundingClientRect().left + parseFloat(cs.paddingLeft) +
parseFloat(cs.paddingRight);
}

View File

@@ -0,0 +1,21 @@
/**
* Gives a component the community feed, by reading `CommunityFeedContext`.
*
* @module
*/
import { useContext, useEffect } from "react";
import type { CommunityFeed } from "../../../shared/community.ts";
import { CommunityFeedContext } from "../../contexts/social/CommunityFeedContext.ts";
/**
* Returns the community feed, kept up to date for as long as the component is
* mounted, or null until it is loaded and for a visitor. The `CommunityFeed`
* component renders it. Needs `CommunityFeedProvider` above, which `App`
* mounts around every page.
*/
export function useCommunityFeed(): CommunityFeed | null {
const { feed, watch } = useContext(CommunityFeedContext);
useEffect(() => watch(), [watch]);
return feed;
}

View File

@@ -11,6 +11,8 @@ import { isLocale, type Locale } from "../shared/locale.ts";
/**
* Returns the stored locale, else French for a French browser, else English.
* The built page asks for that locale's messages ahead of this module, by the
* same rule (`preloadCatalog` in vite.config.ts): the two must agree.
*/
function detectLocale(): Locale {
const stored = localStorage.getItem("locale");

View File

@@ -1,6 +1,7 @@
/**
* Starts the frontend. It loads the stylesheets and the interface's language,
* then mounts `App` on index.html's `#root`.
* sends the first request of the page the visit lands on, then mounts `App`
* on index.html's `#root`.
*
* A deploy renames every chunk, so a tab opened before it fails to load the
* lazy chunks it hasn't fetched yet. It then reloads to pick up the new build,
@@ -17,6 +18,7 @@ import { I18nProvider } from "@lingui/react";
import { App } from "./App.tsx";
import { loadCatalog } from "./i18n.ts";
import { startLandingRequest } from "./utils/shared/earlyFetch.ts";
import "./fonts.css";
import "./tokens.css";
@@ -42,6 +44,10 @@ globalThis.addEventListener("vite:preloadError", (event) => {
location.reload();
});
// The page this visit lands on is loaded with its route, and asks the API for
// its content once loaded: that request leaves now, with the page's code.
startLandingRequest(location.pathname);
// Before the first render, or it would show untranslated message ids.
await loadCatalog();

View File

@@ -33,12 +33,15 @@ import {
deserializePlaylist,
deserializePublicUser,
} from "../utils/shared/deserialize.ts";
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
import {
expectOk,
friendlyFetchError,
parseAPIResponse,
} from "../utils/shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useOnReconnect, useWS, useWSEvent } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { Avatar } from "../components/shared/Avatar.tsx";
@@ -52,6 +55,7 @@ import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
import { CommentThread } from "../components/comment/CommentThread.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { InlineEdit } from "../components/form/InlineEdit.tsx";
import { userPath } from "../../shared/paths.ts";
import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts";
@@ -67,9 +71,10 @@ type DumpState =
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
* slug, the URL takes the current ones. Its comments, related dumps and
* playlists come from their own endpoints once it is loaded, and the socket's
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
* views it gives way to a notice. The poster and moderators can edit the
* title in place, and get a link to `DumpEdit`.
* events keep them current; a reconnection fetches them and the dump again.
* A dump deleted, or made private by its poster, while someone else views it
* gives way to a notice. The poster and moderators can edit the title and the
* note in place, and get a link to `DumpEdit` for the rest.
*/
export function Dump(): React.JSX.Element {
const { handle, slug } = useParams();
@@ -129,12 +134,6 @@ export function Dump(): React.JSX.Element {
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, mediaToken, authFetch } = useAuth();
const {
voteCounts,
@@ -409,45 +408,23 @@ export function Dump(): React.JSX.Element {
const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate"));
const startTitleEdit = () => {
setTitleDraft(dump.title);
setTitleError(null);
setTitleEditing(true);
};
const handleTitleSave = async () => {
const trimmed = titleDraft.trim();
if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return;
setTitleSaving(true);
setTitleError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(
{ title: trimmed } satisfies UpdateDumpRequest,
),
// Saves what the title or the note was changed to in place. The page stays
// mounted across a slug change, and would keep showing the dump it had.
const saveDump = async (changes: UpdateDumpRequest) => {
const updatedDump = deserializeDump(
await expectOk<Wire<Dump>>(
await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(changes),
}),
),
);
setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
const apiResponse = parseAPIResponse<Wire<Dump>>(await res.json());
if (!apiResponse.success) {
setTitleError(apiResponse.error.message);
return;
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
// The page stays mounted across the slug change below, and would keep
// the old title.
setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
}
} catch (err) {
setTitleError(friendlyFetchError(err));
} finally {
setTitleSaving(false);
}
};
@@ -475,79 +452,18 @@ export function Dump(): React.JSX.Element {
</button>
)}
</div>
<div className="dump-header-right" ref={headerRightRef}>
{titleEditing
? (
<div className="dump-title-editor animate-in">
<input
type="text"
className="dump-title-input"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleSave();
} else if (e.key === "Escape") {
setTitleEditing(false);
}
}}
maxLength={VALIDATION.DUMP_TITLE_MAX}
disabled={titleSaving}
autoFocus
/>
<div className="dump-title-actions">
<button
type="button"
className="btn-primary"
onClick={handleTitleSave}
disabled={titleSaving || !titleDraft.trim()}
>
{titleSaving
? <Trans>Saving…</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setTitleEditing(false)}
disabled={titleSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{titleError && (
<p className="dump-title-error">{titleError}</p>
)}
</div>
)
: (
<div
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit ? startTitleEdit : undefined}
// Reachable from the keyboard too; not a button, so the
// title inside stays a heading.
tabIndex={canEdit ? 0 : undefined}
title={canEdit ? t`Edit title` : undefined}
onKeyDown={canEdit
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startTitleEdit();
}
}
: undefined}
>
<h1 className="dump-title">{dump.title}</h1>
{canEdit && (
<span className="dump-title-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
<div className="dump-header-right">
<InlineEdit
value={dump.title}
editable={canEdit}
label={t`Edit title`}
onSave={(title) => saveDump({ title })}
required
maxLength={VALIDATION.DUMP_TITLE_MAX}
fieldClassName="dump-title"
>
<h1 className="dump-title">{dump.title}</h1>
</InlineEdit>
<div className="dump-op">
<Avatar
userId={dump.userId}
@@ -587,8 +503,25 @@ export function Dump(): React.JSX.Element {
</div>
</div>
{dump.comment && (
<Markdown className="dump-comment">{dump.comment}</Markdown>
{(dump.comment || canEdit) && (
<InlineEdit
value={dump.comment ?? ""}
editable={canEdit}
label={t`Edit note`}
onSave={(comment) => saveDump({ comment: comment || null })}
kind="markdown"
maxLength={VALIDATION.DUMP_COMMENT_MAX}
placeholder={t`What makes it worth it?`}
>
{dump.comment
? <Markdown className="dump-comment">{dump.comment}</Markdown>
: (
<p className="inline-edit-empty">
<Trans>What makes it worth it?</Trans>
</p>
)}
</InlineEdit>
)}
</div>

View File

@@ -14,8 +14,7 @@ import { FeedTabBar } from "../components/feed/FeedTabBar.tsx";
import { type FeedTab, type FollowView } from "../../shared/paths.ts";
import { useViewerDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL } from "../config/api.ts";
import { feedApiUrl } from "../utils/shared/urls.ts";
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
import type { Dump } from "../../shared/dump.ts";
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
@@ -84,9 +83,7 @@ export function Index(
const defaultTab = useViewerDefaultFeedTab();
const tab: FeedTab = feedTab ?? defaultTab;
const feedUrl = categorySlug
? `${API_URL}/api/dumps?category=${encodeURIComponent(categorySlug)}`
: `${API_URL}/api/dumps`;
const feedUrl = feedApiUrl(categorySlug);
const fetchPage = useCallback(
(page: number, signal?: AbortSignal) =>
authFetch(pageUrl(feedUrl, page), { signal })
@@ -193,7 +190,7 @@ export function Index(
<div className="header-center-slot">
<PresenceRow
fitWithin=".app-header-center"
fitBeside=".feed-sort"
fitBeside=".feed-sort-scroller"
/>
<FeedTabBar
categorySlug={categorySlug}
@@ -212,7 +209,10 @@ export function Index(
tab={tab}
followView={followView}
/>
<PresenceRow fitWithin=".index-below-header" fitBeside=".feed-sort" />
<PresenceRow
fitWithin=".index-below-header"
fitBeside=".feed-sort-scroller"
/>
</div>
{(tab === "hot" || tab === "new") && (

View File

@@ -36,7 +36,8 @@ export interface PlaylistDetailProps {
* a private one only to its owner. `usePlaylist` loads it, by a former name or
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
* can drag its dumps into a new order and remove one, with a short window to
* undo, and edit or delete it in the editor that opens over its header at
* undo, edit its title and description in place, and edit the rest or delete
* it in the editor that opens over its header at
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
* place, so opening the editor keeps the playlist loaded. Anyone else there
* gets the page, or is asked to sign in first.
@@ -143,9 +144,10 @@ export function PlaylistDetail(
<div className="playlist-detail-header-top">
<PlaylistHeader
playlist={playlist}
onEdit={canEdit
? () => navigate(playlistUrl(playlist, "edit"))
: undefined}
{...(canEdit && {
onEdit: () => navigate(playlistUrl(playlist, "edit")),
onSaved: applyDetails,
})}
/>
</div>
)}

74
src/test/App.test.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { route } from "./fetch.ts";
import { makeCategory, makeSession, page } from "./fixtures.ts";
import { currentPath, renderPage } from "./render.tsx";
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
const next = (path: string) => `/login?next=${encodeURIComponent(path)}`;
function open(url: string, session: ReturnType<typeof makeSession> | null) {
route("GET /api/dumps", () => page([]));
renderPage(url, { session, categories: [music] });
}
describe("App's routes", () => {
test.each([
"/",
"/hot",
"/new",
"/journal",
"/follow",
"/follow/playlists",
"/music",
"/music/new",
"/music/follow",
"/music/follow/playlists",
])("%s is a feed", async (url) => {
open(url, makeSession());
// The tab bar, which the feed draws at two widths.
expect(await screen.findAllByRole("button", { name: "Followed" })).not
.toHaveLength(0);
expect(currentPath()).toBe(url);
expect(screen.queryByRole("heading", { name: "404" })).toBeNull();
});
test.each([
"/hot/more",
"/follow/users",
"/follow/nope",
"/follow/playlists/more",
"/nope",
"/music/nope",
"/music/hot/more",
"/music/follow/users",
"/search/users/more",
])("%s is not found", async (url) => {
open(url, makeSession());
expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
});
test.each([
"/follow",
"/follow/playlists",
"/music/follow/playlists",
"/notifications",
"/settings",
"/manage",
"/community",
"/community/monthly",
"/@ann/dumps/a-find/edit",
])("%s sends a guest to sign in, and back", async (url) => {
open(url, null);
await waitFor(() => expect(currentPath()).toBe(next(url)));
});
test.each(["/login", "/register"])(
"%s sends a signed-in user home",
async (url) => {
open(url, makeSession());
await waitFor(() => expect(currentPath()).toBe("/"));
},
);
});

8
src/test/before-react.ts Normal file
View File

@@ -0,0 +1,8 @@
// Runs before every test file, and before setup.ts, whose imports load React:
// what jsdom leaves out that React looks for as it loads.
// Finding no AnimationEvent, React listens for the animation events under
// their old WebKit names, and `fireEvent.animationIteration` reaches no
// handler.
(globalThis as { AnimationEvent?: unknown }).AnimationEvent ??=
class AnimationEvent extends Event {};

View File

@@ -191,6 +191,21 @@ describe("CommentThread", () => {
expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
});
test("a refused edit stays open and says why; Escape goes back to Edit", async () => {
route("PATCH /api/comments/:id", () => fail("Too spicy", 400));
const user = setup([makeComment({ id: "a", userId: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.type(
screen.getByDisplayValue("old"),
"er{Control>}{Enter}{/Control}",
);
expect(await screen.findByRole("alert")).toHaveTextContent("Too spicy");
expect(screen.getByDisplayValue("older")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByText("old")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toHaveFocus();
});
test("only the author or a moderator can edit and delete", () => {
const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }),

View File

@@ -35,7 +35,12 @@ describe("FilePreview", () => {
const d = file("image/png");
renderWithContexts(<FilePreview dump={d} compact />, { session });
const img = screen.getByRole("img", { name: "thing" });
expect(img).toHaveAttribute("src", dumpThumbnailUrl(d, session.mediaToken));
// The copy scaled to a card's preview, on a dense screen.
expect(img).toHaveAttribute(
"src",
`${dumpThumbnailUrl(d, session.mediaToken)}&w=320`,
);
expect(img).toHaveAttribute("sizes", "128px");
expect(img).toHaveAttribute("loading", "lazy");
});
@@ -101,7 +106,7 @@ describe("FilePreview", () => {
);
expect(container.querySelector("button img")).toHaveAttribute(
"src",
dumpThumbnailUrl(d),
`${dumpThumbnailUrl(d)}&w=320`,
);
});

View File

@@ -123,8 +123,9 @@ describe("JournalCard", () => {
<JournalCard dump={dump} shape="tall" {...base} />
</ul>,
);
expect(container.querySelector("img")!.getAttribute("src")).toBe(
dumpThumbnailUrl(dump),
);
const img = container.querySelector("img")!;
expect(img.getAttribute("src")).toBe(`${dumpThumbnailUrl(dump)}&w=960`);
// A tile of one column: the browser takes a narrower copy for it.
expect(img.getAttribute("sizes")).toContain("290px");
});
});

View File

@@ -75,7 +75,7 @@ describe("RichContentCard", () => {
);
expect(screen.getByRole("img", { name: "A video" })).toHaveAttribute(
"src",
"http://localhost:8000/api/dumps/d/thumbnail",
"http://localhost:8000/api/dumps/d/thumbnail?w=960",
);
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { InlineEdit } from "../../../components/form/InlineEdit.tsx";
function renderEdit(
props: Partial<React.ComponentProps<typeof InlineEdit>> = {},
) {
const onSave = vi.fn(async (_: string) => {});
const user = userEvent.setup();
render(
<I18nProvider i18n={i18n}>
<InlineEdit
value="Old"
editable
label="Edit name"
onSave={onSave}
required
{...props}
>
<h2>
Old <a href="#elsewhere">link</a>
</h2>
</InlineEdit>
</I18nProvider>,
);
return { user, onSave };
}
describe("InlineEdit", () => {
test("without the right to edit, only the text shows", () => {
renderEdit({ editable: false });
expect(screen.getByRole("heading")).toBeInTheDocument();
expect(screen.queryByRole("button")).toBeNull();
});
test("a click on the text opens the field; Enter saves it trimmed", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("heading"));
const input = screen.getByRole("textbox", { name: "Edit name" });
expect(input).toHaveFocus();
await user.type(input, "er {Enter}");
expect(onSave).toHaveBeenCalledWith("Older");
expect(screen.getByRole("heading")).toBeInTheDocument();
});
test("a link in the text is followed, not edited", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("link", { name: "link" }));
expect(screen.queryByRole("textbox")).toBeNull();
});
test("left as it was, nothing is sent", async () => {
const { user, onSave } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).not.toHaveBeenCalled();
expect(screen.queryByRole("textbox")).toBeNull();
});
test("an empty required text can't be saved", async () => {
const { user } = renderEdit();
await user.click(screen.getByRole("button", { name: "Edit name" }));
await user.clear(screen.getByRole("textbox"));
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
test("Escape backs out, and the focus goes back to the ✎", async () => {
const { user, onSave } = renderEdit();
screen.getByRole("button", { name: "Edit name" }).focus();
await user.keyboard("{Enter}");
await user.type(screen.getByRole("textbox"), "x{Escape}");
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Edit name" })).toHaveFocus();
});
test("a failed save stays open and says why", async () => {
const { user } = renderEdit({
onSave: () => Promise.reject(new Error("Taken")),
});
await user.click(screen.getByRole("heading"));
await user.type(screen.getByRole("textbox"), "!{Enter}");
expect(await screen.findByRole("alert")).toHaveTextContent("Taken");
expect(screen.getByRole("textbox")).toHaveValue("Old!");
});
});

View File

@@ -0,0 +1,105 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import type { CommunityFeed } from "../../../../shared/community.ts";
import { Marquee } from "../../../components/layout/Marquee.tsx";
import { CommunityFeedContext } from "../../../contexts/social/CommunityFeedContext.ts";
import { ThemeContext } from "../../../contexts/theme/ThemeContext.ts";
import type { StyleName } from "../../../../shared/theme.ts";
import { makeCommunityFeed, makeSession } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
const joined = (username: string) =>
makeCommunityFeed({
events: [{
id: `user_joined:${username}`,
at: new Date(),
type: "user_joined",
username,
}],
});
function marquee(feed: CommunityFeed | null, style: StyleName = "geocities") {
return (
<ThemeContext.Provider
value={{
style,
colorScheme: "light",
setStyle() {},
setColorScheme() {},
}}
>
<CommunityFeedContext.Provider value={{ feed, watch: () => () => {} }}>
<Marquee />
</CommunityFeedContext.Provider>
</ThemeContext.Provider>
);
}
const session = makeSession({ username: "me" });
const track = () => document.querySelector(".site-marquee-track");
afterEach(() => vi.restoreAllMocks());
describe("Marquee", () => {
test("draws nothing in another style, nor for a visitor", () => {
const band = () => document.querySelector(".site-marquee");
const { rerender, unmount } = renderWithContexts(
marquee(joined("ann"), "smooth"),
{ session },
);
expect(band()).toBeNull();
rerender(marquee(joined("ann")));
expect(band()).toBeInTheDocument();
unmount();
renderWithContexts(marquee(joined("ann")));
expect(band()).toBeNull();
});
test("an empty band until the feed is loaded, then the feed twice over", () => {
const { rerender } = renderWithContexts(marquee(null), { session });
expect(document.querySelector(".site-marquee")).toBeEmptyDOMElement();
rerender(marquee(joined("ann")));
// The second copy is the loop's: neither read out nor tabbed to.
expect(screen.getAllByRole("link", { name: "ann" })).toHaveLength(1);
const [first, second] = [...document.querySelectorAll("a[href='/@ann']")];
expect(first).not.toHaveAttribute("tabindex");
expect(second).toHaveAttribute("tabindex", "-1");
expect(second.closest("[aria-hidden='true']")).toBeInTheDocument();
});
test("what changes mid-crawl waits for the next loop", () => {
const { rerender } = renderWithContexts(marquee(joined("ann")), {
session,
ws: { onlineUsers: [] },
});
rerender(marquee(joined("bob")));
expect(screen.getByRole("link", { name: "ann" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "bob" })).toBeNull();
const before = track()!;
fireEvent.animationIteration(before);
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "ann" })).toBeNull();
// A new loop is a new track, whose animation starts over.
expect(track()).not.toBe(before);
});
test("with reduced motion, the line shows once and follows the feed", () => {
vi.spyOn(globalThis, "matchMedia").mockImplementation((query) =>
({
matches: query.includes("reduce"),
media: query,
addEventListener() {},
removeEventListener() {},
}) as unknown as MediaQueryList
);
const { rerender } = renderWithContexts(marquee(joined("ann")), {
session,
});
expect(document.querySelectorAll("a[href='/@ann']")).toHaveLength(1);
rerender(marquee(joined("bob")));
expect(screen.getByRole("link", { name: "bob" })).toBeInTheDocument();
});
});

View File

@@ -5,14 +5,17 @@ import { AvatarOverrideContext } from "../../../contexts/ws/AvatarOverrideContex
import { renderWithContexts } from "../../render.tsx";
describe("Avatar", () => {
test("an image when the user has one, versioned", () => {
test("an image when the user has one, versioned, at the size shown", () => {
renderWithContexts(
<Avatar userId="u1" username="ann" hasAvatar version={7} />,
<Avatar userId="u1" username="ann" hasAvatar version={7} size={32} />,
);
expect(screen.getByRole("img", { name: "ann" })).toHaveAttribute(
const img = screen.getByRole("img", { name: "ann" });
expect(img).toHaveAttribute(
"src",
"http://localhost:8000/api/users/u1/avatar?v=7",
"http://localhost:8000/api/users/u1/avatar?v=7&w=128",
);
expect(img).toHaveAttribute("sizes", "32px");
expect(img.getAttribute("srcset")).toContain("avatar?v=7&w=320 320w");
});
test("initials otherwise, and after the image fails", () => {

View File

@@ -11,17 +11,20 @@ describe("Thumbnail", () => {
<Thumbnail
src="https://img.test/a.png"
alt="art"
sizes={128}
placeholder={placeholder}
/>,
);
const img = screen.getByRole("img", { name: "art" });
expect(img).toHaveAttribute("src", "https://img.test/a.png");
expect(img).toHaveAttribute("referrerpolicy", "no-referrer");
// Another site's image comes as that site sends it.
expect(img).not.toHaveAttribute("srcset");
fireEvent.error(img);
expect(screen.getByRole("img", { name: "art" }).getAttribute("src"))
.toContain(
"/api/remote/image?url=",
);
const proxied = screen.getByRole("img", { name: "art" });
expect(proxied.getAttribute("src")).toContain("/api/remote/image?url=");
// Through the proxy, it is scaled as the site's own are.
expect(proxied.getAttribute("srcset")).toContain("a.png&w=128 128w");
fireEvent.error(screen.getByRole("img", { name: "art" }));
expect(screen.queryByRole("img", { name: "art" })).not.toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
@@ -32,16 +35,24 @@ describe("Thumbnail", () => {
<Thumbnail
src="http://localhost:8000/api/dumps/d/thumbnail"
alt="t"
sizes="(max-width: 512px) 100vw, 180px"
placeholder={placeholder}
/>,
);
fireEvent.error(screen.getByRole("img", { name: "t" }));
const img = screen.getByRole("img", { name: "t" });
expect(img).toHaveAttribute(
"src",
"http://localhost:8000/api/dumps/d/thumbnail?w=960",
);
expect(img.getAttribute("srcset")).toContain("thumbnail?w=320 320w");
expect(img).toHaveAttribute("sizes", "(max-width: 512px) 100vw, 180px");
fireEvent.error(img);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
});
test("no src: straight to the placeholder; a new src tries again", () => {
const { rerender } = renderWithContexts(
<Thumbnail placeholder={placeholder} alt="t" />,
<Thumbnail placeholder={placeholder} alt="t" sizes={128} />,
);
expect(screen.queryByRole("img", { name: "t" })).not.toBeInTheDocument();
rerender(
@@ -49,6 +60,7 @@ describe("Thumbnail", () => {
src="https://img.test/b.png"
placeholder={placeholder}
alt="t"
sizes={128}
/>,
);
expect(screen.getByRole("img", { name: "t" })).toBeInTheDocument();

View File

@@ -0,0 +1,118 @@
import { describe, expect, test } from "vitest";
import { screen, within } from "@testing-library/react";
import { CommunityFeed } from "../../../components/social/CommunityFeed.tsx";
import { makeCommunityFeed } from "../../fixtures.ts";
import { renderWithContexts } from "../../render.tsx";
const at = new Date(Date.now() - 2 * 3_600_000);
const hit = { title: "Big hit", slug: "big-hit", ownerUsername: "ann" };
const online = (...names: string[]) =>
names.map((username) => ({ userId: username, username, hasAvatar: false }));
describe("CommunityFeed", () => {
test("a line for who is online, the week and its top dump, then one per event", () => {
renderWithContexts(
<CommunityFeed
online={online("ann", "bob")}
feed={makeCommunityFeed({
week: {
totals: { dumps: 1284, upvotes: 1, comments: 0 },
chatMessages: 3,
top: hit,
},
events: [
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
{ id: "b", at, type: "dump_upvoted", username: "bob", dump: hit },
{
id: "c",
at,
type: "dump_commented",
username: "cy",
dump: hit,
commentId: "c9",
},
{
id: "d",
at,
type: "playlist_created",
username: "dee",
playlist: { title: "Mix", slug: "mix", ownerUsername: "dee" },
},
{
id: "e",
at,
type: "user_joined",
username: "eve",
invitedByUsername: "ann",
},
{ id: "f", at, type: "user_joined", username: "fay" },
],
})}
/>,
);
const lines = within(screen.getByRole("list", { name: "Community" }))
.getAllByRole("listitem").map((li) => li.textContent);
expect(lines).toEqual([
"Online now: ann, bob",
// A figure at zero is left out.
"This week: 1,284 dumps, 1 upvote, 3 chat messages",
"Top this week: Big hit",
"ann posted Big hit 2 hours ago",
"bob upvoted Big hit 2 hours ago",
"cy commented on Big hit 2 hours ago",
"dee created the playlist Mix 2 hours ago",
"eve joined, invited by ann 2 hours ago",
"fay joined 2 hours ago",
]);
const href = (name: string, n = 0) =>
screen.getAllByRole("link", { name })[n].getAttribute("href");
expect(href("bob")).toBe("/@bob");
expect(href("1,284 dumps, 1 upvote, 3 chat messages")).toBe("/community");
expect(href("Big hit")).toBe("/@ann/dumps/big-hit");
// The comment's line leads to the comment.
expect(href("Big hit", 3)).toBe("/@ann/dumps/big-hit#comment-c9");
expect(href("Mix")).toBe("/@dee/playlists/mix");
});
test("a line with nothing to say is left out", () => {
renderWithContexts(
<CommunityFeed online={[]} feed={makeCommunityFeed()} />,
);
expect(screen.queryAllByRole("listitem")).toEqual([]);
});
test("past eight online, the others are counted", () => {
renderWithContexts(
<CommunityFeed
online={online("a", "b", "c", "d", "e", "f", "g", "h", "i", "j")}
feed={makeCommunityFeed()}
/>,
);
expect(screen.getByRole("listitem")).toHaveTextContent(
"Online now: a, b, c, d, e, f, g, h +2",
);
});
test("tabIndex goes to every link", () => {
renderWithContexts(
<CommunityFeed
tabIndex={-1}
online={online("ann")}
feed={makeCommunityFeed({
week: {
totals: { dumps: 1, upvotes: 0, comments: 0 },
chatMessages: 0,
top: hit,
},
events: [
{ id: "a", at, type: "dump_posted", username: "ann", dump: hit },
],
})}
/>,
);
const links = screen.getAllByRole("link");
expect(links).toHaveLength(5);
for (const link of links) expect(link).toHaveAttribute("tabindex", "-1");
});
});

View File

@@ -0,0 +1,138 @@
import { describe, expect, test, vi } from "vitest";
import { act, render, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { CommunityFeedProvider } from "../../../contexts/social/CommunityFeedProvider.tsx";
import { useCommunityFeed } from "../../../hooks/social/useCommunityFeed.ts";
import { calls, route } from "../../fetch.ts";
import {
makeCommunityFeed,
makeDump,
makeSession,
storeSession,
wire,
} from "../../fixtures.ts";
import { liveWS } from "../../live.tsx";
const FEED = "GET /api/community/feed";
const joined = (username: string) =>
wire(makeCommunityFeed({
events: [{
id: `user_joined:${username}`,
at: new Date("2026-10-01T00:00:00Z"),
type: "user_joined",
username,
}],
}));
function setup({ signedIn = true } = {}) {
if (signedIn) {
const session = makeSession();
storeSession(session);
route("GET /api/me", () => wire(session.user));
}
const ws = liveWS();
const wrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>
<ws.Wrapper>
<CommunityFeedProvider>{children}</CommunityFeedProvider>
</ws.Wrapper>
</AuthProvider>
);
return { ws, wrapper };
}
describe("CommunityFeedProvider", () => {
test("loads the feed for a component that shows it", async () => {
route(FEED, () => joined("ann"));
const { wrapper } = setup();
const { result } = renderHook(useCommunityFeed, { wrapper });
expect(result.current).toBeNull();
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current!.events[0].username).toBe("ann");
expect(result.current!.events[0].at).toEqual(
new Date("2026-10-01T00:00:00Z"),
);
expect(calls(FEED)[0].headers.get("Authorization")).toMatch(/^Bearer /);
});
test("asks for nothing while no component shows it", async () => {
route(FEED, () => joined("ann"));
const { wrapper: Wrapper } = setup();
render(<Wrapper>a page that shows no feed</Wrapper>);
await act(async () => {});
expect(calls(FEED)).toHaveLength(0);
});
test("a visitor has no feed, and never asks", async () => {
route(FEED, () => joined("ann"));
const { wrapper } = setup({ signedIn: false });
const { result } = renderHook(useCommunityFeed, { wrapper });
await act(async () => {});
expect(result.current).toBeNull();
expect(calls(FEED)).toHaveLength(0);
});
test("fetches it again once the socket's changes have settled", async () => {
let name = "ann";
route(FEED, () => joined(name));
const { ws, wrapper } = setup();
const { result } = renderHook(useCommunityFeed, { wrapper });
await waitFor(() => expect(result.current).not.toBeNull());
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
name = "bob";
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
act(() => vi.advanceTimersByTime(1500));
ws.emit("dump", { type: "created", dump: makeDump() });
act(() => vi.advanceTimersByTime(1500));
// The second change put the fetch off.
expect(calls(FEED)).toHaveLength(1);
act(() => vi.advanceTimersByTime(500));
expect(calls(FEED)).toHaveLength(2);
// What changes no line of the feed fetches nothing.
ws.emit("comment", { type: "updated", dumpId: "d" });
ws.emit("playlist", { type: "dumps_updated", playlistId: "p" });
act(() => vi.advanceTimersByTime(5000));
expect(calls(FEED)).toHaveLength(2);
// A deleted dump and a reconnection do.
ws.set({ deletedDumpIds: new Set(["d"]) });
act(() => vi.advanceTimersByTime(2000));
expect(calls(FEED)).toHaveLength(3);
ws.set({ connectionEpoch: 1 });
act(() => vi.advanceTimersByTime(2000));
expect(calls(FEED)).toHaveLength(4);
} finally {
vi.useRealTimers();
}
await waitFor(() => expect(result.current!.events[0].username).toBe("bob"));
});
test("a change that came while nothing showed the feed is caught up with", async () => {
route(FEED, () => joined("ann"));
const { ws, wrapper } = setup();
// The provider stays, as it does under the pages: the consumer comes and
// goes.
const Show = () => {
useCommunityFeed();
return null;
};
const view = render(<Show />, { wrapper });
await waitFor(() => expect(calls(FEED)).toHaveLength(1));
view.rerender(null);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
ws.emit("vote", { dumpId: "d", voterId: "u", action: "cast" });
act(() => vi.advanceTimersByTime(5000));
expect(calls(FEED)).toHaveLength(1);
} finally {
vi.useRealTimers();
}
view.rerender(<Show />);
await waitFor(() => expect(calls(FEED)).toHaveLength(2));
});
});

View File

@@ -3,6 +3,7 @@ import type { Session } from "../utils/auth/storage.ts";
import type { Category } from "../../shared/category.ts";
import type { ChatMessage } from "../../shared/chat.ts";
import type { Comment } from "../../shared/comment.ts";
import type { CommunityFeed } from "../../shared/community.ts";
import type { Dump } from "../../shared/dump.ts";
import type { Notification } from "../../shared/notification.ts";
import type { Playlist } from "../../shared/playlist.ts";
@@ -142,6 +143,17 @@ export function makeNotification(
};
}
/** A feed with nothing in it: a test gives it the lines it reads. */
export function makeCommunityFeed(
over: Partial<CommunityFeed> = {},
): CommunityFeed {
return {
events: [],
week: { totals: { dumps: 0, upvotes: 0, comments: 0 }, chatMessages: 0 },
...over,
};
}
/** A value as it crosses the wire: dates become ISO strings. */
export function wire<T>(value: T): T {
return JSON.parse(JSON.stringify(value));

View File

@@ -7,6 +7,9 @@ import { useAuth, useRequiredAuth } from "../../../hooks/auth/useAuth.ts";
import { AUTH_STORAGE_KEY } from "../../../utils/auth/storage.ts";
import { calls, route } from "../../fetch.ts";
import { makeSession, makeToken, storeSession } from "../../fixtures.ts";
import { startLandingRequest } from "../../../utils/shared/earlyFetch.ts";
import { feedApiUrl } from "../../../utils/shared/urls.ts";
import { pageUrl } from "../../../utils/feed/pagedList.ts";
function wrapperFor(initial: Session | null) {
return function Wrapper({ children }: { children: ReactNode }) {
@@ -103,6 +106,19 @@ describe("authFetch", () => {
);
});
test("a GET the app started as it booted is handed over, not sent again", async () => {
route("GET /api/dumps", () => ({ items: ["early"], hasMore: false }));
startLandingRequest("/");
const feed = pageUrl(feedApiUrl(), 1);
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });
const res = await result.current.authFetch(feed);
expect((await res.json()).data.items).toEqual(["early"]);
expect(calls("GET /api/dumps")).toHaveLength(1);
// Once: the next one is the page's own.
await result.current.authFetch(feed);
expect(calls("GET /api/dumps")).toHaveLength(2);
});
test("no Authorization header when signed out", async () => {
route("GET /api/open", () => ({}));
const { result } = renderHook(useAuth, { wrapper: wrapperFor(null) });

View File

@@ -1,11 +1,9 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { Authorize } from "../../pages/Authorize.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeSession } from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { currentPath, renderPage } from "../render.tsx";
const QUERY =
"?response_type=code&client_id=https%3A%2F%2Fmusic.example&redirect_uri=https%3A%2F%2Fmusic.example%2Ffederation%2Fcallback&state=s1&code_challenge=c1&code_challenge_method=S256&nonce=n1";
@@ -24,12 +22,7 @@ describe("Authorize", () => {
function open(session = ann) {
const user = userEvent.setup();
renderApp(<Authorize />, {
route: `/authorize${QUERY}`,
path: "/authorize",
session,
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
renderPage(`/authorize${QUERY}`, { session });
return user;
}
@@ -78,12 +71,8 @@ describe("Authorize", () => {
});
test("a signed-out visitor logs in first and comes back", async () => {
renderApp(<Authorize />, {
route: `/authorize${QUERY}`,
path: "/authorize",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
});
await screen.findByText("login page");
renderPage(`/authorize${QUERY}`);
await screen.findByRole("heading", { name: "Log in" });
expect(currentPath()).toBe(
`/login?next=${encodeURIComponent(`/authorize${QUERY}`)}`,
);

View File

@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Community } from "../../pages/Community.tsx";
import { calls, route } from "../fetch.ts";
import {
makeDump,
@@ -10,7 +9,7 @@ import {
makeUser,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { currentPath, renderPage } from "../render.tsx";
function stats(
{
@@ -35,7 +34,7 @@ function stats(
function open(url: string) {
const user = userEvent.setup();
const session = makeSession({ username: "ann" });
renderApp(<Community />, { route: url, path: "/community/*", session });
renderPage(url, { session });
return user;
}

View File

@@ -1,10 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Link, Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
import { DumpEdit } from "../../pages/DumpEdit.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
@@ -15,7 +11,7 @@ import {
makeUser,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
const owner = makeSession({ id: "owner", username: "owner" });
@@ -48,15 +44,7 @@ function openDump(
url = "/@owner/dumps/a-find",
) {
const user = userEvent.setup();
renderApp(<Dump />, {
route: url,
path: "/:handle/dumps/:slug",
session,
categories: [music],
extraRoutes: (
<Route path="/:handle/dumps/:slug/edit" element={<p>edit page</p>} />
),
});
renderPage(url, { session, categories: [music] });
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
@@ -147,6 +135,35 @@ describe("Dump page", () => {
expect(screen.getByRole("heading", { name: "A find" })).toBeInTheDocument();
});
test("the owner writes the note in place; emptied, it goes", async () => {
dumpRoutes();
route(
"PUT /api/dumps/:id",
() =>
wire(makeDump({
id: "d1",
slug: "a-find",
title: "A find",
userId: "owner",
comment: undefined,
})),
);
const { user } = openDump(owner);
await user.click(await screen.findByText("reasons"));
await user.clear(screen.getByDisplayValue("Because **reasons**"));
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("What makes it worth it?"))
.toBeInTheDocument();
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({ comment: null });
});
test("a visitor sees no note where there is none", async () => {
dumpRoutes(makeDump({ slug: "a-find", title: "A find", userId: "owner" }));
openDump(other);
await screen.findByRole("heading", { name: "A find" });
expect(screen.queryByText("What makes it worth it?")).toBeNull();
});
test("others can't rename or edit it", async () => {
dumpRoutes();
const { user } = openDump(other);
@@ -160,14 +177,16 @@ describe("Dump page", () => {
dumpRoutes();
const { user } = openDump(owner);
await user.click(await screen.findByRole("link", { name: "Edit" }));
expect(currentPath()).toBe("/@owner/dumps/a-find/edit");
await waitFor(() =>
expect(currentPath()).toBe("/@owner/dumps/a-find/edit")
);
});
test("the title can be edited from the keyboard", async () => {
dumpRoutes();
const { user } = openDump(owner);
await screen.findByRole("heading", { name: "A find" });
screen.getByTitle("Edit title").focus();
screen.getByRole("button", { name: "Edit title" }).focus();
await user.keyboard("{Enter}");
expect(screen.getByDisplayValue("A find")).toBeInTheDocument();
});
@@ -268,20 +287,9 @@ describe("Dump page", () => {
({ params }) => wire([params.id === first.id ? second : first]),
);
const user = userEvent.setup();
renderApp(<Dump />, {
route: "/start",
path: "/:handle/dumps/:slug",
extraRoutes: (
<Route
path="/start"
element={
<Link to="/@owner/dumps/a-find" state={{ dump: first }}>open</Link>
}
/>
),
});
// Arrived from a card, which hands its dump over with the link.
renderPage({ pathname: "/@owner/dumps/a-find", state: { dump: first } });
FakeWebSocket.last.welcome();
await user.click(screen.getByRole("link", { name: "open" }));
await user.click(await screen.findByRole("link", { name: "Next find" }));
await user.click(await screen.findByRole("link", { name: "A find" }));
expect(await screen.findByRole("heading", { name: "A find", level: 1 }))
@@ -455,27 +463,18 @@ describe("DumpEdit page", () => {
function openEdit(dump: ReturnType<typeof makeDump>, session = owner) {
route("GET /api/users/:u/dumps/:slug", () => wire(dump));
const user = userEvent.setup();
renderApp(
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: `/@owner/dumps/${dump.slug}/edit`,
path: "/:handle/dumps/:slug/edit",
session,
categories: [music],
extraRoutes: (
<>
<Route path="/:handle/dumps/:slug" element={<p>dump page</p>} />
<Route path="/" element={<p>home</p>} />
</>
),
},
);
renderPage(`/@owner/dumps/${dump.slug}/edit`, {
session,
categories: [music],
});
FakeWebSocket.last.welcome();
return user;
}
/** Waits for the editor to have handed over to a dump's page. */
const onDumpPage = () =>
waitFor(() => expect(currentPath()).toMatch(/^\/@[^/]+\/dumps\/[^/]+$/));
const urlDump = () =>
makeDump({
id: "d1",
@@ -515,7 +514,7 @@ describe("DumpEdit page", () => {
await user.click(screen.getByRole("button", { name: "Music" }));
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
await onDumpPage();
expect(currentPath()).toBe("/@owner/dumps/new-title");
expect(calls("PUT /api/dumps/:id")[0].body).toEqual({
url: "https://a.test/",
@@ -542,7 +541,7 @@ describe("DumpEdit page", () => {
await user.type(title, "A Find!");
expect(link).toHaveValue("a-find");
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
await onDumpPage();
expect(calls("PUT /api/dumps/:id")[0].body).not.toHaveProperty("slug");
});
@@ -572,7 +571,7 @@ describe("DumpEdit page", () => {
const user = openEdit(urlDump());
await user.clear(await screen.findByLabelText("Why?"));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
await onDumpPage();
expect(calls("PUT /api/dumps/:id")[0].body).toHaveProperty("comment", null);
});
@@ -642,7 +641,7 @@ describe("DumpEdit page", () => {
);
await user.click(screen.getByRole("button", { name: "Private" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await screen.findByText("dump page");
await onDumpPage();
const [put] = calls("PUT /api/dumps/:id/file");
const form = put.body as FormData;
expect(form.get("file")).toBeInstanceOf(File);
@@ -676,7 +675,7 @@ describe("DumpEdit page", () => {
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("File too large (max 50 MB)"))
.toBeInTheDocument();
expect(screen.queryByText("dump page")).not.toBeInTheDocument();
expect(currentPath()).toBe(`/@owner/dumps/${fileDump.slug}/edit`);
expect(calls("PUT /api/dumps/:id/file")).toHaveLength(1);
expect(calls("PUT /api/dumps/:id")).toHaveLength(0);
});
@@ -719,7 +718,7 @@ describe("DumpEdit page", () => {
name: "Delete dump",
}),
);
await screen.findByText("home");
await waitFor(() => expect(currentPath()).toBe("/"));
expect(
JSON.parse(
document.querySelector('[data-testid="location"]')!.getAttribute(
@@ -760,17 +759,12 @@ describe("DumpEdit page", () => {
expect(await screen.findByText("Not an image")).toBeInTheDocument();
});
test("guests are sent to log in", () => {
renderApp(
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>,
{
route: "/@owner/dumps/x/edit",
path: "/:handle/dumps/:slug/edit",
extraRoutes: <Route path="/login" element={<p>login page</p>} />,
},
test("guests are sent to log in, and back", async () => {
renderPage("/@owner/dumps/x/edit");
await waitFor(() =>
expect(currentPath()).toBe(
`/login?next=${encodeURIComponent("/@owner/dumps/x/edit")}`,
)
);
expect(screen.getByText("login page")).toBeInTheDocument();
});
});

View File

@@ -1,20 +1,14 @@
import { describe, expect, test } from "vitest";
import { screen } from "@testing-library/react";
import { Route } from "react-router";
import { FederationCallback } from "../../pages/FederationCallback.tsx";
import { screen, waitFor } from "@testing-library/react";
import { AUTH_STORAGE_KEY } from "../../utils/auth/storage.ts";
import { calls, fail, route } from "../fetch.ts";
import { makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { makeSession, page, wire } from "../fixtures.ts";
import { currentPath, renderPage } from "../render.tsx";
/** Opens the callback, in a tab that started the sign-in of state `s`. */
function open(query: string, started = "s") {
sessionStorage.setItem("federationState", started);
renderApp(<FederationCallback />, {
route: `/federation/callback${query}`,
path: "/federation/callback",
extraRoutes: <Route path="/" element={<p>home page</p>} />,
});
renderPage(`/federation/callback${query}`);
}
describe("FederationCallback", () => {
@@ -23,8 +17,9 @@ describe("FederationCallback", () => {
"POST /api/federation/sessions",
() => wire(makeSession({ username: "alice@home.example" })),
);
route("GET /api/dumps", () => page([]));
open("?code=c&state=s&iss=https%3A%2F%2Fhome.example");
await screen.findByText("home page");
await waitFor(() => expect(currentPath()).toBe("/"));
expect(calls("POST /api/federation/sessions").map((c) => c.body)).toEqual(
[{ code: "c", state: "s" }],
);

View File

@@ -1,13 +1,6 @@
import { describe, expect, test } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { useLocation } from "react-router";
import { Index } from "../../pages/Index.tsx";
import {
FEED_TABS,
type FeedTab,
type FollowView,
} from "../../../shared/paths.ts";
import { calls, fail, route } from "../fetch.ts";
import {
makeCategory,
@@ -16,41 +9,19 @@ import {
page,
wire,
} from "../fixtures.ts";
import { currentPath, renderApp } from "../render.tsx";
import { currentPath, renderPage } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
import { FakeIntersectionObserver } from "../setup.ts";
const HOUR = 3_600_000;
const music = makeCategory({ id: "c-music", slug: "music", name: "Music" });
/** Reads a feed's path as `App`'s routes do. */
function Scoped() {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const isTab = (FEED_TABS as readonly string[]).includes(segments[0] ?? "");
const categorySlug = isTab ? undefined : segments[0];
const [feedTab, followView] = segments.slice(isTab ? 0 : 1);
return (
<Index
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={feedTab as FeedTab | undefined}
followView={followView as FollowView | undefined}
/>
);
}
function setup(
url: string,
{ session = null as ReturnType<typeof makeSession> | null } = {},
) {
const user = userEvent.setup();
renderApp(<Scoped />, {
route: url,
path: "*",
session,
categories: [music],
});
renderPage(url, { session, categories: [music] });
const socket = FakeWebSocket.last;
socket.welcome();
return { user, socket };
@@ -309,10 +280,12 @@ describe("Index feeds", () => {
.toBeInTheDocument();
});
test("a guest on the follow tab is sent to sign in, and back", () => {
test("a guest on the follow tab is sent to sign in, and back", async () => {
route("GET /api/dumps", () => page([older]));
setup("/music/follow");
expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow");
await waitFor(() =>
expect(currentPath()).toBe("/login?next=%2Fmusic%2Ffollow")
);
});
test("the Android share sheet opens the new-dump form with the link", async () => {

View File

@@ -1,10 +1,9 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Manage } from "../../pages/Manage.tsx";
import { calls, fail, route } from "../fetch.ts";
import { makeCategory, makeSession, wire } from "../fixtures.ts";
import { renderApp } from "../render.tsx";
import { renderPage } from "../render.tsx";
const instance = vi.hoisted(() => ({
federationIncoming: true,
@@ -31,7 +30,7 @@ async function group(label: string) {
function open(session = admin) {
route("GET /api/federation/peers", () => [], { fallback: true });
const user = userEvent.setup();
renderApp(<Manage />, { route: "/manage", path: "/manage", session });
renderPage("/manage", { session });
return user;
}

View File

@@ -1,12 +1,12 @@
import { describe, expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { NotFound } from "../../pages/NotFound.tsx";
import { renderWithContexts } from "../render.tsx";
import { renderPage } from "../render.tsx";
describe("NotFound", () => {
test("NotFound", async () => {
renderWithContexts(<NotFound />, { route: "/nope" });
expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
test("a path the app has no page for", async () => {
renderPage("/search/users/more");
expect(await screen.findByRole("heading", { name: "404" }))
.toBeInTheDocument();
await waitFor(() => expect(document.title).toMatch(/^Page not found/));
expect(screen.getByRole("link", { name: "Go home" })).toHaveAttribute(
"href",

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