// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the // barrel wholesale to stub its requests must not take the error path's own plumbing with it. import { alreadyReported, HttpError, isAborted, markReported } from "./api/core"; import { notify } from "./notifications"; import i18n from "../i18n"; const GATEWAY = new Set([502, 503, 504]); /** * WHY a write failed, in the reader's language — the line under the toast's title. * * The server's own reason wins: `req` has already run the structured detail through * `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is * gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string * detail never reaches `HttpError.detail` (see there) — the hand-written English that once put * "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still * gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status * says it. */ export function failureReason(err: unknown): string { const t = i18n.t.bind(i18n); if (err instanceof HttpError) { if (err.detailData != null && err.detail) return err.detail; // 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the // change did not go through, which the connection notice alone does not say. if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable"); if (err.status === 403) return t("errors.reason.forbidden"); if (err.status === 404) return t("errors.notFound.generic"); if (err.status === 429) return t("errors.reason.rateLimited"); return t("errors.generic"); } // `fetch` rejects with a TypeError when the request never reached the server. if (err instanceof TypeError) return t("errors.reason.unreachable"); return t("errors.reason.unexpected"); } /** * Surface a failed write as an error toast instead of the click silently doing nothing: the title * names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says * WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including * the app-wide mutation reporter in lib/queryClient. */ export function notifyActionError( err: unknown, title: string, level: "error" | "warning" = "error", ): void { // Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own // `onError` got there first): a second toast reported one fault twice. A cancel is not a failure. if (alreadyReported(err) || isAborted(err)) return; notify({ level, title, message: failureReason(err) }); markReported(err); } /** * A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this * instance cannot send email", "someone registered on that address") — shown as it is, and marked, * so the app-wide mutation reporter does not put a second, generic toast next to it. */ export function notifyKnownFailure(err: unknown, message: string): void { if (alreadyReported(err)) return; notify({ level: "error", message }); markReported(err); }