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

Fixed along the way. Dumps: clearing a dump's note sent `comment: undefined`, which JSON drops, so the server kept the old one; it's sent as null now. Replacing a file dropped a visibility change made in the same save (the file endpoint doesn't read it); it follows as a separate update. Renaming a dump onto a new slug kept showing the old title until reload. The thumbnail picker and delete on the edit page failed without a word, the latter as an unhandled rejection. Playlists: an edit that kept the slug waited on the socket's echo to show, and a new cover never appeared (the upload's answer was ignored, a refused one said nothing); covers were also served stale for an hour, since `max-age=3600` met an unversioned URL — setPlaylistImage now bumps updated_at and every cover URL carries `?v=` (playlistImageUrl). Removing a dump, reordering, and deleting a playlist ignored non-OK answers, leaving page and server apart; they restore, reload or report. Profile: clearing "Who am I?" sent undefined and came back on reload; your own upvoted list (and the upvoted page) was empty until the socket's welcome, and forever without one; "View all" under Following linked to a route that doesn't exist and is a Load more now. Chat: Escape on "Delete this message?" closed the whole chat — ConfirmModal takes Escape first, in the capture phase. Player: a pause or play from a media key, headset or the OS wasn't reflected (MediaPlayer follows the element's own play/pause events), and the seek bar tracked dragging with mouse events only, so on touch the thumb fought timeupdate. Feeds: a guest on /~/followed got a blank page; a server error in the followed feed read as "Could not connect". Search: a failed later page threw away the results shown. Notifications: a failed mark-read replaced a loaded list with an error. Silent failures now say so: deleting a comment, the voters/likers "Load more" (stuck on "Loading…"), and the password-reset request, which said "sent" whatever happened.

Notifications also stop hiding what was new: the bell still clears on opening the page, but the dots and the "N new" count stay for the visit instead of vanishing the moment the mark-read lands, and one arriving while the page is open is marked read too, so the bell doesn't count it.

Accessibility: Modal and ConfirmModal are dialogs (role, aria-modal, labelled/described); playlist membership rows are checkboxes that work from the keyboard; a dump's title, and the profile's email and description, can be opened for editing from the keyboard (the pencil shows on focus); the category editor's ✓ and the global player's video button have names. Player, rich-content and download labels were hard-coded English and are translated, with the new French strings. Lint: fast-refresh and render-purity rules are off for src/test/ only, where probes hand their context out on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 14:35:41 +00:00
parent 25601edaf8
commit 54e86c4366
78 changed files with 10816 additions and 691 deletions

View File

@@ -701,6 +701,35 @@ export function UserPublicProfile() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, state.status, username]);
// There's no page of its own for who someone follows, so the list grows
// here.
const loadMoreFollowedUsers = () => {
if (
followedState?.status !== "loaded" || followedState.users.loadingMore
) return;
const nextPage = followedState.users.page + 1;
const setUsers = (
fn: (u: PaginatedList<PublicUser>) => PaginatedList<PublicUser>,
) =>
setFollowedState((s) =>
s?.status === "loaded" ? { ...s, users: fn(s.users) } : s
);
setUsers((u) => ({ ...u, loadingMore: true }));
fetch(
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
)
.then(expectOk<PaginatedData<RawPublicUser>>)
.then(({ items, hasMore }) =>
setUsers((u) => ({
items: [...u.items, ...items.map(deserializePublicUser)],
hasMore,
page: nextPage,
loadingMore: false,
}))
)
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
};
// Restore scroll position after cache restoration
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -863,7 +892,15 @@ export function UserPublicProfile() {
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
title="Edit email"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
@@ -913,6 +950,21 @@ export function UserPublicProfile() {
onClick={isOwnProfile
? () => setDescEditing(true)
: undefined}
// Reachable from the keyboard too; not a button, so the
// markdown inside keeps its own structure.
tabIndex={isOwnProfile ? 0 : undefined}
title={isOwnProfile ? t`Edit description` : undefined}
onKeyDown={isOwnProfile
? (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
e.preventDefault();
setDescEditing(true);
}
}
: undefined}
>
{profileUser.description
? (
@@ -1077,12 +1129,16 @@ export function UserPublicProfile() {
))}
</ul>
{followedState.users.hasMore && (
<Link
to={`/users/${profileUser.username}/following`}
className="profile-view-all"
<button
type="button"
className="load-more-btn"
onClick={loadMoreFollowedUsers}
disabled={followedState.users.loadingMore}
>
<Trans>View all →</Trans>
</Link>
{followedState.users.loadingMore
? <Trans>Loading…</Trans>
: <Trans>Load more</Trans>}
</button>
)}
</>
)}
@@ -1496,13 +1552,19 @@ function UpvotedDumpList(
useEffect(() => {
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
setVotedIds(new Set(wsMyVotes));
// Only a baseline: the list as loaded stands. myVotes may still be the
// empty set from before the socket's welcome, and taking it as the
// truth would hide every dump.
prevMyVotesRef.current = new Set(wsMyVotes);
return;
}
const prev = prevMyVotesRef.current;
setVotedIds(new Set(wsMyVotes));
setVotedIds((ids) => {
const next = new Set(ids);
for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id);
for (const id of wsMyVotes) next.add(id);
return next;
});
for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
prevMyVotesRef.current = new Set(wsMyVotes);
@@ -1787,7 +1849,8 @@ function DescriptionEditor(
const submitting = form.formState.isSubmitting;
const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim() || undefined;
const trimmed = description.trim();
// Sent even when empty: left out, the server would keep the old one.
await expectOk<RawPublicUser>(
await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
@@ -1797,7 +1860,7 @@ function DescriptionEditor(
),
}),
);
onSaved(trimmed);
onSaved(trimmed || undefined);
});
return (