A clip and an editor range can come out as a silent GIF (long side 320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB is not kept. A shared picture is served inline under the preview's sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still for Facebook's crawler.
79 lines
3.4 KiB
TypeScript
79 lines
3.4 KiB
TypeScript
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
|
|
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
|
|
// moment, and the notice store cannot import the request core that imports it.
|
|
import i18n from "../../i18n";
|
|
import { formatBytes, formatDecimal } from "../format";
|
|
import {
|
|
REFUSAL_BY_REASON,
|
|
REFUSAL_FAMILIES,
|
|
REFUSAL_FIXED,
|
|
type RefusalFamily,
|
|
} from "./refusalCodes";
|
|
|
|
type Detail = {
|
|
code?: string;
|
|
reason?: string;
|
|
limit?: number;
|
|
limit_s?: number;
|
|
min?: number;
|
|
} | null;
|
|
|
|
// Which sentence answers which code lives in `refusalCodes` — the records the backendIds contract holds
|
|
// against every code and reason the backend can send. A Map, so a code is looked up as data only.
|
|
const families = new Map<string, RefusalFamily>(Object.entries(REFUSAL_FAMILIES));
|
|
const byReason = new Map(
|
|
Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [
|
|
code,
|
|
{ reasons: new Map<string, string>(Object.entries(f.reasons)), fallback: f.fallback },
|
|
]),
|
|
);
|
|
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
|
|
|
|
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
|
|
// minutes (a GIF's in seconds), an upload cap in bytes, a password's minimum length; a generic
|
|
// `refused` interpolates whatever values its sentence names (`{{min}}`, `{{name}}`, …).
|
|
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
|
|
const lang = i18n.language;
|
|
if (o.code === "refused") return { ...o };
|
|
if (o.code === "clip" || o.code === "edit")
|
|
return o.limit_s == null
|
|
? {}
|
|
: {
|
|
minutes: formatDecimal(o.limit_s / 60, lang, 1, 0),
|
|
seconds: formatDecimal(o.limit_s, lang, 0, 0),
|
|
};
|
|
if (o.code === "quota")
|
|
return o.reason === "max_bytes"
|
|
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
|
|
: { limit: o.limit };
|
|
if (o.code === "reset") return { min: o.limit ?? o.min };
|
|
if (o.code === "upload_too_large") return { size: formatBytes(o.limit, lang) };
|
|
return {};
|
|
}
|
|
|
|
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. A reason this
|
|
// build does not know reads as its code's fallback sentence rather than a raw key, and a code it does
|
|
// not know (or one no screen shows, `REFUSAL_UNSHOWN`) as the generic failure.
|
|
export function localizeDetail(d: unknown): string {
|
|
const t = i18n.t.bind(i18n);
|
|
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
|
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
|
|
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
|
// "check your input" message rather than leaking them into a translated sentence.
|
|
if (Array.isArray(d)) return t("errors.validation");
|
|
const o = d as Detail | undefined;
|
|
if (!o?.code) return t("errors.generic");
|
|
const family = families.get(o.code);
|
|
if (family)
|
|
return t(`${family.prefix}.${o.reason}${family.suffix ?? ""}`, {
|
|
...valuesOf(o),
|
|
defaultValue: t(family.fallback),
|
|
});
|
|
const shared = byReason.get(o.code);
|
|
if (shared) {
|
|
return t(shared.reasons.get(o.reason ?? "") ?? shared.fallback, valuesOf(o));
|
|
}
|
|
const one = fixed.get(o.code);
|
|
return one ? t(one, valuesOf(o)) : t("errors.generic");
|
|
}
|