diff --git a/web/src/lib/__tests__/languages.test.ts b/web/src/lib/__tests__/languages.test.ts new file mode 100644 index 0000000..eda00fa --- /dev/null +++ b/web/src/lib/__tests__/languages.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_UI_LANGUAGE, UI_LANGUAGES, resolveUiLanguage } from "@/lib/languages"; +import { DEFAULT_SETTINGS, acceptRemote } from "@/store/settings"; + +/** + * The interface language decides what `` claims, and a wrong claim + * is exactly what makes Chrome offer to translate a page that needs no + * translating — which is the offer that ends in a rewritten DOM and a crashed + * component tree. So the resolution is deliberately narrow. + */ +describe("resolveUiLanguage", () => { + it("is English when nothing has been chosen", () => { + // The absent case covers both a new account and every settings file + // written before this setting existed. + expect(resolveUiLanguage(undefined)).toBe("en"); + expect(resolveUiLanguage(null)).toBe("en"); + expect(resolveUiLanguage("")).toBe("en"); + expect(DEFAULT_SETTINGS.uiLanguage).toBe(DEFAULT_UI_LANGUAGE); + }); + + it("refuses a language whose strings are not shipped", () => { + // The account travels between machines and can outlive a catalogue. A + // page that says lang="de" while rendering English is worse than one that + // admits to English: it stops the reader translating it themselves. + expect(resolveUiLanguage("de")).toBe("en"); + expect(resolveUiLanguage("xx-XX")).toBe("en"); + }); + + it("honours one that is", () => { + for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag); + }); + + it("only offers languages that resolve to themselves", () => { + // Guards the ordering mistake: adding a picker entry before its catalogue. + for (const l of UI_LANGUAGES) { + expect(resolveUiLanguage(l.tag)).toBe(l.tag); + expect(l.name.trim()).not.toBe(""); + } + }); + + it("follows the account rather than the device", () => { + // Language is a preference about the person, not the screen: it is not in + // DEVICE_KEYS, so it rides in the settings file like the rest. + expect(acceptRemote({ uiLanguage: "en" })).toEqual({ uiLanguage: "en" }); + }); +}); diff --git a/web/src/lib/languages.ts b/web/src/lib/languages.ts new file mode 100644 index 0000000..3260cd6 --- /dev/null +++ b/web/src/lib/languages.ts @@ -0,0 +1,44 @@ +/** + * The interface languages that actually have strings shipped. + * + * Deliberately not `lib/locales.ts`. That list is every tag CLDR can format a + * date in — about 620 of them — and it answers a different question: what + * calendar, clock and numerals to use. This one answers "what language is the + * app written in", and the only honest entries are the ones somebody has + * translated. Offering a language with no strings behind it would set + * `` to a language the page is not in, which is worse than not + * offering it: it stops Chrome offering to translate a page the reader cannot + * read. + * + * Wanting German dates with an English interface is a real preference, and so + * is the reverse, which is why `uiLanguage` and `locale` are separate settings + * rather than one. + * + * Adding a language means adding its catalogue and then adding it here, in + * that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout + * work well beyond strings, so they are not simply a matter of another entry. + */ +export interface UiLanguage { + /** BCP 47, and what `` is set to. */ + tag: string; + /** The language's name in that language, which is how a picker should read. */ + name: string; +} + +export const UI_LANGUAGES: readonly UiLanguage[] = [ + { tag: "en", name: "English" }, +]; + +export const DEFAULT_UI_LANGUAGE = "en"; + +/** + * The language to actually render in. + * + * A stored preference is only honoured if its strings are still shipped: a + * catalogue can be withdrawn, and an account carrying `de` from another + * machine must not leave this one claiming to be German while showing English. + */ +export function resolveUiLanguage(stored: string | undefined | null): string { + if (!stored) return DEFAULT_UI_LANGUAGE; + return UI_LANGUAGES.some((l) => l.tag === stored) ? stored : DEFAULT_UI_LANGUAGE; +} diff --git a/web/src/store/__tests__/lang-attribute.test.ts b/web/src/store/__tests__/lang-attribute.test.ts new file mode 100644 index 0000000..272c118 --- /dev/null +++ b/web/src/store/__tests__/lang-attribute.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { applyLang, DEFAULT_SETTINGS } from "@/store/settings"; + +/** + * `` has to be right *before first paint*, not after mount. + * + * Chrome decides whether to offer a translation from the language it detects + * against the language the page declares. A `lang` patched in from a + * `useEffect` leaves a window where the two disagree, and that window is + * enough to raise the prompt on a page that was already in the reader's + * language — after which accepting it rewrites the DOM under React. + * + * Two halves, and they are different claims: the served HTML already says so, + * and the stored preference is applied without waiting for a render. + */ +describe("the served HTML", () => { + it("declares a language in the markup itself, not from script", () => { + // jsdom rewrites import.meta.url to an http URL, so resolve from the + // Vite root instead of relative to this file. + const html = readFileSync(resolve(process.cwd(), "index.html"), "utf8"); + expect(html).toMatch(/]*\slang="en"/); + }); +}); + +describe("applyLang", () => { + beforeEach(() => { + document.documentElement.removeAttribute("lang"); + }); + + it("puts the resolved language on the document", () => { + applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "en" }); + expect(document.documentElement.lang).toBe("en"); + }); + + it("falls back to English rather than claiming a language it cannot render", () => { + applyLang({ ...DEFAULT_SETTINGS, uiLanguage: "de" }); + expect(document.documentElement.lang).toBe("en"); + }); + + it("is applied from the module the app imports before it renders", async () => { + /* + * main.tsx imports App, which reaches this store, before it calls + * createRoot().render(). Re-importing runs the module's own side effects + * against a document that has just had the attribute stripped, which is + * the closest a test runner gets to "was it set before the first paint". + */ + document.documentElement.removeAttribute("lang"); + vi.resetModules(); + await import("@/store/settings"); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 47a46ea..cdcc691 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -4,6 +4,7 @@ import { loadJson, saveJson } from "@/lib/storage"; import { queueSettingsPush } from "@/lib/settingsSync"; import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime"; import type { SwipeAction } from "@/lib/swipe"; +import { resolveUiLanguage } from "@/lib/languages"; /** * "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a @@ -88,6 +89,22 @@ export interface Settings { weekStart: 0 | 1 | 6; /** "" = follow the mail server's locale, then the browser's. */ locale: string; + /** + * The language the interface is written in, and what `` says. + * + * Separate from `locale` above, which is a *formatting* choice — what + * calendar, clock and numerals to use. They are genuinely different + * questions: German dates with an English interface is a real preference, + * and so is the reverse. Folding them together would silently rewrite + * everybody's date format the first time they picked a language. + * + * Absent means English, for a new account and for every existing one whose + * settings file predates this. The browser's `Accept-Language` is + * deliberately not consulted as the stored default: a served locale should + * be something the reader chose, not something guessed on their behalf and + * then written down as though they had. + */ + uiLanguage: string; dateFormat: DateFormat; timeFormat: TimeFormat; calendarDefaultView: "month" | "week" | "day" | "agenda"; @@ -182,6 +199,7 @@ export const DEFAULT_SETTINGS: Settings = { attachmentReminder: true, weekStart: 1, locale: "", + uiLanguage: "en", dateFormat: "auto", timeFormat: "auto", calendarDefaultView: "week", @@ -286,6 +304,7 @@ export const useSettings = create((set, get) => ({ set({ settings }); applyTheme(settings); applyDateTimePrefs(settings); + applyLang(settings); // Dragging a splitter changes a device key on every frame and must not put // a request in the air; anything else is queued and coalesced. if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) { @@ -297,6 +316,7 @@ export const useSettings = create((set, get) => ({ set({ settings: DEFAULT_SETTINGS }); applyTheme(DEFAULT_SETTINGS); applyDateTimePrefs(DEFAULT_SETTINGS); + applyLang(DEFAULT_SETTINGS); queueSettingsPush(syncedPart(DEFAULT_SETTINGS)); }, exportJson() { @@ -318,6 +338,7 @@ export const useSettings = create((set, get) => ({ set({ settings }); applyTheme(settings); applyDateTimePrefs(settings); + applyLang(settings); }, })); @@ -325,6 +346,31 @@ function applyDateTimePrefs(s: Settings): void { setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat }); } +/** + * Put the served language on ``. + * + * Chrome offers to translate when the language it detects does not match the + * one the page declares, so a `lang` that is briefly wrong is enough to raise + * the prompt on a page that was already correct — and accepting that prompt is + * what rewrites the DOM under React and crashes the component tree + * (facebook/react#11538). + * + * So this is not done in an effect after mount. It runs where `applyTheme` + * runs: at module load, from the localStorage cache, before `createRoot()` has + * rendered anything and therefore before first paint. `index.html` ships + * `lang="en"` statically, so the very first bytes are already right for the + * default and this only ever corrects a reader who chose otherwise. + * + * There is no server-rendered alternative to reach for. ihasmail serves a + * static shell and keeps no account state; the settings file lives in the + * reader's own JMAP Files on the mail server, so the only way to read it + * before the page existed would be to authenticate to Stalwart on every page + * load, which is the thing the whole design avoids. + */ +export function applyLang(s: Settings = useSettings.getState().settings): void { + document.documentElement.lang = resolveUiLanguage(s.uiLanguage); +} + /** Background of each theme, for the browser chrome (`theme-color`). */ const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const; @@ -360,6 +406,10 @@ export function isDarkTheme(theme: Theme, prefersDark = false): boolean { if (typeof window !== "undefined") { applyTheme(); + // Before `createRoot().render()` in main.tsx, which imports this module on + // the way in -- so the language is declared before React has produced a + // single node, let alone painted one. + applyLang(); window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme()); } diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 106394e..b81c8b3 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -912,6 +912,10 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); } .login-card .pw-wrap { position: relative; } .login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; } +/* The translate boundary is a wrapper with no appearance of its own; it must + not interrupt the flex/grid chain the panes inside it rely on. */ +.translate-boundary { display: contents; } + /* ========================================================================== Touch gestures (see lib/touch.ts) ========================================================================== */ diff --git a/web/src/ui/TranslateBoundary.tsx b/web/src/ui/TranslateBoundary.tsx new file mode 100644 index 0000000..2994174 --- /dev/null +++ b/web/src/ui/TranslateBoundary.tsx @@ -0,0 +1,100 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +/** + * A boundary that survives Chrome translating the page. + * + * Chrome's translator rewrites the rendered DOM directly, wrapping text nodes + * in `` elements React has never heard of. React holds references to the + * nodes it created, so the next update calls `removeChild` or `insertBefore` + * against a parent whose children have moved, the DOM throws, and the whole + * component tree unmounts. It is a longstanding React/Chromium problem + * (facebook/react#11538), not a fault in anything here, and it cannot be + * fixed from inside React. + * + * `translate="no"` and the structural wrapping elsewhere in this change make + * it rarer. Neither makes it impossible: those are hints to the automatic + * prompt, and a reader can always force a translation from the extension + * regardless of what the page asked for. So the last line is to catch it and + * put the subtree back. + * + * Recovery is a remount rather than a crash screen, because there is nothing + * to lose: this wraps the main content area only, so the header, the sidebar + * and any open composer are outside it and keep their state. What is inside + * re-derives from the stores, which is where it came from a moment ago. + * + * Deliberately narrow. A boundary is a class component because React offers no + * hook for this, and that is the whole of the cost -- no dependency, no + * context, no stored state. + */ + +/** The DOM errors Chrome's rewriting produces, as opposed to real bugs. */ +export function isDomMutationError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + // NotFoundError is what removeChild/insertBefore throw when the node they + // were given is not where React last saw it. The name is checked first + // because it is the reliable half -- the message is browser-specific and + // localised, so matching on it alone would work in English Chrome and + // nowhere else, which for a translation bug would be a poor joke. + if (err.name === "NotFoundError" || err.name === "HierarchyRequestError") return true; + return /removeChild|insertBefore|replaceChild|not a child of this node/i.test(err.message); +} + +interface Props { + children: ReactNode; + /** Told about each recovery, for whoever is counting. */ + onRecover?: (info: { attempt: number; error: Error }) => void; +} + +interface State { + /** Bumping this remounts the subtree, which is the whole recovery. */ + generation: number; + failed: Error | null; +} + +/** + * How many times a subtree is put back before it is left broken. + * + * Not unlimited: if something genuinely wrong is throwing a DOM error on every + * render, remounting for ever is an invisible infinite loop that pins a core. + * Three is enough for a reader toggling a translation on and off, and far too + * few to hide a real bug. + */ +const MAX_RECOVERIES = 3; + +export class TranslateBoundary extends Component { + state: State = { generation: 0, failed: null }; + private recoveries = 0; + + static getDerivedStateFromError(error: Error): Partial | null { + // Anything that is not the translator's doing is left to propagate, so a + // real bug still surfaces as a real bug rather than as a subtree that + // silently reappears empty. + if (!isDomMutationError(error)) throw error; + return { failed: error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + if (!isDomMutationError(error)) throw error; + this.recoveries += 1; + if (this.recoveries > MAX_RECOVERIES) { + console.error("[ihasmail] giving up re-rendering after repeated DOM errors", error, info.componentStack); + return; + } + /* + * console.info, not console.error. A reader translating the page is not a + * fault, and logging it as one would put an entry in every error reporter + * that reads the console, for behaviour that is expected and recovered + * from. The marker is here to be counted, not alarmed at. + */ + console.info( + `[ihasmail] recovered from a DOM error, most likely page translation (recovery ${this.recoveries} of ${MAX_RECOVERIES}): ${error.message}`, + ); + this.props.onRecover?.({ attempt: this.recoveries, error }); + this.setState((s) => ({ generation: s.generation + 1, failed: null })); + } + + render(): ReactNode { + if (this.state.failed) return null; // one frame, while the remount lands + return
{this.props.children}
; + } +} diff --git a/web/src/ui/__tests__/translate-boundary.test.tsx b/web/src/ui/__tests__/translate-boundary.test.tsx new file mode 100644 index 0000000..e12a0bf --- /dev/null +++ b/web/src/ui/__tests__/translate-boundary.test.tsx @@ -0,0 +1,118 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TranslateBoundary, isDomMutationError } from "../TranslateBoundary"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +/** + * Chrome's translator wraps text nodes in behind React's back, so the + * next update calls removeChild against a parent whose children have moved and + * the DOM throws. React unmounts the whole tree over it + * (facebook/react#11538). The boundary's job is to put the subtree back + * instead, and to leave everything that is not that alone. + */ +describe("recognising the translator's damage", () => { + it("knows the DOM errors Chrome's rewriting produces", () => { + const notFound = new Error("Failed to execute 'removeChild' on 'Node'"); + notFound.name = "NotFoundError"; + expect(isDomMutationError(notFound)).toBe(true); + expect(isDomMutationError(new Error("The node before which the new node is to be inserted is not a child of this node"))).toBe(true); + }); + + it("matches on the error name as well as the message", () => { + // The message is browser-specific and localised. Matching only on English + // text would be a translation bug that only works in English. + const localised = new Error("Знайдений вузол не є дочірнім"); + localised.name = "NotFoundError"; + expect(isDomMutationError(localised)).toBe(true); + }); + + it("does not claim an ordinary bug", () => { + expect(isDomMutationError(new TypeError("x is not a function"))).toBe(false); + expect(isDomMutationError("a string")).toBe(false); + }); +}); + +describe("the boundary", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + + /* + * Throws on its first renders, then succeeds — the shape of a pane whose + * DOM was rewritten and then remounted clean. + * + * It has to keep throwing past the first attempt. React answers an error in + * a concurrent render by retrying the whole root synchronously, and a + * component that throws exactly once succeeds on that retry and never + * reaches the boundary at all — which looks like the boundary not working + * and is really the test not reproducing anything. + */ + function Flaky({ fails }: { fails: { left: number } }) { + if (fails.left > 0) { + fails.left -= 1; + const err = new Error("Failed to execute 'removeChild' on 'Node'"); + err.name = "NotFoundError"; + throw err; + } + return

content

; + } + + it("remounts the subtree instead of losing it", () => { + const fails = { left: 2 }; // the concurrent attempt and the sync retry + const onRecover = vi.fn(); + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + act(() => { + root.render(); + }); + expect(host.textContent).toBe("content"); + expect(onRecover).toHaveBeenCalledTimes(1); + }); + + it("logs the recovery as information, not as an error", () => { + // A reader translating the page is expected and recovered from. Logging it + // as an error would file a bug report in every console-reading reporter, + // every time, for behaviour that worked. + const fails = { left: 2 }; + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + act(() => { + root.render(); + }); + expect(info).toHaveBeenCalledOnce(); + expect(String(info.mock.calls[0]?.[0])).toContain("recovered from a DOM error"); + /* + * React logs every error a boundary catches to console.error itself, in + * development, and that is not ours to suppress. What matters is that + * ihasmail does not add one of its own on top: the recovery is reported + * as information, so a console-reading error reporter sees React's dev + * noise and nothing from us claiming a failure. + */ + const ours = error.mock.calls.filter((c) => String(c[0]).includes("[ihasmail]")); + expect(ours).toEqual([]); + }); + + it("lets a real bug through rather than swallowing it", () => { + function Broken(): never { + throw new TypeError("genuinely broken"); + } + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => { + act(() => { + root.render(); + }); + }).toThrow(/genuinely broken/); + }); +}); diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 7dfad02..07a0b16 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -14,6 +14,7 @@ import { ContactsSidebar } from "./contacts/ContactsSidebar"; import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { formatSize } from "@/lib/format"; +import { TranslateBoundary } from "@/ui/TranslateBoundary"; const PUSH_LABEL = { connected: "Live updates connected", @@ -71,7 +72,9 @@ export function AppShell({ children }: { children: ReactNode }) { - + {/* A product name, not a word. "ihasmail" translated is a different + product, and the one on the tab beside it is still called this. */} + ihasmail @@ -97,7 +100,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{session?.username}
-
{session?.ihasmail?.loginName}
+
{session?.ihasmail?.loginName}
@@ -145,7 +148,13 @@ export function AppShell({ children }: { children: ReactNode }) { } label="Files" active={section === "files"} /> -
{children}
+ {/* + Scoped to the content, not the shell. If Chrome's translator breaks a + message list, the top bar, the folder tree and any open composer are + outside this and carry on -- so recovery is a pane blinking rather + than the app disappearing. + */} +
{children}
{isMobile && ( diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx index 52c3aae..f6e66a5 100644 --- a/web/src/views/Login.tsx +++ b/web/src/views/Login.tsx @@ -53,7 +53,7 @@ export function LoginPage() {
-

ihasmail

+

ihasmail

Fast, friendly webmail. Your mailbox, your way.

{error && ( @@ -98,7 +98,7 @@ export function LoginPage() { One

with a break rather than two: .foot carries a 20px margin-top, which a second paragraph would repeat as a gap. */} - ihasmail v{APP_VERSION} + ihasmail v{APP_VERSION}
ihasmail.org {" · "} diff --git a/web/src/views/calendar/EventPopover.tsx b/web/src/views/calendar/EventPopover.tsx index 737be0d..777a800 100644 --- a/web/src/views/calendar/EventPopover.tsx +++ b/web/src/views/calendar/EventPopover.tsx @@ -79,10 +79,10 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns {ev.description &&

{ev.description}
} {alerts.length > 0 &&
{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}
} {category &&
{category.name}
} -
{inst.calendar?.name ?? "Calendar"}{ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}{ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}{ev.freeBusyStatus === "free" ? " · shown as free" : ""}
+
{`${inst.calendar?.name ?? "Calendar"}${ev.status === "cancelled" ? " · cancelled" : ev.status === "tentative" ? " · tentative" : ""}${ev.privacy && ev.privacy !== "public" ? ` · ${ev.privacy}` : ""}${ev.freeBusyStatus === "free" ? " · shown as free" : ""}`}
{participants.length > 0 && (
-
{participants.length} participant{participants.length === 1 ? "" : "s"}
+
{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}
{participants.map(([k, p]) => (
diff --git a/web/src/views/compose/RecipientPicker.tsx b/web/src/views/compose/RecipientPicker.tsx index 2792b62..8ad9523 100644 --- a/web/src/views/compose/RecipientPicker.tsx +++ b/web/src/views/compose/RecipientPicker.tsx @@ -167,7 +167,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad toggle(r)} /> {r.book.includes("·") ? : } - {r.name ?? r.email} + {r.name ?? r.email} {r.name && · {r.email}} {r.book} diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index beb7e15..224bfb9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -126,7 +126,7 @@ export function ContactsView({ id }: { id?: string }) {
navigate(`/contacts/${c.id}`)}> {photo ? : c.kind === "group" ? : contactDisplayName(c).slice(0, 1).toUpperCase()}
-
{contactDisplayName(c)}{c.kind === "group" ? · group : ""}
+
{contactDisplayName(c)}{c.kind === "group" ? · group : null}
{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}
@@ -201,7 +201,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con )} {(org || Object.values(c.titles ?? {}).length > 1) && (

Work

- {org?.name &&
Company{org.name}{org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}
} + {org?.name &&
Company{`${org.name}${org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}`}
} {Object.values(c.titles ?? {}).map((t, i) =>
{t.kind === "role" ? "Role" : "Title"}{t.name}
)}
)} diff --git a/web/src/views/mail/InviteCard.tsx b/web/src/views/mail/InviteCard.tsx index a242492..6ce5c22 100644 --- a/web/src/views/mail/InviteCard.tsx +++ b/web/src/views/mail/InviteCard.tsx @@ -86,12 +86,12 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
-
{title}{method === "REPLY" && organizer ? "" : ""}
+
{title}

{ev.title || "(untitled event)"}

- {inst &&
{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}
} + {inst &&
{`${formatTimeRange(inst.start, inst.end, inst.allDay)}${ev.timeZone ? ` (${ev.timeZone})` : ""}`}
} {location &&
{location}
} {organizer &&
Organizer: {organizer.name || participantEmail(organizer)}
} - {attendees.length > 0 &&
{attendees.length} attendee{attendees.length === 1 ? "" : "s"}
} + {attendees.length > 0 &&
{`${attendees.length} attendee${attendees.length === 1 ? "" : "s"}`}
} {method === "REPLY" && (
{attendees.map((a) =>
{a.name || participantEmail(a)}: {a.participationStatus ?? "unknown"}
)} diff --git a/web/src/views/mail/MessageList.tsx b/web/src/views/mail/MessageList.tsx index 9331f5b..5f7b865 100644 --- a/web/src/views/mail/MessageList.tsx +++ b/web/src/views/mail/MessageList.tsx @@ -647,7 +647,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
- {who} + {who} {count > 1 && {count}} diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index ea326f1..34eea3e 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -287,7 +287,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {addrMenu.node} {filterOpen && setFilterOpen(false)} />} setShowSource(false)} title="Original message" size="xl"> - {source === null ?
:
{source}
} + {source === null ?
:
{source}
}
setShowHeaders(false)} title="Message headers" size="lg">
@@ -444,7 +444,9 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod return ( <> -
+ {/* The sender's content, rendered as-is. Translating it would + rewrite what someone actually wrote. */} +
{hasQuote && (
+

Language

+
+ + +
+ {/* + Said plainly rather than left to be discovered. A picker with one entry + looks broken; a picker with one entry and a sentence explaining that + more are coming is a roadmap. + */} +

+ Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not. +

+

+ This is separate from Language & region in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round. +

+

Swiping

On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead. diff --git a/web/src/views/settings/FiltersSettings.tsx b/web/src/views/settings/FiltersSettings.tsx index 828173f..29ff1a3 100644 --- a/web/src/views/settings/FiltersSettings.tsx +++ b/web/src/views/settings/FiltersSettings.tsx @@ -157,7 +157,7 @@ function RulesEditor() { {content && (

Preview generated Sieve script -
{rulesToSieve(list)}
+
{rulesToSieve(list)}
)} {editing && ( @@ -230,7 +230,7 @@ function ScriptsEditor() {
setName(e.target.value)} disabled={Boolean(sel)} />
-