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

Binary file not shown.

View File

@@ -202,6 +202,7 @@ function CommentNode({
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteFailed, setDeleteFailed] = useState(false);
const innerRef = useHeightTransition<HTMLDivElement>(
`${editOpen}:${replyOpen}`,
);
@@ -227,11 +228,14 @@ function CommentNode({
async function handleDelete() {
if (!token) return;
setDeleteFailed(false);
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "DELETE",
}).catch(() => null);
if (res?.ok) {
onCommentDeleted(comment.id);
} else {
setDeleteFailed(true);
}
}
@@ -407,6 +411,11 @@ function CommentNode({
/>
)}
</div>
{deleteFailed && (
<p className="form-hint form-hint--error" role="alert">
<Trans>This comment couldn't be deleted. Try again.</Trans>
</p>
)}
{replyOpen && (
<CommentForm
editorRef={replyEditorRef}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useId } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -15,20 +15,33 @@ export function ConfirmModal(
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
const label = confirmLabel ?? t`Delete`;
const messageId = useId();
useScrollLock();
useEffect(() => {
// A confirm can sit on top of a Modal (deleting a chat message), whose
// own Escape handler is also on the document and was added first. Take
// the key in the capture phase and mark it handled, so the Modal leaves
// it alone and only the confirm goes.
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
if (e.key !== "Escape" || e.defaultPrevented) return;
e.preventDefault();
onCancel();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [onCancel]);
return createPortal(
<div className="modal-backdrop" onClick={onCancel}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<div
className="confirm-modal"
role="alertdialog"
aria-modal="true"
aria-describedby={messageId}
onClick={(e) => e.stopPropagation()}
>
<p className="confirm-modal-message" id={messageId}>{message}</p>
<div className="confirm-modal-actions">
<button type="button" className="btn-secondary" onClick={onCancel}>
<Trans>Cancel</Trans>

View File

@@ -20,7 +20,8 @@ export function FeedTabBar() {
const defaultTab: FeedTab = preferredTab === "followed" && !user
? DEFAULT_FEED_TAB
: preferredTab;
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "")
const active: FeedTab = (FEED_TABS as readonly string[]).includes(feedTab ?? "") &&
(feedTab !== "followed" || !!user)
? (feedTab as FeedTab)
: defaultTab;

View File

@@ -13,6 +13,8 @@ import {
VIEWBOX_W,
WAVEFORM_H,
} from "../utils/waveform.ts";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
interface FilePreviewProps {
dump: Dump;
@@ -103,7 +105,7 @@ function AudioFilePreview(
type="button"
className="audio-player-btn"
onClick={handlePlayBtn}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{isPlaying ? <IconPause /> : <IconPlay />}
</button>
@@ -208,7 +210,7 @@ export default function FilePreview(
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label="Play"
aria-label={t`Play`}
>
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
<span className="rich-content-play-overlay" aria-hidden="true">
@@ -229,7 +231,7 @@ export default function FilePreview(
e.stopPropagation();
play(filePlayerItem(dump, fileUrl, mime, token));
}}
aria-label="Play"
aria-label={t`Play`}
>
{thumbOverride
? <VideoThumb src={thumbOverride} fallback={mimeIcon(mime)} />
@@ -264,6 +266,7 @@ export default function FilePreview(
<button
type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
aria-label={videoPlaying ? t`Pause` : t`Play`}
onClick={() =>
videoActive
? togglePlay()
@@ -311,7 +314,7 @@ export default function FilePreview(
return (
<a href={fileUrl} download={dump.fileName} className="file-download-link">
{mimeIcon(mime)} Download {dump.fileName}
{mimeIcon(mime)} <Trans>Download {dump.fileName}</Trans>
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
</a>
);

View File

@@ -8,6 +8,7 @@ import {
WAVEFORM_H,
} from "../utils/waveform.ts";
import { fmt } from "../utils/duration.ts";
import { t } from "@lingui/core/macro";
export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
@@ -170,10 +171,12 @@ function SeekBar(
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => onDragChange(true)}
onMouseUp={() => onDragChange(false)}
// Pointer events, so a finger holds the thumb as a mouse does.
onPointerDown={() => onDragChange(true)}
onPointerUp={() => onDragChange(false)}
onPointerCancel={() => onDragChange(false)}
onChange={(e) => onSeek(Number(e.target.value))}
aria-label="Seek"
aria-label={t`Seek`}
/>
</div>
);
@@ -369,12 +372,27 @@ export function MediaPlayer(
setPlaying(false);
onErrorRef.current?.();
};
// The element can also be started or stopped from outside the page — a
// media key, a headset, the OS's media controls — so follow it rather
// than only what our own button did.
const onPlayEvent = () => {
setPlaying(true);
onPlayStateChangeRef.current?.(true);
};
const onPauseEvent = () => {
setPlaying(false);
onPlayStateChangeRef.current?.(false);
};
a.addEventListener("timeupdate", onTime);
a.addEventListener("play", onPlayEvent);
a.addEventListener("pause", onPauseEvent);
a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => {
a.removeEventListener("timeupdate", onTime);
a.removeEventListener("play", onPlayEvent);
a.removeEventListener("pause", onPauseEvent);
a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
@@ -462,7 +480,7 @@ export function MediaPlayer(
type="button"
className="audio-player-btn"
onClick={toggle}
aria-label={playing ? "Pause" : "Play"}
aria-label={playing ? t`Pause` : t`Play`}
>
{playing ? <IconPause /> : <IconPlay />}
</button>
@@ -478,7 +496,7 @@ export function MediaPlayer(
type="button"
className="audio-player-vol-btn"
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
aria-label={muted ? t`Unmute` : t`Mute`}
>
<IconVolume muted={muted} />
</button>
@@ -495,7 +513,7 @@ export function MediaPlayer(
step={0.01}
value={muted ? 0 : volume}
onChange={changeVolume}
aria-label="Volume"
aria-label={t`Volume`}
/>
</div>
</div>
@@ -505,7 +523,7 @@ export function MediaPlayer(
type="button"
className="audio-player-vol-btn"
onClick={goFullscreen}
aria-label="Fullscreen"
aria-label={t`Fullscreen`}
>
<IconFullscreen />
</button>

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { useScrollLock } from "../hooks/useScrollLock.ts";
@@ -20,6 +20,7 @@ export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) {
const backdropRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const requestClose = useCallback(() => {
if (onBeforeClose?.() === false) return;
@@ -44,9 +45,14 @@ export function Modal(
if (e.target === backdropRef.current) requestClose();
}}
>
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
<div
className={`modal-card${wide ? " modal-card--wide" : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="modal-header">
<span className="modal-title">{title}</span>
<span className="modal-title" id={titleId}>{title}</span>
<button
type="button"
className="modal-close-btn"

View File

@@ -1,10 +1,9 @@
import { Link, useNavigate } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { playlistUrl } from "../utils/urls.ts";
import { playlistImageUrl, playlistUrl } from "../utils/urls.ts";
import {
isPlaylistVisited,
isRecent,
@@ -40,7 +39,7 @@ export function PlaylistCard(
{playlist.imageMime
? (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
src={playlistImageUrl(playlist)}
alt=""
className="playlist-card-img"
/>

View File

@@ -42,9 +42,18 @@ export function PlaylistMembershipPanel({
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
role="checkbox"
aria-checked={m.hasDump}
tabIndex={0}
onClick={() => onToggle(m)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle(m);
}
}}
>
<span className="playlist-membership-check">
<span className="playlist-membership-check" aria-hidden="true">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">

View File

@@ -1,4 +1,5 @@
import { useContext } from "react";
import { t } from "@lingui/core/macro";
import type { RichContent } from "../model.ts";
import { PlayerContext } from "../contexts/PlayerContext.ts";
import {
@@ -79,7 +80,7 @@ export default function RichContentCard(
if (isActive && current?.kind === "stream") togglePlay();
else void playOrOpen();
}}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t`Pause` : t`Play`}
>
{thumbnail}
<span className="rich-content-play-overlay">
@@ -97,8 +98,8 @@ export default function RichContentCard(
className="rich-content-compact"
onClick={(e) => e.stopPropagation()}
aria-label={richContent.siteName
? `Open on ${richContent.siteName}`
: "Open source"}
? t`Open on ${richContent.siteName}`
: t`Open source`}
>
{thumbnail}
<span className="rich-content-open-overlay" aria-hidden="true">
@@ -125,7 +126,7 @@ export default function RichContentCard(
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={() => void playOrOpen()}
aria-label="Play"
aria-label={t`Play`}
>
{thumbnailImg}
<span className="rich-content-play-overlay">▶</span>

View File

@@ -169,7 +169,13 @@ export function UserListPopover(
loadingMore: false,
};
});
});
})
// Offline or an unreadable reply: give the button back for a retry.
.catch(() =>
setState((prev) =>
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
)
);
};
if (!placement) return null;

View File

@@ -7,6 +7,8 @@ interface SubmitButtonProps {
pendingLabel?: ReactNode;
disabled?: boolean;
className?: string;
/** For an icon-only button (e.g. "✓"), its accessible name. */
"aria-label"?: string;
}
/**
@@ -19,11 +21,17 @@ export function SubmitButton({
pendingLabel,
disabled,
className = "btn-primary",
"aria-label": ariaLabel,
}: SubmitButtonProps) {
const { isSubmitting } = useFormState();
return (
<button type="submit" className={className} disabled={isSubmitting || disabled}>
<button
type="submit"
className={className}
disabled={isSubmitting || disabled}
aria-label={ariaLabel}
>
{isSubmitting && pendingLabel != null ? pendingLabel : children}
</button>
);