diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index 55c18d51..a81f4c74 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -8,7 +8,7 @@ // // Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe // records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted. -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect } from "react"; @@ -47,6 +47,7 @@ function mount() { return qc; } const account = (over: Partial = {}) => ({ id: 5, preferences: {}, ...over }) as Me; +let savePrefs: MockInstance; const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") }); beforeEach(async () => { @@ -57,6 +58,7 @@ beforeEach(async () => { mounts = 0; await i18n.changeLanguage("en"); vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true }); + savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never); }); afterEach(() => { cleanup(); @@ -85,6 +87,26 @@ describe("BootGate", () => { expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu")); }); + it("an account with no language stored stores the one it is shown in, once", async () => { + await i18n.changeLanguage("hu"); // the browser's language, never saved + vi.spyOn(api, "me").mockResolvedValue(account()); + const qc = mount(); + + await screen.findByTestId("app"); + await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" })); + expect(savePrefs).toHaveBeenCalledTimes(1); + // The server now sorts in Hungarian, and so does what reads the stored language. + await waitFor(() => expect(qc.getQueryData(qk.me())?.preferences.language).toBe("hu")); + }); + + it("an account with its language stored stores nothing", async () => { + vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } })); + mount(); + + await screen.findByTestId("app"); + expect(savePrefs).not.toHaveBeenCalled(); + }); + it("mounts nothing of the app while the identity is still unknown", async () => { vi.spyOn(api, "me").mockReturnValue(new Promise(() => {})); mount(); diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx index 9e7bc8f0..5091975e 100644 --- a/frontend/src/components/BootGate.tsx +++ b/frontend/src/components/BootGate.tsx @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { api, getActiveAccount, setActiveAccount } from "../lib/api"; +import { log } from "../lib/log"; +import { saveLanguage } from "../lib/saveLanguage"; import { qk } from "../lib/queryKeys"; import i18n, { isSupported, setLanguage } from "../i18n"; import { LoadingState, StateMessage } from "./ui/QueryState"; @@ -38,6 +40,7 @@ export function BootScreen() { export default function BootGate({ children }: { children: ReactNode }) { const { t } = useTranslation(); + const qc = useQueryClient(); // First-run install gate: a fresh instance reports configured=false and runs in setup mode, where // every other route is locked — so `me` is held back until the instance is known to be set up // (otherwise it would 503 against the setup-mode lock). @@ -81,7 +84,10 @@ export default function BootGate({ children }: { children: ReactNode }) { // not is corrected here — otherwise the tab would run as the default account on the server while // reading and writing the gone account's local state. // • Adopt the account's language, which follows the account across devices — the local copy only - // knows what this browser last showed. + // knows what this browser last showed. An account with none stored yet (the setup wizard's admin, + // an account older than the stamp sign-up gives) stores the language it is shown in: the server + // orders its lists and writes its mail in the stored one, else English, and a Hungarian screen + // over English-ordered lists breaks "A→Z in the UI language's alphabet". const [readyFor, setReadyFor] = useState(undefined); // `me` is read for the identity it produced, so it rides a ref: a background refetch of the same // account must not re-run the adoption over a language the user has just changed. That rule used @@ -104,11 +110,21 @@ export default function BootGate({ children }: { children: ReactNode }) { const account = meRef.current; if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); const lang = account?.preferences?.language; - // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. - if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang); + if (typeof lang === "string" && isSupported(lang)) { + // eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale. + if (lang !== i18n.language) setLanguage(lang); + } else if (account) { + // eslint-disable-next-line local/locale-format -- a one-shot read of the language the app is about to be shown in, stored once; nothing renders it. + const shown = i18n.language; + saveLanguage(qc, shown).catch((e: unknown) => + log.warn("boot.language_store_failed", "the shown language could not be stored", { + error: String(e), + }), + ); + } // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them. setReadyFor(identity); - }, [identity, readyFor]); + }, [identity, readyFor, qc]); // No answer at all (a network or 5xx failure on the very first setup or account probe — past the // one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a6dc845a..58cd49e9 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -27,7 +27,6 @@ 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"; @@ -174,7 +173,7 @@ export default function Channels() { setPriority(c.id, next); qc.setQueryData( qk.channels(), - (old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)), + (old) => old && placeByPriority(old, c.id, me.preferences), ); setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 })); }; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4bd12039..b147c691 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -483,7 +483,9 @@ export default function PlexBrowse() { if (sub.view.kind === "show") { const showId = sub.view.id; return ( + // Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog). diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 66be5c9c..4adbaef2 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -276,6 +276,29 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => { } }); + it("a retry after the wait is over waits again: a bio still on its way still lands", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + plexPerson.mockReset(); + plexPerson + .mockResolvedValueOnce(person({ biography: null, enriching: true })) + .mockRejectedValueOnce(new Error("server restarting")) + .mockResolvedValueOnce(person({ biography: null, enriching: true })) // the retry + .mockResolvedValue(person({ biography: "Late bio." })); + wrap(view); + await screen.findByText("Movie One"); + await act(() => vi.advanceTimersByTimeAsync(BIO_POLL)); + await act(() => vi.advanceTimersByTimeAsync(25_000)); // the first wait runs out + fireEvent.click(within(screen.getByRole("alert")).getByRole("button")); + await act(() => vi.advanceTimersByTimeAsync(10)); + expect(screen.getByRole("status")).toBeTruthy(); // on its way again, not a blank + await act(() => vi.advanceTimersByTimeAsync(BIO_POLL)); + await screen.findByText("Late bio."); + } finally { + vi.useRealTimers(); + } + }); + it("offers More only when the bio is cut, and Less once open", async () => { const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index aff680e3..0481054f 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -160,12 +160,14 @@ export function PlexPersonView({ // S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not // forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask // leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at - // once (the next open tries again). The page is keyed on the person, so the wait starts per person. + // once (the next open tries again). The page is keyed on the person, so the wait starts per person; + // a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing. const [waitOver, setWaitOver] = useState(false); + const [waitRound, setWaitRound] = useState(0); useEffect(() => { const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS); return () => clearTimeout(timer); - }, []); + }, [waitRound]); const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang), @@ -336,7 +338,15 @@ export function PlexPersonView({ // person with no bio; the retry asks (and waits) again. d.enriching && q.isError && ( - q.refetch()}> + { + setWaitOver(false); + setWaitRound((n) => n + 1); + void q.refetch(); + }} + > {t("plex.person.bioFailed")} ) diff --git a/frontend/src/components/ui/ClampedText.dom.test.tsx b/frontend/src/components/ui/ClampedText.dom.test.tsx index 711fc1c2..6dbcad68 100644 --- a/frontend/src/components/ui/ClampedText.dom.test.tsx +++ b/frontend/src/components/ui/ClampedText.dom.test.tsx @@ -45,6 +45,21 @@ describe("ClampedText", () => { } }); + it("starts another text collapsed: a reused view opening the next item", () => { + const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400); + const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60); + try { + const { rerender } = render(view("The first show's long summary.")); + fireEvent.click(screen.getByText("More")); + rerender(view("The next show's long summary.")); + expect(screen.getByText(/next show/).className).toContain("line-clamp-3"); + expect(screen.getByText("More").getAttribute("aria-expanded")).toBe("false"); + } finally { + tall.mockRestore(); + 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; diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index 9422a0e8..41ddf634 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -6,7 +6,9 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con /** 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 - * resize: the window and the text-size slider both move where the cut falls. */ + * resize: the window and the text-size slider both move where the cut falls. Another text starts + * collapsed: a view reused for the next item (a show page opening another show) must not open its + * text already expanded. */ export function ClampedText({ text, lines, @@ -22,6 +24,11 @@ export function ClampedText({ }) { const [expanded, setExpanded] = useState(false); const [clipped, setClipped] = useState(false); + const [shown, setShown] = useState(text); + if (shown !== text) { + setShown(text); + setExpanded(false); + } const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; diff --git a/frontend/src/i18n/locales/en/audit.json b/frontend/src/i18n/locales/en/audit.json index de5f2d0d..82df2382 100644 --- a/frontend/src/i18n/locales/en/audit.json +++ b/frontend/src/i18n/locales/en/audit.json @@ -3,7 +3,7 @@ "intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.", "empty": "No audit entries yet.", "system": "System", - "clear": "Clear log", + "clear": "Clear audit log", "clearTitle": "Clear the audit log?", "clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)", "cleared": "Cleared {{count}} audit entries", diff --git a/frontend/src/i18n/locales/hu/audit.json b/frontend/src/i18n/locales/hu/audit.json index 0478b75b..907042c2 100644 --- a/frontend/src/i18n/locales/hu/audit.json +++ b/frontend/src/i18n/locales/hu/audit.json @@ -3,7 +3,7 @@ "intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.", "empty": "Még nincs naplóbejegyzés.", "system": "Rendszer", - "clear": "Napló ürítése", + "clear": "Eseménynapló ürítése", "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", diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index af07a43c..06755b52 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -75,7 +75,7 @@ "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.", - "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.", + "audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, az eseménynapló csak nő — ártalmatlan, de korlátlanul.", "log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.", "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.", "people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később." diff --git a/frontend/src/lib/channelOrder.dom.test.ts b/frontend/src/lib/channelOrder.dom.test.ts index 583b22aa..479b8751 100644 --- a/frontend/src/lib/channelOrder.dom.test.ts +++ b/frontend/src/lib/channelOrder.dom.test.ts @@ -1,28 +1,32 @@ +// @vitest-environment jsdom +// jsdom: the order language comes from the account's stored one, checked against i18n's languages. import { describe, expect, it } from "vitest"; import { placeByPriority, priorityChoices } from "./channelOrder"; const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title }); const ids = (rows: { id: string }[]) => rows.map((r) => r.id); +const EN = { language: "en" }; +const HU = { language: "hu" }; describe("placeByPriority", () => { const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)]; it("bubbles a raised channel up to the first row it now outranks", () => { expect( - ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")), + ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", EN)), ).toEqual(["a", "b", "c", "e", "d"]); }); it("sinks a lowered channel below every row it no longer outranks", () => { - expect( - ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")), - ).toEqual(["b", "c", "a", "d", "e"]); + expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", EN))).toEqual( + ["b", "c", "a", "d", "e"], + ); }); it("orders equal priorities by title, case-folded, a missing title last", () => { const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")]; - expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]); - expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([ + expect(ids(placeByPriority(rows, "z", EN))).toEqual(["z", "x", "y"]); + expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", EN))).toEqual([ "x", "y", ]); @@ -31,21 +35,31 @@ describe("placeByPriority", () => { it("leaves every other row where it was, even ones out of the server's order", () => { const rows = [row("p", 0), row("q", 9), row("r", 2)]; // r lands before the first row it outranks; p and q keep their (unsorted) order. - expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]); + expect(ids(placeByPriority(rows, "r", EN))).toEqual(["r", "p", "q"]); }); it("returns an unknown id's list unchanged", () => { - expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list)); + expect(ids(placeByPriority(list, "nope", EN))).toEqual(ids(list)); }); it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => { const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")]; const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }]; - expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]); + expect(ids(placeByPriority(moved, "m", HU))).toEqual(["e", "m", "f"]); const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")]; - expect( - ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")), - ).toEqual(["u", "c", "x"]); + expect(ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", HU))).toEqual([ + "u", + "c", + "x", + ]); + }); + + it("places by the STORED language, whatever the screen shows: the server sorts by that", () => { + const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")]; + const moved = [...cs.slice(0, 2), { ...cs[2]!, priority: 0 }]; + // None stored (a Hungarian screen picked while signed out): the server's English order. + expect(ids(placeByPriority(moved, "c", {}))).toEqual(["c", "u", "x"]); + expect(ids(placeByPriority(moved, "c", { language: "de" }))).toEqual(["c", "u", "x"]); }); }); diff --git a/frontend/src/lib/channelOrder.ts b/frontend/src/lib/channelOrder.ts index 75fcd41b..5eb877f0 100644 --- a/frontend/src/lib/channelOrder.ts +++ b/frontend/src/lib/channelOrder.ts @@ -1,4 +1,5 @@ import { compareText } from "./collate"; +import { serverOrderLang } from "./saveLanguage"; // The channel manager's list order, and the priority steps its menu offers (E28 S3e3b). @@ -32,14 +33,16 @@ function before(a: Ordered, b: Ordered, lang: string): boolean { /** `rows` with the channel `id` taken out and put back where its priority now places it, every * other row left exactly where it was — the server's order, without a refetch that would reorder - * anything the reader did not touch. */ + * anything the reader did not touch. It takes the account's `preferences`, not a language: the + * server sorts in the STORED language (`serverOrderLang`), which a UI language is not always. */ export function placeByPriority( rows: readonly T[], id: string, - lang: string, + preferences: Record | null | undefined, ): T[] { const moved = rows.find((r) => r.id === id); if (!moved) return [...rows]; + const lang = serverOrderLang(preferences); const rest = rows.filter((r) => r !== moved); const at = rest.findIndex((r) => before(moved, r, lang)); rest.splice(at < 0 ? rest.length : at, 0, moved); diff --git a/frontend/src/lib/saveLanguage.ts b/frontend/src/lib/saveLanguage.ts index bd414407..a06c9660 100644 --- a/frontend/src/lib/saveLanguage.ts +++ b/frontend/src/lib/saveLanguage.ts @@ -2,18 +2,22 @@ import type { QueryClient } from "@tanstack/react-query"; import { isSupported } from "../i18n"; import { api } from "./api"; import { LANGUAGE_ORDERED } from "./queryKeys"; +import { updateMeCache } from "./useMe"; /** Store the reader's language choice on the server, then ask again for the lists the server orders * by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet. - * Rejects with the save's error; the lists are left alone then. */ + * The cached `me` takes it too, so what reads the stored language (`serverOrderLang`) agrees with the + * server at once, not at the next `me` poll. Rejects with the save's error; nothing is touched then. */ export async function saveLanguage(qc: QueryClient, code: string): Promise { await api.savePrefs({ language: code }); + updateMeCache(qc, (me) => ({ ...me, preferences: { ...me.preferences, 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. */ + * stored preference, else English. Not always the UI's: an account with none stored stores the one + * it is shown in at boot (BootGate), but until that save lands, or if it fails, the two differ — so a + * list re-slotted here to match the server's order uses this, not the UI's. */ export function serverOrderLang(preferences: Record | null | undefined): string { const lang = preferences?.language; return typeof lang === "string" && isSupported(lang) ? lang : "en";