From dcd8a9566a5d23877d4a995b884d7052b905a1f8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 03:19:07 +0200 Subject: [PATCH] fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the selected list: launched from Watch later (no Remove, by design), selecting another list under it made Remove appear — and delete from Watch later. The snapshot now carries the launch list's editability too. - Selecting another list under an open player (the command palette works over it) put the page into "Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and came back at its launch video. It now sits at one place in the tree whatever the page is doing. - A removal takes the video out of the CACHED list as well as the rows: the player's progress checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that still held the video — the row came back and the undo history went. - The watched-state callback refreshed the selected list, not the launch list (D3's sibling). - `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised: the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new `removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now. - The "viewer moved on under the confirm" test stepped back only once, which lands on the same video whether or not the removed one left the queue; it now takes the second step that tells them apart. --- backend/app/routes/playlists.py | 5 +- backend/tests/test_playlist_move.py | 16 + .../src/components/PlayerModal.dom.test.tsx | 5 + .../src/components/Playlists.dom.test.tsx | 93 ++- frontend/src/components/Playlists.tsx | 555 ++++++++++-------- frontend/src/i18n/locales/en/playlists.json | 3 + frontend/src/i18n/locales/hu/playlists.json | 3 + frontend/src/lib/api/core.test.ts | 20 +- frontend/src/lib/api/core.ts | 2 + 9 files changed, 438 insertions(+), 264 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 3a1bcd12..1051ddcb 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -50,7 +50,10 @@ def _check_pull_cooldown(user: User) -> None: def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: pl = db.get(Playlist, playlist_id) if pl is None or pl.user_id != user.id: - raise HTTPException(status_code=404, detail="Unknown playlist") + # Structured, so the UI can say it in the reader's language: this 404 is exactly the one a + # user meets in practice (the list was deleted in another tab), and a bare "Unknown + # playlist" reached the Hungarian UI in English. One reason for "gone" and "not yours" alike. + raise HTTPException(status_code=404, detail={"code": "playlist", "reason": "not_found"}) return pl diff --git a/backend/tests/test_playlist_move.py b/backend/tests/test_playlist_move.py index 8590c2a8..027f0606 100644 --- a/backend/tests/test_playlist_move.py +++ b/backend/tests/test_playlist_move.py @@ -128,6 +128,22 @@ def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as assert _ids(db, theirs.id) == [] +def test_a_list_that_is_not_yours_answers_with_a_reason_the_ui_can_translate( + db, make_user, as_user +): + # The one 404 a user meets in practice (the list went away in another tab). It used to be a bare + # English "Unknown playlist", which the Hungarian UI then showed verbatim. + user, other = make_user(), make_user() + _seed_videos(db, ["v1"]) + theirs = _playlist(db, other, "Theirs", ["v1"]) + + r = as_user(user).delete(f"/api/playlists/{theirs.id}/items/v1") + assert r.status_code == 404 + assert r.json()["detail"] == {"code": "playlist", "reason": "not_found"} + db.expire_all() + assert _ids(db, theirs.id) == ["v1"] + + def test_move_marks_both_linked_sides_dirty(db, make_user, as_user): """A move edits two YouTube mirrors at once — the source lost an item, the target gained one. Marking only the source is the asymmetric fix this repo keeps re-learning.""" diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 194580a4..08c07545 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -216,6 +216,11 @@ describe("PlayerModal — removing the playing video from its playlist", () => { expect(lastStart()?.videoId).toBe("C"); prev(); await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + // …and A is gone from the queue, not just skipped over: B is its head now, so stepping back + // again goes nowhere. (One step back lands on B either way — it took the second to tell.) + prev(); + await settle(); + expect(lastStart()?.videoId).toBe("B"); expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again }); diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index e835fc1a..7a9cf766 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -10,9 +10,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const h = vi.hoisted(() => ({ notify: vi.fn(), selectedId: 1 as number | null, + mounted: false, player: null as null | { video: { id: string; position_seconds: number }; onClose: () => void; + onState: (id: string, status: string) => void; onRemoveFromList?: (v: unknown) => Promise; }, })); @@ -34,12 +36,21 @@ vi.mock("../lib/notifications", async (importOriginal) => ({ vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) })); vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) })); -vi.mock("./PlayerModal", () => ({ - default: (props: NonNullable) => { - h.player = props; - return null; - }, -})); +vi.mock("./PlayerModal", async () => { + const { useEffect } = await import("react"); + return { + default: function PlayerStandIn(props: NonNullable) { + h.player = props; + useEffect(() => { + h.mounted = true; + return () => { + h.mounted = false; + }; + }, []); + return null; + }, + }; +}); import Playlists from "./Playlists"; import { api, HttpError, type Video } from "../lib/api"; @@ -72,10 +83,11 @@ const vid = (id: string, position_seconds: number): Video => watch_url: `https://www.youtube.com/watch?v=${id}`, }) as unknown as Video; +const WATCH_LATER = 9; const list = (id: number, items: Video[]) => ({ id, name: `List ${id}`, - kind: "user", + kind: id === WATCH_LATER ? "watch_later" : "user", yt_playlist_id: null, item_count: items.length, items, @@ -107,6 +119,7 @@ const play = async (title: string) => beforeEach(() => { h.selectedId = 1; h.player = null; + h.mounted = false; h.notify.mockClear(); }); afterEach(() => { @@ -171,4 +184,70 @@ describe("Playlists — removing a video from inside the player", () => { expect(done).toBe(false); expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" })); }); + + it("offers Remove by the list it was launched from, not by whichever list is selected now", async () => { + // Watch later deliberately has no Remove. Gated on the SELECTED list, it appeared on a player + // launched from Watch later as soon as another list was selected under it — and deleted from + // Watch later, since the removal itself targets the launch list. + h.selectedId = WATCH_LATER; + const { rerender } = mount({ [WATCH_LATER]: [vid("V", 0)], 2: [vid("W", 0)] }); + await play("Video V"); + await waitFor(() => expect(h.player).not.toBeNull()); + expect(h.player!.onRemoveFromList).toBeUndefined(); + h.selectedId = 2; + rerender(); + await screen.findByRole("button", { name: "card.play — Video W" }); + expect(h.player!.onRemoveFromList).toBeUndefined(); + }); + + it("takes the removed video out of the cached list, so a progress checkpoint cannot bring it back", async () => { + // The player steps to the next item after a removal and stores the last one's position into + // every cached list. Landing before the refetch, that write handed the page a list still holding + // the video, and the page — seeing a different set of ids — reset: the row came back. + vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never); + mount({ 1: [vid("V", 0), vid("W", 0)] }); + await play("Video V"); + await waitFor(() => expect(h.player).not.toBeNull()); + vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); // the refetch is still out + await act(async () => { + await h.player!.onRemoveFromList!(vid("V", 0)); + }); + await act(async () => { + patchCachedProgress(qc, new Map([["V", 99]])); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(screen.queryByRole("button", { name: "card.play — Video V" })).toBeNull(); + expect(screen.getByRole("button", { name: "card.play — Video W" })).toBeTruthy(); + }); +}); + +describe("Playlists — the player outlives a change of selection", () => { + it("stays mounted while a newly selected list is still loading", async () => { + // The page swaps itself for "Loading…" until the new list arrives; with the player rendered only + // in the loaded branch, that took it down mid-playback and put it back at its launch video. + const { rerender } = mount({ 1: [vid("V", 0)] }); + await play("Video V"); + await waitFor(() => expect(h.mounted).toBe(true)); + vi.mocked(api.playlist).mockReturnValue(new Promise(() => {})); + h.selectedId = 2; + rerender(); + await screen.findByText("playlists.loading"); + expect(h.mounted).toBe(true); + }); + + it("refreshes the list it was launched from when the viewer marks the video in it", async () => { + // Invalidating the SELECTED list left the launch list's row showing the old status. + vi.spyOn(api, "setState").mockResolvedValue(undefined as never); + const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("W", 0)] }); + await play("Video V"); + await waitFor(() => expect(h.player).not.toBeNull()); + h.selectedId = 2; + rerender(); + await screen.findByRole("button", { name: "card.play — Video W" }); + await act(async () => { + h.player!.onState("V", "watched"); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(qc.getQueryState(qk.playlist(1))?.isInvalidated).toBe(true); + }); }); diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 61a0ce58..0c6641ab 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -40,8 +40,9 @@ import { X, Youtube, } from "lucide-react"; -import { api, HttpError, type Playlist, type Video } from "../lib/api"; +import { api, HttpError, type Playlist, type PlaylistDetail, type Video } from "../lib/api"; import { notify } from "../lib/notifications"; +import { notifyActionError } from "../lib/actionErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { playlistName } from "../lib/playlistName"; import { useUndoable } from "../lib/useUndoable"; @@ -227,12 +228,15 @@ export default function Playlists() { // normal case. Only onClose takes the player down now. `listId` is part of that snapshot for the // same reason: the selection can change under an open player (the command palette opens over it), // and "remove this from the playlist" means the list it was LAUNCHED from, not whichever is - // selected by the time the button is pressed. + // selected by the time the button is pressed. So does whether that button exists at all + // (`editable`): gating it on the current selection offered Remove on a player launched from + // Watch later — which deliberately has none — as soon as another list was selected under it. const [playing, setPlaying] = useState<{ video: Video; queue: Video[]; index: number; listId: number | null; + editable: boolean; } | null>(null); // Where the last move went. `LS.playlist` is the SELECTED (source) list and `updated_at` moves on // any edit, so neither answers "where do I keep sending things"; this does, and it is a per-account @@ -301,6 +305,8 @@ export default function Playlists() { // push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from // every account without the scope, which they have always had. const canEdit = editable; + const canEditRef = useRef(canEdit); + canEditRef.current = canEdit; const [pushing, setPushing] = useState(false); const [reverting, setReverting] = useState(false); @@ -310,7 +316,13 @@ export default function Playlists() { const queue = shownRef.current; const index = queue.findIndex((v) => v.id === id); if (index < 0) return; - setPlaying({ video: queue[index]!, queue, index, listId: selectedRef.current }); + setPlaying({ + video: queue[index]!, + queue, + index, + listId: selectedRef.current, + editable: canEditRef.current, + }); }, []); const onPlay = useCallback((v: Video) => openPlayer(v.id), [openPlayer]); const onRemove = useCallback((v: Video) => { @@ -551,6 +563,14 @@ export default function Playlists() { order.rebase((list) => list.filter((v) => v.id !== video.id)); lastSetRef.current = `${listId}:${idsKey(next)}`; } + // …and out of the CACHED list too, not just the rows. Anything that rewrites that cache entry + // before the refetch lands handed the membership guard a list that still held the video — the + // player's own progress checkpoint does exactly that when a removal steps it to the next item — + // so the guard reset the order: the removed row came back and the undo history the rebase had + // kept went with it. (A failure resyncs below, which puts the row back where it belongs.) + qc.setQueryData(qk.playlist(listId), (old) => + old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old, + ); try { await api.removeFromPlaylist(listId, video.id); refreshAll(listId); @@ -561,7 +581,7 @@ export default function Playlists() { // confirmed removal fails in silence (inside the full-screen player there is not even a row // snapping back to show it). if (e instanceof HttpError && (e.status === 404 || e.status === 403)) - notify({ level: "error", message: e.detail || t("playlists.removeFailed") }); + notifyActionError(e, t("playlists.removeFailed")); refreshAll(listId); // …and resync, which restores any row the optimistic removal took out return false; } @@ -597,7 +617,7 @@ export default function Playlists() { // error dialog for 400/409/422 and 5xx and raises the connectivity notice for a bad gateway, // so toasting unconditionally reported a transport fault twice (E10 review round 2). if (e instanceof HttpError && (e.status === 404 || e.status === 403)) - notify({ level: "error", message: e.detail || t("playlists.moveFailed") }); + notifyActionError(e, t("playlists.moveFailed")); } // BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact // key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as @@ -636,7 +656,10 @@ export default function Playlists() { function playerState(id: string, status: string) { api.setState(id, status).then(() => { - qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); + // Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a + // list that is no longer the selected one (see `playing`), and that list holds the row whose + // status just changed. Only lists actually on screen refetch. + qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.feed() }); }); } @@ -767,293 +790,315 @@ export default function Playlists() { : []), ]; + // The open player, at ONE place in the tree whichever state the page below it is in. Selecting + // another list under an open player (the command palette works over it) puts the page into + // "Loading…" until that list arrives; with the player rendered only in the loaded branch, that + // unmounted it mid-playback and remounted it at the video it was launched with. + const player = playing && ( + + + + ); + if (selectedId == null) { - return
{t("playlists.pickOne")}
; + return ( + <> +
{t("playlists.pickOne")}
+ {player} + + ); } // The page used to render "Loading…" forever when the detail query failed — an error read as a // slow network that never arrived. if (detailQuery.isError) { return ( -
- - - {t("playlists.loadFailed")} - - -
+ <> +
+ + + {t("playlists.loadFailed")} + + +
+ {player} + ); } if (!detail) { - return
{t("playlists.loading")}
; + return ( + <> +
{t("playlists.loading")}
+ {player} + + ); } + // No top padding: the sticky column header must sit flush at the scroller top so it doesn't + // shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above + // gives the breathing room). Same reason as the channel table — see Channels.tsx. return ( - // No top padding: the sticky column header must sit flush at the scroller top so it doesn't - // shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above - // gives the breathing room). Same reason as the channel table — see Channels.tsx. -
- {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's + <> +
+ {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's fixed band, so only the video list scrolls. */} - - {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the + + {/* Two tight rows beside a small cover: title + actions on the first, the meta line and the view controls sharing the second. This band is FIXED chrome (it never scrolls), so every row it sheds is list height won back — the old layout stacked three rows next to the cover plus a fourth block under it, for roughly twice this height. */} -
-
-
- {items[0]?.thumbnail_url && ( - - )} -
-
-
- {renaming ? ( - setRenameValue(e.target.value)} - onBlur={saveRename} - onKeyDown={(e) => { - if (e.key === "Enter") saveRename(); - if (e.key === "Escape") setRenaming(false); - }} - aria-label={t("playlists.rename")} - className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40" - /> - ) : ( - <> -

{plName(detail)}

- {editable && ( - - )} - {detail.yt_playlist_id && ( - - - - )} - +
+
+
+ {items[0]?.thumbnail_url && ( + )} -
- {linked && detail.dirty && ( - - - {t("playlists.unsynced")} - - )} - - {editable && canWrite && ( - - )} - {linked && detail.dirty && ( - - )} - {editable && ( -
- -
- - {t("playlists.itemCount", { count: items.length })} - {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} - {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} - - {(items.length > 1 || viewActive) && ( - <> - - - {viewActive && ( - + )} + {detail.yt_playlist_id && ( + + + + )} + + )} +
+ {linked && detail.dirty && ( + - {t("playlists.clearView")} + + {t("playlists.unsynced")} + + )} + + {editable && canWrite && ( + )} - {canSaveOrder && ( + {linked && detail.dirty && ( )} - {/* ONE sentence that names the state. Three loose texts used to pile up here + {editable && ( +
+
+ +
+ + {t("playlists.itemCount", { count: items.length })} + {totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`} + {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`} + + {(items.length > 1 || viewActive) && ( + <> + + + {viewActive && ( + + )} + {canSaveOrder && ( + + )} + {/* ONE sentence that names the state. Three loose texts used to pile up here (reset link + save hint + "reordering is off"), and none of them answered the question a user actually has — "did I just change my playlist?". Worse, sorting a list whose stored order ALREADY matches the sort produced a warning, no Save button and no visible change, which reads as a malfunction. Now the line says which of the three situations you are in; the disabled grip still explains itself on hover/focus. */} - {viewActive && {viewStatus}} - {canEdit && ( -
- -
- )} - - )} + {viewActive && {viewStatus}} + {canEdit && ( +
+ +
+ )} + + )} +
-
- + - {items.length === 0 ? ( -
-
- - {t("playlists.empty")} + {items.length === 0 ? ( +
+
+ + {t("playlists.empty")} +
-
- ) : ( - - {/* Not clipped, so the header's filter popovers can overflow the table box. */} -
- {/* table-fixed, not auto: every column but the title has a declared width, so the + ) : ( + + {/* Not clipped, so the header's filter popovers can overflow the table box. */} +
+ {/* table-fixed, not auto: every column but the title has a declared width, so the title absorbs the rest and its `truncate` works. Auto layout sizes columns from min-content, and a nowrap title cell's min-content is the entire title. */} - - - v.id)} - strategy={verticalListSortingStrategy} - > - - {shown.map((v) => ( - - ))} - - -
- {shown.length === 0 && ( -
- {t("playlists.noneMatchFilter")} -
- )} -
-
- )} + + + v.id)} + strategy={verticalListSortingStrategy} + > + + {shown.map((v) => ( + + ))} + + +
+ {shown.length === 0 && ( +
+ {t("playlists.noneMatchFilter")} +
+ )} +
+
+ )} - {/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */} - - - {playing && ( - - - - )} -
+ {/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */} + +
+ {player} + ); } diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 65f9fd5c..06792711 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -77,6 +77,9 @@ "movedToast": "“{{title}}” moved to “{{name}}” ✓", "moveFailed": "Couldn't move the video.", "removeFailed": "Couldn't remove the video.", + "errors": { + "not_found": "This playlist doesn't exist any more." + }, "moveErrors": { "not_in_playlist": "That video isn't in this playlist any more — it changed somewhere else.", "same_playlist": "That video is already in this playlist." diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index a9bfe1f9..50d68821 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -77,6 +77,9 @@ "movedToast": "„{{title}}” áthelyezve ide: „{{name}}” ✓", "moveFailed": "Nem sikerült áthelyezni a videót.", "removeFailed": "Nem sikerült eltávolítani a videót.", + "errors": { + "not_found": "Ez a lejátszási lista már nem létezik." + }, "moveErrors": { "not_in_playlist": "Ez a videó már nincs ebben a listában — időközben máshol megváltozott.", "same_playlist": "Ez a videó már ebben a listában van." diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index 73e3b365..fee88f07 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -21,7 +21,7 @@ vi.mock("../activeAccount", () => ({ notifyActiveAccountChange: vi.fn(), })); -import { uploadReq, setUnauthorizedHandler, HttpError } from "./core"; +import { req, uploadReq, setUnauthorizedHandler, HttpError } from "./core"; // A fake XHR whose lifecycle the test drives: capture the instance, set status/responseText, then // invoke onload/onerror/abort by hand. @@ -117,3 +117,21 @@ describe("uploadReq", () => { expect(reportError).not.toHaveBeenCalled(); }); }); + +describe("req — a structured refusal reads in the user's language", () => { + it("names a playlist that is gone instead of passing the server's English through", async () => { + // The 404 every playlist route answers when the list was deleted in another tab. It used to be a + // bare "Unknown playlist" string, shown verbatim under the Hungarian UI. + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ detail: { code: "playlist", reason: "not_found" } }), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ); + await expect(req("/api/playlists/7/items/v1", { method: "DELETE" })).rejects.toMatchObject({ + status: 404, + detail: "playlists.errors.not_found", + }); + expect(reportError).not.toHaveBeenCalled(); // a 404 is the caller's to report + }); +}); diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index c507f67e..2847df40 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -82,6 +82,8 @@ function localizeDetail(d: unknown): string { // unknown reason falls back to the plain failure rather than printing a key. if (o?.code === "playlist_move") return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed")); + // Any playlist route whose list is gone (deleted in another tab) — or was never the caller's. + if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic")); // SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace // limit, and the copy says so. if (o?.code === "sync_cooldown") return t("errors.syncCooldown");