fix(ui,i18n): E30 review round 2 — honest bio failure, a full show summary, one HU name per thing
- Person page: a bio ask that fails says so with a retry instead of reading as 'no biography'. - Show page: the summary is a ClampedText (5 lines + More), like the person and collection pages; one More/Less pair (plex.textMore/textLess) for all three. - Channels: a re-slotted row is placed in the language the server orders by (the stored preference, else English), not the UI's guess. - A skipped run's body is only the reason (its title already says skipped). - HU: Behúzási kvótatartalék; Eseménynapló ürítve / az eseménynaplót; indok; közvetlenül játszható / közvetlen közvetítés in the Plex hints. - Tests: ClampedText re-measures on a resize, the show summary clamp, serverOrderLang.
This commit is contained in:
@@ -27,6 +27,7 @@ import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../lib/channelOrder";
|
||||
import { serverOrderLang } from "../lib/saveLanguage";
|
||||
import { focusNavItem } from "../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
@@ -173,7 +174,7 @@ export default function Channels() {
|
||||
setPriority(c.id, next);
|
||||
qc.setQueryData<ManagedChannel[]>(
|
||||
qk.channels(),
|
||||
(old) => old && placeByPriority(old, c.id, lang),
|
||||
(old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)),
|
||||
);
|
||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
@@ -248,6 +248,6 @@ describe("PlexCollectionView — the summary (E30 review)", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("One short line.");
|
||||
expect(screen.queryByText("plex.collection.more")).toBeNull(); // jsdom: nothing is cut
|
||||
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
|
||||
});
|
||||
});
|
||||
|
||||
@@ -336,8 +336,8 @@ export default function PlexCollectionView({
|
||||
<ClampedText
|
||||
text={d.summary}
|
||||
lines={3}
|
||||
moreLabel={t("plex.collection.more")}
|
||||
lessLabel={t("plex.collection.less")}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// and the api is mocked so the query resolves synchronously in the test.
|
||||
import { setLanguage } from "../i18n";
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -251,7 +251,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("stops asking when the page's own request fails", async () => {
|
||||
it("stops asking when the page's own request fails, and says so with a retry", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
plexPerson.mockReset();
|
||||
@@ -265,6 +265,12 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(plexPerson).toHaveBeenCalledTimes(2); // not every 1.5 s for as long as it fails
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
// Not a blank that reads as "no biography": the failure is said, and a retry asks again.
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("plex.person.bioFailed");
|
||||
plexPerson.mockResolvedValue(person({ biography: "Landed bio." }));
|
||||
fireEvent.click(within(alert).getByRole("button"));
|
||||
await screen.findByText("Landed bio.");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -277,8 +283,8 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
plexPerson.mockReset();
|
||||
plexPerson.mockResolvedValue(person({ biography: "A long life.\n\nMany films." }));
|
||||
wrap(view);
|
||||
fireEvent.click(await screen.findByText("plex.person.more"));
|
||||
expect(screen.getByText("plex.person.less").getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
@@ -287,7 +293,7 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
||||
plexPerson.mockResolvedValue(person({ biography: "Short." }));
|
||||
wrap(view);
|
||||
await screen.findByText("Short.");
|
||||
expect(screen.queryByText("plex.person.more")).toBeNull(); // jsdom: nothing is cut
|
||||
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -99,9 +99,7 @@ describe("an audit row's details", () => {
|
||||
target_id: "rss_poll",
|
||||
after: { status: "skipped", summary: "sync paused" },
|
||||
});
|
||||
expect(details(skipped)).toBe(
|
||||
"RSS-lekérdezés (új feltöltések) kihagyva | kihagyva: a szinkron szünetel",
|
||||
);
|
||||
expect(details(skipped)).toBe("RSS-lekérdezés (új feltöltések) kihagyva | a szinkron szünetel");
|
||||
});
|
||||
|
||||
it("tell a secret's reset from its update, and name a choice", () => {
|
||||
|
||||
@@ -132,3 +132,28 @@ describe("the restart button on a show and a season", () => {
|
||||
expect(onPlay).toHaveBeenCalledWith("e11", ALL, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the show page's summary (E30 review)", () => {
|
||||
it("is clamped with a More, so a long one can still be read in full", async () => {
|
||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||
Object.assign(detail.show, { summary: "A long summary.\n\nThat goes on." });
|
||||
try {
|
||||
wrap(
|
||||
<PlexShowView
|
||||
showId="show"
|
||||
onBack={() => {}}
|
||||
onPlay={() => {}}
|
||||
onOpenSeason={() => {}}
|
||||
onOpenShow={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("plex.textMore"));
|
||||
expect(screen.getByText("plex.textLess").getAttribute("aria-expanded")).toBe("true");
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
Object.assign(detail.show, { summary: undefined });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,17 +325,20 @@ export function PlexPersonView({
|
||||
<ClampedText
|
||||
text={d.biography}
|
||||
lines={8}
|
||||
moreLabel={t("plex.person.more")}
|
||||
lessLabel={t("plex.person.less")}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-4"
|
||||
/>
|
||||
) : bioOnItsWay ? (
|
||||
<LoadingState shape="text" label={t("plex.person.bioLoading")} className="mt-4" />
|
||||
) : (
|
||||
bioOnItsWay && (
|
||||
<LoadingState
|
||||
shape="text"
|
||||
label={t("plex.person.bioLoading")}
|
||||
className="mt-4"
|
||||
/>
|
||||
// An ask that failed while the bio was on its way is said, not left looking like a
|
||||
// person with no bio; the retry asks (and waits) again.
|
||||
d.enriching &&
|
||||
q.isError && (
|
||||
<StateMessage tone="error" compact onRetry={() => q.refetch()}>
|
||||
{t("plex.person.bioFailed")}
|
||||
</StateMessage>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -614,9 +617,13 @@ export function PlexShowView({
|
||||
</div>
|
||||
)}
|
||||
{show.summary && (
|
||||
<p className="measure mt-3 line-clamp-5 text-sm leading-relaxed text-muted">
|
||||
{show.summary}
|
||||
</p>
|
||||
<ClampedText
|
||||
text={show.summary}
|
||||
lines={5}
|
||||
moreLabel={t("plex.textMore")}
|
||||
lessLabel={t("plex.textLess")}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{show.resume && (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the
|
||||
// collection page in the E30 review, where a one-line summary carried a More that did nothing).
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ClampedText } from "./ClampedText";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -44,4 +44,33 @@ describe("ClampedText", () => {
|
||||
box.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("measures again when its box changes: the window or the text size moved the cut", () => {
|
||||
const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something
|
||||
const real = globalThis.ResizeObserver;
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(private cb: ResizeObserverCallback) {}
|
||||
observe() {
|
||||
seen.push(this.cb);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
let height = 60;
|
||||
const tall = vi
|
||||
.spyOn(HTMLElement.prototype, "scrollHeight", "get")
|
||||
.mockImplementation(() => height);
|
||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||
try {
|
||||
render(view("A text that fits, until the reader raises the text size."));
|
||||
expect(screen.queryByText("More")).toBeNull();
|
||||
height = 400; // the text size went up: the same text now runs past three lines
|
||||
act(() => seen[seen.length - 1]!([], {} as ResizeObserver));
|
||||
expect(screen.getByText("More")).toBeTruthy();
|
||||
} finally {
|
||||
tall.mockRestore();
|
||||
box.mockRestore();
|
||||
globalThis.ResizeObserver = real;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// Static class names, so Tailwind sees them.
|
||||
const CLAMP = { 3: "line-clamp-3", 8: "line-clamp-8" } as const;
|
||||
const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as const;
|
||||
|
||||
/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
|
||||
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"textMore": "More",
|
||||
"textLess": "Less",
|
||||
"navLabel": "Plex",
|
||||
"backToLibrary": "Back to library",
|
||||
"collection": {
|
||||
@@ -13,8 +15,6 @@
|
||||
"nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.",
|
||||
"readOnly": "Read-only",
|
||||
"empty": "This collection has no items.",
|
||||
"more": "More",
|
||||
"less": "Less",
|
||||
"addSearch": "Search titles to add…",
|
||||
"addHint": "Type to search for a title to add.",
|
||||
"addNoResults": "No matches.",
|
||||
@@ -353,8 +353,7 @@
|
||||
"aka": "Also known as",
|
||||
"knownFor": "Known for",
|
||||
"bioLoading": "Loading the biography",
|
||||
"more": "More",
|
||||
"less": "Less"
|
||||
"bioFailed": "Couldn't load the biography right now"
|
||||
},
|
||||
"view": {
|
||||
"label": "View",
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"empty": "Még nincs naplóbejegyzés.",
|
||||
"system": "Rendszer",
|
||||
"clear": "Napló ürítése",
|
||||
"clearTitle": "Üríted a naplót?",
|
||||
"clearTitle": "Üríted az eseménynaplót?",
|
||||
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||
"cleared": "{{count}} naplóbejegyzés törölve",
|
||||
"clearFailed": "Nem sikerült üríteni a naplót",
|
||||
"clearFailed": "Nem sikerült üríteni az eseménynaplót",
|
||||
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
|
||||
"on": "Be",
|
||||
"off": "Ki",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"sync_pause": "Szinkron szüneteltetve",
|
||||
"sync_resume": "Szinkron folytatva",
|
||||
"job_run": "Ütemezett feladat lefutott",
|
||||
"audit_clear": "Napló ürítve",
|
||||
"audit_clear": "Eseménynapló ürítve",
|
||||
"download_credential_set": "Letöltési cookie-k beállítva",
|
||||
"download_credential_delete": "Letöltési cookie-k törölve",
|
||||
"invite_delete": "Fehérlista-bejegyzés törölve",
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"hint": "Naponta elkölthető YouTube Data API-egységek (az ingyenes keret 10 000). Az alap 9000 tartalékot hagy."
|
||||
},
|
||||
"backfill_quota_reserve": {
|
||||
"label": "Letöltési kvótatartalék",
|
||||
"label": "Behúzási kvótatartalék",
|
||||
"hint": "Tartalékban tartott egységek, hogy az ütemezett behúzás ne éheztesse ki az interaktív szinkront / dúsítást."
|
||||
},
|
||||
"search_daily_limit_per_user": {
|
||||
@@ -194,11 +194,11 @@
|
||||
},
|
||||
"plex_max_transcodes": {
|
||||
"label": "Max. párhuzamos átkódolás",
|
||||
"hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A remux (stream-copy) munkamenetek csak az összesített munkamenet-korlátba számítanak bele; a direktben játszható fájlokra nincs korlát."
|
||||
"hint": "A nem-H.264 fájlok egyidejű átkódolási munkameneteinek felső korlátja. A közvetlen közvetítés munkamenetei csak az összesített munkamenet-korlátba számítanak bele; a közvetlenül játszható fájlokra nincs korlát."
|
||||
},
|
||||
"plex_max_sessions": {
|
||||
"label": "Max párhuzamos lejátszási munkamenet",
|
||||
"hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a remuxot (stream-copy) is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A direktben játszható fájlok nem munkamenetek, rájuk nincs korlát."
|
||||
"hint": "Az összes egyidejű lejátszási munkamenet felső korlátja, a közvetlen közvetítést is beleértve — mindegyik a film teljes, szegmentált másolatát írja a munkakötetre. Egy fiók legfeljebb kettőt tarthat. A közvetlenül játszható fájlok nem munkamenetek, rájuk nincs korlát."
|
||||
},
|
||||
"plex_transcode_hw": {
|
||||
"label": "Hardveres átkódolás",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"plex_audio_loudnorm": {
|
||||
"label": "Hangerő normalizálása",
|
||||
"hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak remuxos vagy átkódolt lejátszásnál él (a direktben játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes."
|
||||
"hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak közvetlen közvetítésnél vagy átkódolásnál él (a közvetlenül játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes."
|
||||
},
|
||||
"tmdb_api_key": {
|
||||
"label": "TMDB API-kulcs",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"textMore": "Több",
|
||||
"textLess": "Kevesebb",
|
||||
"navLabel": "Plex",
|
||||
"backToLibrary": "Vissza a könyvtárhoz",
|
||||
"collection": {
|
||||
@@ -13,8 +15,6 @@
|
||||
"nameMaybeTaken": "Lehet, hogy már létezik ilyen nevű kollekció egy másik könyvtárban — itt akkor is létrehozhatod.",
|
||||
"readOnly": "Csak olvasható",
|
||||
"empty": "Ennek a kollekciónak nincsenek elemei.",
|
||||
"more": "Több",
|
||||
"less": "Kevesebb",
|
||||
"addSearch": "Keress címet a hozzáadáshoz…",
|
||||
"addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.",
|
||||
"addNoResults": "Nincs találat.",
|
||||
@@ -353,8 +353,7 @@
|
||||
"aka": "Más néven",
|
||||
"knownFor": "Ismert szerepei",
|
||||
"bioLoading": "Az életrajz betöltése",
|
||||
"more": "Több",
|
||||
"less": "Kevesebb"
|
||||
"bioFailed": "Az életrajzot most nem sikerült betölteni"
|
||||
},
|
||||
"view": {
|
||||
"label": "Nézet",
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"channels_processed": "feldolgozott csatorna",
|
||||
"videos_added": "hozzáadott videó",
|
||||
"reconciled_full": "teljesen egyeztetve",
|
||||
"reason": "ok",
|
||||
"reason": "indok",
|
||||
"channels_tagged": "címkézett csatorna",
|
||||
"orphan_tags_removed": "törölt árva címke",
|
||||
"probed": "ellenőrizve",
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("a job's result", () => {
|
||||
"hu",
|
||||
),
|
||||
).toBe(
|
||||
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
|
||||
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · indok: nincs szolgáltatásfiók) · " +
|
||||
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export function jobResultText(t: TFunction, result: JobResult, lang: string): st
|
||||
|
||||
/** What a job run said, as the inbox notice and the audit row both show it (E30): a title naming the job
|
||||
* and how the run ended, and its result in the reader's language — from the data when it rode along,
|
||||
* else read back from the English one-liner. A run skipped for a pause names why; a failed run keeps the
|
||||
* else read back from the English one-liner. A skipped run names only why; a failed run keeps the
|
||||
* upstream's own error text (it is diagnostic). */
|
||||
export function jobRunText(
|
||||
t: TFunction,
|
||||
@@ -63,8 +63,21 @@ export function jobRunText(
|
||||
: (parseSummary(summary) ??
|
||||
(run.status === "skipped" && summary ? { skipped: summary } : null));
|
||||
}
|
||||
// A skipped run's title already says "skipped": its body is only the reason, not "skipped: <reason>".
|
||||
const reason = run.status === "skipped" ? onlySkipReason(result) : null;
|
||||
return {
|
||||
title: t(titleKey, { job }),
|
||||
body: result ? jobResultText(t, result, lang) : summary,
|
||||
body:
|
||||
reason !== null
|
||||
? t(`scheduler.skip.${vocabKey(reason)}`, { defaultValue: reason })
|
||||
: result
|
||||
? jobResultText(t, result, lang)
|
||||
: summary,
|
||||
};
|
||||
}
|
||||
|
||||
function onlySkipReason(result: JobResult | null): string | null {
|
||||
const keys = result ? Object.keys(result) : [];
|
||||
const word = result?.skipped;
|
||||
return keys.length === 1 && typeof word === "string" ? word : null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { LANGUAGE_ORDERED, qk } from "./queryKeys";
|
||||
import { saveLanguage } from "./saveLanguage";
|
||||
import { saveLanguage, serverOrderLang } from "./saveLanguage";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -37,3 +37,12 @@ describe("saveLanguage", () => {
|
||||
expect(asked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serverOrderLang", () => {
|
||||
it("is the stored language, else English — never the UI's guess", () => {
|
||||
expect(serverOrderLang({ language: "hu" })).toBe("hu");
|
||||
expect(serverOrderLang({})).toBe("en"); // nothing stored: the server sorts in English
|
||||
expect(serverOrderLang({ language: "de" })).toBe("en");
|
||||
expect(serverOrderLang(undefined)).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { isSupported } from "../i18n";
|
||||
import { api } from "./api";
|
||||
import { LANGUAGE_ORDERED } from "./queryKeys";
|
||||
|
||||
@@ -9,3 +10,11 @@ export async function saveLanguage(qc: QueryClient, code: string): Promise<void>
|
||||
await api.savePrefs({ language: code });
|
||||
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
|
||||
}
|
||||
|
||||
/** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the
|
||||
* stored preference, else English. Not always the UI's — a browser-detected language, or one picked
|
||||
* while signed out, is never stored — so a list re-slotted here to match the server's order uses it. */
|
||||
export function serverOrderLang(preferences: Record<string, unknown> | null | undefined): string {
|
||||
const lang = preferences?.language;
|
||||
return typeof lang === "string" && isSupported(lang) ? lang : "en";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user