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:
2026-10-01 10:43:59 +02:00
parent 08e62bbb16
commit 3563858fd1
14 changed files with 147 additions and 32 deletions
+26 -12
View File
@@ -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"]);
});
});
+5 -2
View File
@@ -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);
+7 -3
View File
@@ -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";