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
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:
@@ -27,6 +27,7 @@ import type {
|
||||
import { deserializeNotification } from "../model.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||
import { expectOk } from "../components/form/index.ts";
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
@@ -269,13 +270,15 @@ export function Notifications() {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
const data = body.data as PaginatedData<RawNotification>;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await expectOk<PaginatedData<RawNotification>>(
|
||||
await authFetch(
|
||||
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
status: "loaded",
|
||||
items: data.items.map(deserializeNotification),
|
||||
@@ -283,25 +286,31 @@ export function Notifications() {
|
||||
page: 1,
|
||||
loadingMore: false,
|
||||
});
|
||||
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Marking them read is a separate matter: if it fails, the list that
|
||||
// loaded is still worth showing, and they stay unread for next time.
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. The list keeps showing which ones were new
|
||||
// when the page opened: flipping them to read here, a moment after
|
||||
// they appear, would take the dots and the "new" count away before
|
||||
// anyone could see them.
|
||||
clearUnreadNotifications();
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
|
||||
: s
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof TypeError) {
|
||||
setState({ status: "error", error: friendlyFetchError(err) });
|
||||
} else if (err instanceof Error && err.message === "Failed to load") {
|
||||
setState({ status: "error", error: err.message });
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Offline: see above.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
@@ -316,6 +325,18 @@ export function Notifications() {
|
||||
});
|
||||
}
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell shouldn't count it.
|
||||
const loaded = state.status === "loaded";
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
|
||||
.then((res) => {
|
||||
if (res.ok) clearUnreadNotifications();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user