fix(ui,i18n): E30 review round 3 — the stored language follows the screen, a fresh text starts collapsed, Retry waits again
This commit is contained in:
@@ -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<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
||||
let savePrefs: MockInstance<typeof api.savePrefs>;
|
||||
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<Me>(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<Me | null>(() => {}));
|
||||
mount();
|
||||
|
||||
@@ -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<number | null | undefined>(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
|
||||
|
||||
@@ -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<ManagedChannel[]>(
|
||||
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 }));
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
<PlexShowView
|
||||
key={showId}
|
||||
showId={showId}
|
||||
onBack={sub.back}
|
||||
onPlay={(epRk, queue, fromStart) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
<StateMessage tone="error" compact onRetry={() => q.refetch()}>
|
||||
<StateMessage
|
||||
tone="error"
|
||||
compact
|
||||
onRetry={() => {
|
||||
setWaitOver(false);
|
||||
setWaitRound((n) => n + 1);
|
||||
void q.refetch();
|
||||
}}
|
||||
>
|
||||
{t("plex.person.bioFailed")}
|
||||
</StateMessage>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLParagraphElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 újraszkennel — 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."
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T extends Ordered>(
|
||||
rows: readonly T[],
|
||||
id: string,
|
||||
lang: string,
|
||||
preferences: Record<string, unknown> | 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);
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown> | null | undefined): string {
|
||||
const lang = preferences?.language;
|
||||
return typeof lang === "string" && isSupported(lang) ? lang : "en";
|
||||
|
||||
Reference in New Issue
Block a user