Folder names follow the language, and three bugs that found
Answering "can we ask Stalwart to serve German folder names": no, and it would not help if we could. The account locale exists in `x:AccountSettings`, and ihasmail already reads it -- that is what "Your mail server reports German" comes from -- but writing it needs `sysAccountSettingsSet`, which the built-in user role does not carry; only an admin could. And even then it would change nothing, because folder names are stored data written once when the account is provisioned. No server renames them afterwards; every other client has them mapped. The role is the way through. JMAP tags the standard folders and ihasmail already trusts the role over the name everywhere it matters, so the *displayed* name can follow the interface language with nothing written to the server. A folder somebody made and called "Newsletters" keeps that name: those are their words, and translating them would name a folder they never created. The cost is real and worth stating: Thunderbird on the same account still shows "Deleted Items", because that is what the folder is called. Inside ihasmail it stays consistent -- everything that names a folder goes through one function, including the "moved to …" toast, which exists precisely so that message does not name somewhere the reader cannot find. Renaming still edits the server's own name, never the localised one. Three things fell out of it. The message list refreshed for ever after a language change, which is the one somebody noticed. The root keys its tree on the language version, so a publish remounts everything; remounting re-runs the effect that loads the account's settings, which calls applyLang, which called setCatalog again -- with an identical tag and an identical catalogue -- and publishing that non-change went round again. setCatalog now returns early when nothing changed. Measured rather than assumed: three consecutive five-second windows with no JMAP calls at all, against a pre-change count that never settled. Calendar months and weekdays stayed English, because formatting locale and interface language are separate settings and only the first feeds Intl. Keeping them separate is right -- German dates with an English interface is a real preference -- but somebody who picks German and is shown "September" has not got what they asked for. A chosen interface language now joins the *automatic* chain ahead of the server and the browser. Setting a formatting locale explicitly still wins, and English is not counted, so an English interface on a German browser keeps German dates exactly as before. And the Archive folder read "Archivieren", which is the verb. English uses one word for the button and the folder; German does not, and neither does "Important", which is also a priority tag. tc(context, source) keys the catalogue on both and falls back to the plain English, which was right in English all along -- the gettext approach, including the control character as separator so no real string can collide. The catalogue checker needed teaching about tc() twice: first it reported the eight contextual entries as stale, then it asked for the plain fallbacks as though they were a second obligation. A check that reports work which does not exist gets switched off, which is worse than not having one.
This commit is contained in:
@@ -35,6 +35,16 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
|
|||||||
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
|
||||||
const fn = n.expression.text, a0 = n.arguments[0];
|
const fn = n.expression.text, a0 = n.arguments[0];
|
||||||
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
|
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
|
||||||
|
// tc(context, source) keys the catalogue on both, joined by the same
|
||||||
|
// control character tc() uses. Without this the contextual entries all
|
||||||
|
// looked stale, which is the checker's own false alarm rather than a
|
||||||
|
// catalogue problem.
|
||||||
|
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
|
||||||
|
// Only the contextual key is required. The plain one is tc()'s
|
||||||
|
// fallback, not a second obligation -- asking for both would report
|
||||||
|
// work that does not exist.
|
||||||
|
wanted.add(`${a0.text}\u0004${n.arguments[1].text}`);
|
||||||
|
}
|
||||||
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
if (fn === "plural" && n.arguments[1] && ts.isObjectLiteralExpression(n.arguments[1])) {
|
||||||
for (const p of n.arguments[1].properties) {
|
for (const p of n.arguments[1].properties) {
|
||||||
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
|
if (ts.isPropertyAssignment(p) && p.name.getText(src) === "other" && ts.isStringLiteral(p.initializer)) wanted.add(p.initializer.text);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n";
|
import { CONTEXT_SEPARATOR, currentLanguage, interpolate, plural, setCatalog, subscribeForTest, t, tc, tNode, type Catalog } from "@/lib/i18n";
|
||||||
|
|
||||||
const de: Catalog = {
|
const de: Catalog = {
|
||||||
strings: {
|
strings: {
|
||||||
@@ -109,3 +109,44 @@ describe("tNode", () => {
|
|||||||
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("setCatalog", () => {
|
||||||
|
it("does not announce a change that did not happen", () => {
|
||||||
|
/*
|
||||||
|
* The root keys its tree on the language version, so every publish
|
||||||
|
* remounts the app -- which re-runs the effect that loads the account's
|
||||||
|
* settings, which calls applyLang, which lands back in setCatalog with the
|
||||||
|
* same language. Publishing that non-change looped for ever, and from the
|
||||||
|
* outside it looked like the message list refreshing without end.
|
||||||
|
*/
|
||||||
|
const seen: number[] = [];
|
||||||
|
const stop = subscribeForTest(() => seen.push(1));
|
||||||
|
const cat: Catalog = { strings: { Archive: "Archivieren" }, plurals: {} };
|
||||||
|
setCatalog("de", cat);
|
||||||
|
setCatalog("de", cat);
|
||||||
|
setCatalog("de", cat);
|
||||||
|
expect(seen.length).toBe(1);
|
||||||
|
setCatalog("en", { strings: {}, plurals: {} });
|
||||||
|
expect(seen.length).toBe(2);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tc", () => {
|
||||||
|
it("tells apart an English word doing two jobs", () => {
|
||||||
|
// "Archive" is the button and the folder; German wants a different word
|
||||||
|
// for each, and one key cannot hold both.
|
||||||
|
setCatalog("de", {
|
||||||
|
strings: { "Archive": "Archivieren", [`folder${CONTEXT_SEPARATOR}Archive`]: "Archiv" },
|
||||||
|
plurals: {},
|
||||||
|
});
|
||||||
|
expect(t("Archive")).toBe("Archivieren");
|
||||||
|
expect(tc("folder", "Archive")).toBe("Archiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the plain translation, then to English", () => {
|
||||||
|
setCatalog("de", { strings: { "Drafts": "Entwürfe" }, plurals: {} });
|
||||||
|
expect(tc("folder", "Drafts")).toBe("Entwürfe"); // no context entry yet
|
||||||
|
expect(tc("folder", "Sent")).toBe("Sent"); // nothing at all
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
|
||||||
|
import { setCatalog, type Catalog } from "@/lib/i18n";
|
||||||
|
import type { Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart names the standard folders once, at account creation, and never
|
||||||
|
* renames them — so a German reader on an English-provisioned account would
|
||||||
|
* otherwise see "Deleted Items" in an otherwise German app. The role is what
|
||||||
|
* lets ihasmail say "Papierkorb" without writing anything to the server.
|
||||||
|
*/
|
||||||
|
const de: Catalog = {
|
||||||
|
strings: { Inbox: "Posteingang", "Deleted Items": "Papierkorb", Drafts: "Entwürfe" },
|
||||||
|
plurals: {},
|
||||||
|
};
|
||||||
|
const mb = (id: string, name: string, role: string | null = null, parentId: string | null = null) =>
|
||||||
|
({ id, name, role, parentId } as unknown as Mailbox);
|
||||||
|
|
||||||
|
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
|
||||||
|
|
||||||
|
describe("mailboxDisplayName", () => {
|
||||||
|
it("is the server's name until a catalogue says otherwise", () => {
|
||||||
|
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the interface language for a folder carrying a role", () => {
|
||||||
|
setCatalog("de", de);
|
||||||
|
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Papierkorb");
|
||||||
|
expect(mailboxDisplayName(mb("2", "Inbox", "inbox"))).toBe("Posteingang");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a folder somebody made alone", () => {
|
||||||
|
// "Newsletters" is their word. Translating it would name a folder they
|
||||||
|
// never created, and it would not match what any other client shows.
|
||||||
|
setCatalog("de", de);
|
||||||
|
expect(mailboxDisplayName(mb("3", "Newsletters"))).toBe("Newsletters");
|
||||||
|
expect(mailboxDisplayName(mb("4", "Work", "subscribed"))).toBe("Work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a missing mailbox rather than printing undefined", () => {
|
||||||
|
expect(mailboxDisplayName(null)).toBe("");
|
||||||
|
expect(mailboxDisplayName(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLocalisedName", () => {
|
||||||
|
it("tells an editor when the name on screen is not the server's", () => {
|
||||||
|
// A rename box prefilled with "Papierkorb" would rename the folder to that
|
||||||
|
// the moment somebody pressed Save — a real change made by accident.
|
||||||
|
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
|
||||||
|
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
|
||||||
|
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mailboxDisplayPath", () => {
|
||||||
|
it("localises each part that has a role and leaves the rest", () => {
|
||||||
|
setCatalog("de", de);
|
||||||
|
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
|
||||||
|
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops rather than looping on a parent cycle", () => {
|
||||||
|
// A malformed tree from the server must not hang the folder picker.
|
||||||
|
const all: Record<string, Mailbox> = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") };
|
||||||
|
expect(mailboxDisplayPath(all.a!, all)).toBe("B / A");
|
||||||
|
});
|
||||||
|
});
|
||||||
+25
-3
@@ -24,6 +24,28 @@ export interface DateTimePrefs {
|
|||||||
|
|
||||||
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
|
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interface language, when one has been chosen over the default.
|
||||||
|
*
|
||||||
|
* Formatting and interface language are separate settings on purpose -- German
|
||||||
|
* dates with an English interface is a real preference. But somebody who picks
|
||||||
|
* German and is then shown "September" and "Monday" has not got what they
|
||||||
|
* asked for: choosing a language *is* a statement about language, and month
|
||||||
|
* names are language.
|
||||||
|
*
|
||||||
|
* So it joins the automatic chain, ahead of the server and the browser, and
|
||||||
|
* only while the formatting locale is left on "Automatic". Setting one
|
||||||
|
* explicitly still wins over everything, which is what that setting is for.
|
||||||
|
* English is not counted, because it is the default nobody has to choose --
|
||||||
|
* an English interface on a German browser should keep German dates, as it
|
||||||
|
* always has.
|
||||||
|
*/
|
||||||
|
let uiLanguage: string | null = null;
|
||||||
|
|
||||||
|
export function setUiLanguageForFormatting(tag: string | null | undefined): void {
|
||||||
|
uiLanguage = tag && tag !== "en" ? tag : null;
|
||||||
|
}
|
||||||
|
|
||||||
let prefs: DateTimePrefs = DEFAULT_PREFS;
|
let prefs: DateTimePrefs = DEFAULT_PREFS;
|
||||||
let serverLocale: string | null = null;
|
let serverLocale: string | null = null;
|
||||||
|
|
||||||
@@ -90,9 +112,9 @@ export function normalizeLocale(raw: string | null | undefined): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The locale Intl should use: explicit choice → server → browser default. */
|
/** Explicit choice → chosen interface language → server → browser default. */
|
||||||
export function resolvedLocale(): string | undefined {
|
export function resolvedLocale(): string | undefined {
|
||||||
return prefs.locale || serverLocale || undefined;
|
return prefs.locale || uiLanguage || serverLocale || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the effective locale came from — used to label the "Automatic" option. */
|
/** Where the effective locale came from — used to label the "Automatic" option. */
|
||||||
@@ -524,7 +546,7 @@ let optionsExtras = "";
|
|||||||
* outside the generated list is still selectable).
|
* outside the generated list is still selectable).
|
||||||
*/
|
*/
|
||||||
export function localeOptions(): LocaleOption[] {
|
export function localeOptions(): LocaleOption[] {
|
||||||
const extras = `${serverLocale ?? ""}|${prefs.locale}`;
|
const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`;
|
||||||
if (optionsCache && optionsExtras === extras) return optionsCache;
|
if (optionsCache && optionsExtras === extras) return optionsCache;
|
||||||
const tags = new Set<string>(LOCALE_TAGS);
|
const tags = new Set<string>(LOCALE_TAGS);
|
||||||
if (serverLocale) tags.add(serverLocale);
|
if (serverLocale) tags.add(serverLocale);
|
||||||
|
|||||||
@@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string {
|
|||||||
return interpolate(current.strings[source] ?? source, vars);
|
return interpolate(current.strings[source] ?? source, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate where the English word is doing two jobs.
|
||||||
|
*
|
||||||
|
* English-as-key has one real weakness and this is it: "Archive" is the button
|
||||||
|
* that archives a message and the folder the message lands in, and German
|
||||||
|
* needs "Archivieren" for the first and "Archiv" for the second. One key
|
||||||
|
* cannot hold both. "Important" is the same — a priority tag and a folder.
|
||||||
|
*
|
||||||
|
* So a context can be given, and the lookup becomes context + source while the
|
||||||
|
* fallback stays the plain English. A translator sees the context and knows
|
||||||
|
* which sense to render; a catalogue that has not got round to it still
|
||||||
|
* renders the English word, which was right in English all along.
|
||||||
|
*
|
||||||
|
* The separator is a control character rather than a punctuation mark, which
|
||||||
|
* is the gettext convention and for the same reason: no English string can
|
||||||
|
* contain it by accident.
|
||||||
|
*/
|
||||||
|
export const CONTEXT_SEPARATOR = "\u0004";
|
||||||
|
|
||||||
|
export function tc(context: string, source: string, vars?: Vars): string {
|
||||||
|
const keyed = current.strings[`${context}${CONTEXT_SEPARATOR}${source}`];
|
||||||
|
return interpolate(keyed ?? current.strings[source] ?? source, vars);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate a counted thing.
|
* Translate a counted thing.
|
||||||
*
|
*
|
||||||
@@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Subscribe to catalogue changes without React. Used by the tests. */
|
||||||
|
export function subscribeForTest(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => void listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
/** The language in force, for anything that needs the tag itself. */
|
/** The language in force, for anything that needs the tag itself. */
|
||||||
export function currentLanguage(): string {
|
export function currentLanguage(): string {
|
||||||
return currentTag;
|
return currentTag;
|
||||||
@@ -132,6 +162,23 @@ export function currentLanguage(): string {
|
|||||||
* language's rules against another's forms.
|
* language's rules against another's forms.
|
||||||
*/
|
*/
|
||||||
export function setCatalog(tag: string, catalog: Catalog): void {
|
export function setCatalog(tag: string, catalog: Catalog): void {
|
||||||
|
/*
|
||||||
|
* Publishing only when something actually changed is not an optimisation
|
||||||
|
* here, it is the thing that stops an infinite loop.
|
||||||
|
*
|
||||||
|
* The root keys its tree on the language version, so a publish remounts
|
||||||
|
* everything. Remounting re-runs the effect that fetches the account's
|
||||||
|
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
|
||||||
|
* back here -- with the identical tag and the identical catalogue. Publishing
|
||||||
|
* that non-change bumped the version again and went round for ever: the
|
||||||
|
* message list refetched on every pass, which is what it looked like from
|
||||||
|
* the outside.
|
||||||
|
*
|
||||||
|
* Reference equality is enough. `EMPTY` is a module constant and a
|
||||||
|
* dynamically imported catalogue is cached, so the same language really does
|
||||||
|
* hand back the same object.
|
||||||
|
*/
|
||||||
|
if (currentTag === tag && current === catalog) return;
|
||||||
currentTag = tag;
|
currentTag = tag;
|
||||||
current = catalog;
|
current = catalog;
|
||||||
publish();
|
publish();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { tc } from "@/lib/i18n";
|
||||||
|
import type { Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to call a folder on screen.
|
||||||
|
*
|
||||||
|
* Stalwart names the standard folders once, when the account is created, in
|
||||||
|
* whatever language the server was set up in — and never renames them
|
||||||
|
* afterwards, because the name is stored data every other client has mapped.
|
||||||
|
* So a German reader on an English-provisioned account sees "Deleted Items"
|
||||||
|
* in an otherwise German app, and there is nothing the server can be asked to
|
||||||
|
* do about it: the account locale exists in `x:AccountSettings`, but writing it
|
||||||
|
* needs `sysAccountSettingsSet`, which the built-in user role does not carry.
|
||||||
|
*
|
||||||
|
* The role is the way out. JMAP tags the standard folders — `inbox`, `trash`,
|
||||||
|
* `drafts` and the rest — and ihasmail already trusts the role rather than the
|
||||||
|
* name everywhere it matters, so the display name can follow the interface
|
||||||
|
* language without anything being written to the server.
|
||||||
|
*
|
||||||
|
* Only the roles. A folder somebody made and called "Newsletters" keeps that
|
||||||
|
* name, because those are their words and translating them would be inventing
|
||||||
|
* a folder they never made.
|
||||||
|
*
|
||||||
|
* The cost, and it is real: another client on the same account still shows
|
||||||
|
* "Deleted Items", because that is what the folder is called. Within ihasmail
|
||||||
|
* this stays consistent — everything that names a folder goes through here,
|
||||||
|
* including the "moved to …" toast, which exists precisely so that message
|
||||||
|
* does not name somewhere the reader cannot find.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Every one of these is translated in the "folder" context, including the
|
||||||
|
* unambiguous ones. Two of them genuinely need it -- "Archive" is also the
|
||||||
|
* button that archives, "Important" is also a priority tag, and German wants a
|
||||||
|
* different word for each -- and applying it to only those two would leave the
|
||||||
|
* next person to notice which. A context on all of them is one rule.
|
||||||
|
*/
|
||||||
|
const ROLE_NAMES: Record<string, () => string> = {
|
||||||
|
inbox: () => tc("folder", "Inbox"),
|
||||||
|
archive: () => tc("folder", "Archive"),
|
||||||
|
drafts: () => tc("folder", "Drafts"),
|
||||||
|
sent: () => tc("folder", "Sent"),
|
||||||
|
trash: () => tc("folder", "Deleted Items"),
|
||||||
|
junk: () => tc("folder", "Junk Mail"),
|
||||||
|
important: () => tc("folder", "Important"),
|
||||||
|
all: () => tc("folder", "All mail"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The folder's name as the reader should see it. */
|
||||||
|
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
|
||||||
|
if (!mailbox) return "";
|
||||||
|
const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
|
||||||
|
return localised ? localised() : mailbox.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this folder's displayed name is ihasmail's rather than the server's.
|
||||||
|
*
|
||||||
|
* Anything that *edits* the name has to know: a rename dialog prefilled with
|
||||||
|
* "Papierkorb" would rename the folder to that on the server the moment
|
||||||
|
* somebody pressed Save, which is a real change made by accident to a folder
|
||||||
|
* they were only looking at. Renaming a role folder is refused anyway, but
|
||||||
|
* relying on that would be relying on a rule enforced somewhere else.
|
||||||
|
*/
|
||||||
|
export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
|
||||||
|
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path of folder names, for a picker that shows where a folder sits. */
|
||||||
|
export function mailboxDisplayPath(mailbox: Mailbox, all: Record<string, Mailbox>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let cur: Mailbox | undefined = mailbox;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (cur && !seen.has(cur.id)) {
|
||||||
|
seen.add(cur.id);
|
||||||
|
parts.unshift(mailboxDisplayName(cur));
|
||||||
|
cur = cur.parentId ? all[cur.parentId] : undefined;
|
||||||
|
}
|
||||||
|
return parts.join(" / ");
|
||||||
|
}
|
||||||
@@ -851,6 +851,30 @@ export const catalog: Catalog = {
|
|||||||
"does not match regex": "entspricht regulärem Ausdruck nicht",
|
"does not match regex": "entspricht regulärem Ausdruck nicht",
|
||||||
"exists": "ist vorhanden",
|
"exists": "ist vorhanden",
|
||||||
"does not exist": "ist nicht vorhanden",
|
"does not exist": "ist nicht vorhanden",
|
||||||
|
|
||||||
|
// ── Folder names shown for a JMAP role (see lib/mailboxName.ts) ────
|
||||||
|
// Not what the server calls them: Stalwart names these once at account
|
||||||
|
// creation and cannot rename them afterwards. Custom folders keep the
|
||||||
|
// reader's own words and are never translated.
|
||||||
|
// Keyed with the "folder" context (see tc() in lib/i18n.ts), because two
|
||||||
|
// of these are also something else in English: "Archive" is the button
|
||||||
|
// that archives a message, and "Important" is a priority tag. German wants
|
||||||
|
// a different word for each, and one key cannot hold both.
|
||||||
|
"folder\u0004Inbox": "Posteingang",
|
||||||
|
"folder\u0004Archive": "Archiv",
|
||||||
|
"folder\u0004Drafts": "Entwürfe",
|
||||||
|
"folder\u0004Sent": "Gesendet",
|
||||||
|
"folder\u0004Deleted Items": "Papierkorb",
|
||||||
|
"folder\u0004Junk Mail": "Spam",
|
||||||
|
"folder\u0004Important": "Wichtig",
|
||||||
|
"folder\u0004All mail": "Alle Nachrichten",
|
||||||
|
"folder": "Ordner",
|
||||||
|
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
||||||
|
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
||||||
|
"Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}",
|
||||||
|
"Delete “{name}”?": "„{name}“ löschen?",
|
||||||
|
"Rename folder": "Ordner umbenennen",
|
||||||
|
"Search: {query}": "Suche: {query}",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { settings, useSettings } from "./settings";
|
import { settings, useSettings } from "./settings";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Named explicitly so `shareWith` comes back, which it does not otherwise --
|
* Named explicitly so `shareWith` comes back, which it does not otherwise --
|
||||||
@@ -478,7 +480,9 @@ export const useMail = create<MailState>((set, get) => ({
|
|||||||
// moved to "Trash" or "Spam" on a server whose folders are called
|
// moved to "Trash" or "Spam" on a server whose folders are called
|
||||||
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
// "Deleted Items" and "Junk Mail" -- naming somewhere that does not
|
||||||
// exist, in the one message whose job is saying where it went.
|
// exist, in the one message whose job is saying where it went.
|
||||||
const name = mailboxes[toMailboxId]?.name ?? opts.label ?? "folder";
|
// Through the display name, so the message names the folder the reader
|
||||||
|
// is looking at in the sidebar rather than the server's own word for it.
|
||||||
|
const name = mailboxDisplayName(mailboxes[toMailboxId]) || opts.label || t("folder");
|
||||||
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
toast.show(`${ids.length === 1 ? "Conversation" : `${ids.length} conversations`} moved to ${name}`, {
|
||||||
action: {
|
action: {
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { loadJson, saveJson } from "@/lib/storage";
|
import { loadJson, saveJson } from "@/lib/storage";
|
||||||
import { queueSettingsPush } from "@/lib/settingsSync";
|
import { queueSettingsPush } from "@/lib/settingsSync";
|
||||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
import { setDateTimePrefs, setUiLanguageForFormatting, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
import type { SwipeAction } from "@/lib/swipe";
|
import type { SwipeAction } from "@/lib/swipe";
|
||||||
import { resolveUiLanguage } from "@/lib/languages";
|
import { resolveUiLanguage } from "@/lib/languages";
|
||||||
import { loadLanguage } from "@/lib/i18n";
|
import { loadLanguage } from "@/lib/i18n";
|
||||||
@@ -344,6 +344,9 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function applyDateTimePrefs(s: Settings): void {
|
function applyDateTimePrefs(s: Settings): void {
|
||||||
|
// The interface language feeds the automatic locale, so month and weekday
|
||||||
|
// names follow the language somebody chose rather than staying English.
|
||||||
|
setUiLanguageForFormatting(resolveUiLanguage(s.uiLanguage));
|
||||||
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { toast } from "@/ui/toast";
|
|||||||
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
import { isUnknownMailbox } from "@/lib/mailboxRoute";
|
||||||
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
|
||||||
import { plural, t as translate, tNode } from "@/lib/i18n";
|
import { plural, t as translate, tNode } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
|
|
||||||
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
|||||||
[emails, mailboxId, mailboxes, openThread, openDraft],
|
[emails, mailboxId, mailboxes, openThread, openDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = search ? `Search: ${listQuery?.label ?? q}` : (mailboxId && mailboxes[mailboxId]?.name) || "Mail";
|
const title = search ? translate("Search: {query}", { query: listQuery?.label ?? q }) : (mailboxId && mailboxDisplayName(mailboxes[mailboxId])) || translate("Mail");
|
||||||
const reading = Boolean(threadId);
|
const reading = Boolean(threadId);
|
||||||
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
const paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right";
|
||||||
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMail } from "@/store/mail";
|
|||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||||
|
|
||||||
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
export function MailboxPicker({ title, onClose, onPick, exclude }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[] }) {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri
|
|||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
const all = Object.values(mailboxes)
|
const all = Object.values(mailboxes)
|
||||||
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
|
.filter((m) => !exclude?.includes(m.id) && m.myRights.mayAddItems)
|
||||||
.map((m) => ({ m, path: mailboxPath(m.id) }))
|
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) }))
|
||||||
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
||||||
const ql = q.trim().toLowerCase();
|
const ql = q.trim().toLowerCase();
|
||||||
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage";
|
|||||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||||
import { haptic, useTouchRow } from "@/lib/touch";
|
import { haptic, useTouchRow } from "@/lib/touch";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
|
|
||||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||||
inbox: <Inbox size={20} />,
|
inbox: <Inbox size={20} />,
|
||||||
@@ -63,9 +64,9 @@ export function MailboxTree() {
|
|||||||
setExpanded(next);
|
setExpanded(next);
|
||||||
saveJson("mbx-expanded", next);
|
saveJson("mbx-expanded", next);
|
||||||
}
|
}
|
||||||
toast.success(parentId ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`);
|
toast.success(parentId ? t("“{name}” moved into “{parent}”", { name: mailboxDisplayName(m), parent: mailboxDisplayName(mailboxes[parentId]) }) : t("“{name}” moved to the top level", { name: mailboxDisplayName(m) }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`);
|
toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(m), reason: (err as Error).message }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ export function MailboxTree() {
|
|||||||
<FolderRow
|
<FolderRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
mailbox={m}
|
mailbox={m}
|
||||||
label={m.name}
|
label={mailboxDisplayName(m)}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -332,7 +333,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
const rename = async () => {
|
const rename = async () => {
|
||||||
const name = await promptDialog({ title: "Rename folder", defaultValue: m.name });
|
const name = await // The server's own name, never the localised one: this box writes
|
||||||
|
// back whatever it is prefilled with.
|
||||||
|
promptDialog({ title: t("Rename folder"), defaultValue: m.name });
|
||||||
if (!name?.trim() || name.trim() === m.name) return;
|
if (!name?.trim() || name.trim() === m.name) return;
|
||||||
try {
|
try {
|
||||||
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
await useMail.getState().updateMailbox(m.id, { name: name.trim() });
|
||||||
@@ -341,7 +344,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
const ok = await confirmDialog({ title: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await useMail.getState().destroyMailbox(m.id, true);
|
await useMail.getState().destroyMailbox(m.id, true);
|
||||||
@@ -351,7 +354,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
|||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const empty = () => confirmAndEmpty({ id: m.id, name: m.name, role: m.role, totalEmails: m.totalEmails });
|
const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails });
|
||||||
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
const isSpecial = Boolean(m.role) && m.role !== "subscribed";
|
||||||
const color = folderColor(colors, m.id);
|
const color = folderColor(colors, m.id);
|
||||||
const setColor = (c: string | null) => {
|
const setColor = (c: string | null) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format";
|
|||||||
import { ShareDialog } from "./ShareDialog";
|
import { ShareDialog } from "./ShareDialog";
|
||||||
import type { Mailbox } from "@/jmap/types";
|
import type { Mailbox } from "@/jmap/types";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||||
|
|
||||||
export function FoldersSettings() {
|
export function FoldersSettings() {
|
||||||
const mailboxes = useMail((s) => s.mailboxes);
|
const mailboxes = useMail((s) => s.mailboxes);
|
||||||
@@ -42,12 +43,14 @@ export function FoldersSettings() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{list.map(({ m, path }) => (
|
{list.map(({ m, path }) => (
|
||||||
<tr key={m.id}>
|
<tr key={m.id}>
|
||||||
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{path}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
<td><div className="row gap-8">{m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}<span>{mailboxDisplayPath(m, mailboxes)}</span>{!m.isSubscribed && <span className="badge muted">{t("hidden")}</span>}{m.role && m.role !== "subscribed" && <span className="hint">({m.role})</span>}</div></td>
|
||||||
<td>{m.totalEmails.toLocaleString()}</td>
|
<td>{m.totalEmails.toLocaleString()}</td>
|
||||||
<td>{m.unreadEmails.toLocaleString()}</td>
|
<td>{m.unreadEmails.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
<div className="row" style={{ justifyContent: "flex-end", gap: 0 }}>
|
||||||
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await promptDialog({ title: "Rename folder", defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localised one: this box writes
|
||||||
|
// back whatever it is prefilled with.
|
||||||
|
promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||||
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
||||||
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||||
|
|||||||
Reference in New Issue
Block a user