Files
siftlode/frontend/src/lib/useChannelPriority.ts
T
peter 8a50538275 fix(ui): a write that fails says what failed and why, wherever it fails
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.

- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
  onError, so it waits a task and skips a failure the site already reported; the toast title comes
  from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
  structured localized detail, else the status). It marks the error reported (`markReported`), so
  nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
  comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
  account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
  now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
  macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
2026-09-13 01:45:28 +02:00

87 lines
3.6 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import { api } from "./api";
import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
/** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
* press-and-hold sends ONE request, not one per step, and pending flushes fire on unmount so a
* last-moment bump isn't lost. The cache-shape-specific bits are injected (the manager maps over a
* ManagedChannel[]; the detail patches one ChannelDetail), so the two call sites can't drift — the
* same drift the sibling `useChannelTagActions` hook prevents for tags.
*
* Timers are keyed by channel id, so a pending flush survives the caller switching channels: it
* fires later and reads the right cache via `read(id)`, rather than needing the caller to flush on
* every id change. `read`/`optimistic`/`onSuccess`/`onError` are read through a ref so the returned
* callbacks stay stable — the unmount flush then runs only on unmount, never on every render. */
export function useChannelPriority(opts: {
/** Current priority for this channel from whichever cache the caller owns (undefined if absent). */
read: (channelId: string) => number | undefined;
/** Apply the optimistic priority to the caller's cache. */
optimistic: (channelId: string, next: number) => void;
/** Called after the server write lands (e.g. invalidate sibling caches). Optional. */
onSuccess?: (channelId: string) => void;
/** Called if the server write fails — revert by refetching the owning cache. */
onError: (channelId: string) => void;
/** Debounce window in ms; defaults to 400. */
delay?: number;
}) {
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const optsRef = useRef(opts);
optsRef.current = opts;
const flush = useCallback((channelId: string) => {
const latest = optsRef.current.read(channelId);
if (latest == null) return;
api
.updateChannel(channelId, { priority: latest })
.then(() => optsRef.current.onSuccess?.(channelId))
.catch((err: unknown) => {
// Said here, once, for both callers: their `onError` only refetches, so a refused priority
// used to just snap back — often after the stepper had already been let go (E11 S2b).
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
optsRef.current.onError(channelId);
});
}, []);
// Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
// optimistically + a debounced flush.
const setPriority = useCallback(
(channelId: string, next: number) => {
optsRef.current.optimistic(channelId, next);
const pending = timers.current.get(channelId);
if (pending) clearTimeout(pending);
timers.current.set(
channelId,
setTimeout(() => {
timers.current.delete(channelId);
flush(channelId);
}, optsRef.current.delay ?? 400),
);
},
[flush],
);
// Read the LATEST value from the cache each step (not a render closure) so a hold-repeat
// accumulates correctly.
const bumpPriority = useCallback(
(channelId: string, delta: number) => {
setPriority(channelId, (optsRef.current.read(channelId) ?? 0) + delta);
},
[setPriority],
);
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach((timer, channelId) => {
clearTimeout(timer);
flush(channelId);
});
pending.clear();
};
}, [flush]);
return { setPriority, bumpPriority };
}