diff --git a/scripts/i18n-catalog-check.mjs b/scripts/i18n-catalog-check.mjs index 30b03f5..695857a 100644 --- a/scripts/i18n-catalog-check.mjs +++ b/scripts/i18n-catalog-check.mjs @@ -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)) { const fn = n.expression.text, a0 = n.arguments[0]; 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])) { 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); diff --git a/web/src/lib/__tests__/i18n.test.tsx b/web/src/lib/__tests__/i18n.test.tsx index 24fe203..d5a64a4 100644 --- a/web/src/lib/__tests__/i18n.test.tsx +++ b/web/src/lib/__tests__/i18n.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; 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 = { strings: { @@ -109,3 +109,44 @@ describe("tNode", () => { expect(render(tNode("{count} of {scheme}", { scheme: x }, { count: 3 }))).toBe("3 of x"); }); }); + +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 + }); +}); diff --git a/web/src/lib/__tests__/mailboxName.test.ts b/web/src/lib/__tests__/mailboxName.test.ts new file mode 100644 index 0000000..555065d --- /dev/null +++ b/web/src/lib/__tests__/mailboxName.test.ts @@ -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 = { a: mb("a", "A", null, "b"), b: mb("b", "B", null, "a") }; + expect(mailboxDisplayPath(all.a!, all)).toBe("B / A"); + }); +}); diff --git a/web/src/lib/datetime.ts b/web/src/lib/datetime.ts index d31d06b..7d6e15b 100644 --- a/web/src/lib/datetime.ts +++ b/web/src/lib/datetime.ts @@ -24,6 +24,28 @@ export interface DateTimePrefs { 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 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 { - return prefs.locale || serverLocale || undefined; + return prefs.locale || uiLanguage || serverLocale || undefined; } /** 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). */ export function localeOptions(): LocaleOption[] { - const extras = `${serverLocale ?? ""}|${prefs.locale}`; + const extras = `${serverLocale ?? ""}|${prefs.locale}|${uiLanguage ?? ""}`; if (optionsCache && optionsExtras === extras) return optionsCache; const tags = new Set(LOCALE_TAGS); if (serverLocale) tags.add(serverLocale); diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index 51af7b9..ebe4510 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -61,6 +61,30 @@ export function t(source: string, vars?: Vars): string { 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. * @@ -119,6 +143,12 @@ export function tNode(source: string, parts: Record, vars?: V 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. */ export function currentLanguage(): string { return currentTag; @@ -132,6 +162,23 @@ export function currentLanguage(): string { * language's rules against another's forms. */ 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; current = catalog; publish(); diff --git a/web/src/lib/mailboxName.ts b/web/src/lib/mailboxName.ts new file mode 100644 index 0000000..2dbef51 --- /dev/null +++ b/web/src/lib/mailboxName.ts @@ -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> = { + 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 { + const parts: string[] = []; + let cur: Mailbox | undefined = mailbox; + const seen = new Set(); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + parts.unshift(mailboxDisplayName(cur)); + cur = cur.parentId ? all[cur.parentId] : undefined; + } + return parts.join(" / "); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 251ca96..449e095 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -851,6 +851,30 @@ export const catalog: Catalog = { "does not match regex": "entspricht regulärem Ausdruck nicht", "exists": "ist 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: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" }, diff --git a/web/src/store/mail.ts b/web/src/store/mail.ts index fddc9a9..6b2c4fa 100644 --- a/web/src/store/mail.ts +++ b/web/src/store/mail.ts @@ -21,6 +21,8 @@ import type { import { toast } from "@/ui/toast"; import { settings, useSettings } from "./settings"; 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 -- @@ -478,7 +480,9 @@ export const useMail = create((set, get) => ({ // moved to "Trash" or "Spam" on a server whose folders are called // "Deleted Items" and "Junk Mail" -- naming somewhere that does not // 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}`, { action: { label: "Undo", diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 8c767bb..80975f8 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { create } from "zustand"; import { loadJson, saveJson } from "@/lib/storage"; 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 { resolveUiLanguage } from "@/lib/languages"; import { loadLanguage } from "@/lib/i18n"; @@ -344,6 +344,9 @@ export const useSettings = create((set, get) => ({ })); 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 }); } diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 732e124..4f84828 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -17,6 +17,7 @@ import { toast } from "@/ui/toast"; import { isUnknownMailbox } from "@/lib/mailboxRoute"; import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled"; 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 }) { const [, navigate] = useLocation(); @@ -306,7 +307,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; [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 paneClass = settings.readingPane === "bottom" ? "pane-bottom" : settings.readingPane === "off" ? "pane-off" : "pane-right"; const showList = !(settings.readingPane === "off" && reading) && !(narrow && reading); diff --git a/web/src/views/mail/MailboxPicker.tsx b/web/src/views/mail/MailboxPicker.tsx index 2d92bd0..8bd9630 100644 --- a/web/src/views/mail/MailboxPicker.tsx +++ b/web/src/views/mail/MailboxPicker.tsx @@ -4,6 +4,7 @@ import { useMail } from "@/store/mail"; import { Dialog } from "@/ui/dialog"; import type { Id, Mailbox } from "@/jmap/types"; 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[] }) { const mailboxes = useMail((s) => s.mailboxes); @@ -13,7 +14,7 @@ export function MailboxPicker({ title, onClose, onPick, exclude }: { title: stri const list = useMemo(() => { const all = Object.values(mailboxes) .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))); const ql = q.trim().toLowerCase(); return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all; diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 421f6bb..d2b3de9 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -15,6 +15,7 @@ import { loadRaw, saveJson } from "@/lib/storage"; import { canDropFolder, folderColor, movable } from "@/lib/folderMove"; import { haptic, useTouchRow } from "@/lib/touch"; import { t } from "@/lib/i18n"; +import { mailboxDisplayName } from "@/lib/mailboxName"; const ROLE_ICONS: Record = { inbox: , @@ -63,9 +64,9 @@ export function MailboxTree() { setExpanded(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) { - 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() { { - 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; try { 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 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; try { 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); } }; - 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 color = folderColor(colors, m.id); const setColor = (c: string | null) => { diff --git a/web/src/views/settings/FoldersSettings.tsx b/web/src/views/settings/FoldersSettings.tsx index bfa238a..4e1b0ee 100644 --- a/web/src/views/settings/FoldersSettings.tsx +++ b/web/src/views/settings/FoldersSettings.tsx @@ -7,6 +7,7 @@ import { formatSize } from "@/lib/format"; import { ShareDialog } from "./ShareDialog"; import type { Mailbox } from "@/jmap/types"; import { t } from "@/lib/i18n"; +import { mailboxDisplayPath } from "@/lib/mailboxName"; export function FoldersSettings() { const mailboxes = useMail((s) => s.mailboxes); @@ -42,12 +43,14 @@ export function FoldersSettings() { {list.map(({ m, path }) => ( -
{m.role === "inbox" ? : }{path}{!m.isSubscribed && {t("hidden")}}{m.role && m.role !== "subscribed" && ({m.role})}
+
{m.role === "inbox" ? : }{mailboxDisplayPath(m, mailboxes)}{!m.isSubscribed && {t("hidden")}}{m.role && m.role !== "subscribed" && ({m.role})}
{m.totalEmails.toLocaleString()} {m.unreadEmails.toLocaleString()}
- + {Object.keys(m.shareWith ?? {}).length > 0 && }