Merge pull request #145 from Coffey-Labs/translate-resilience

Defend against Chrome rewriting the DOM, and add the language setting
This commit is contained in:
Coffey Labs
2026-08-31 09:29:30 -07:00
committed by GitHub
21 changed files with 477 additions and 27 deletions
+46
View File
@@ -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 `<html lang>` 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" });
});
});
+44
View File
@@ -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
* `<html lang>` 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 `<html lang>` 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;
}
@@ -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";
/**
* `<html lang>` 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(/<html[^>]*\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");
});
});
+50
View File
@@ -4,6 +4,7 @@ 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, type DateFormat, type TimeFormat } from "@/lib/datetime";
import type { SwipeAction } from "@/lib/swipe"; 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 * "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; weekStart: 0 | 1 | 6;
/** "" = follow the mail server's locale, then the browser's. */ /** "" = follow the mail server's locale, then the browser's. */
locale: string; locale: string;
/**
* The language the interface is written in, and what `<html lang>` 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; dateFormat: DateFormat;
timeFormat: TimeFormat; timeFormat: TimeFormat;
calendarDefaultView: "month" | "week" | "day" | "agenda"; calendarDefaultView: "month" | "week" | "day" | "agenda";
@@ -182,6 +199,7 @@ export const DEFAULT_SETTINGS: Settings = {
attachmentReminder: true, attachmentReminder: true,
weekStart: 1, weekStart: 1,
locale: "", locale: "",
uiLanguage: "en",
dateFormat: "auto", dateFormat: "auto",
timeFormat: "auto", timeFormat: "auto",
calendarDefaultView: "week", calendarDefaultView: "week",
@@ -286,6 +304,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
set({ settings }); set({ settings });
applyTheme(settings); applyTheme(settings);
applyDateTimePrefs(settings); applyDateTimePrefs(settings);
applyLang(settings);
// Dragging a splitter changes a device key on every frame and must not put // 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. // a request in the air; anything else is queued and coalesced.
if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) { if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
@@ -297,6 +316,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
set({ settings: DEFAULT_SETTINGS }); set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS); applyTheme(DEFAULT_SETTINGS);
applyDateTimePrefs(DEFAULT_SETTINGS); applyDateTimePrefs(DEFAULT_SETTINGS);
applyLang(DEFAULT_SETTINGS);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS)); queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
}, },
exportJson() { exportJson() {
@@ -318,6 +338,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
set({ settings }); set({ settings });
applyTheme(settings); applyTheme(settings);
applyDateTimePrefs(settings); applyDateTimePrefs(settings);
applyLang(settings);
}, },
})); }));
@@ -325,6 +346,31 @@ function applyDateTimePrefs(s: Settings): void {
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat }); setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
} }
/**
* Put the served language on `<html lang>`.
*
* 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`). */ /** Background of each theme, for the browser chrome (`theme-color`). */
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const; 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") { if (typeof window !== "undefined") {
applyTheme(); 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()); window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
} }
+4
View File
@@ -912,6 +912,10 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.login-card .pw-wrap { position: relative; } .login-card .pw-wrap { position: relative; }
.login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; } .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) Touch gestures (see lib/touch.ts)
========================================================================== */ ========================================================================== */
+100
View File
@@ -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 `<font>` 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<Props, State> {
state: State = { generation: 0, failed: null };
private recoveries = 0;
static getDerivedStateFromError(error: Error): Partial<State> | 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 <div key={this.state.generation} className="translate-boundary">{this.props.children}</div>;
}
}
@@ -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 <font> 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 <p>content</p>;
}
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(<TranslateBoundary onRecover={onRecover}><Flaky fails={fails} /></TranslateBoundary>);
});
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(<TranslateBoundary><Flaky fails={fails} /></TranslateBoundary>);
});
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(<TranslateBoundary><Broken /></TranslateBoundary>);
});
}).toThrow(/genuinely broken/);
});
});
+12 -3
View File
@@ -14,6 +14,7 @@ import { ContactsSidebar } from "./contacts/ContactsSidebar";
import { CalendarSidebar } from "./calendar/CalendarSidebar"; import { CalendarSidebar } from "./calendar/CalendarSidebar";
import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { TranslateBoundary } from "@/ui/TranslateBoundary";
const PUSH_LABEL = { const PUSH_LABEL = {
connected: "Live updates connected", connected: "Live updates connected",
@@ -71,7 +72,9 @@ export function AppShell({ children }: { children: ReactNode }) {
</button> </button>
<Link href="/mail" className="brand"> <Link href="/mail" className="brand">
<img src="/img/logo.png" alt="" /> <img src="/img/logo.png" alt="" />
<span className="brand-name"> {/* A product name, not a word. "ihasmail" translated is a different
product, and the one on the tab beside it is still called this. */}
<span className="brand-name notranslate" translate="no">
ihasmail ihasmail
</span> </span>
</Link> </Link>
@@ -97,7 +100,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<div style={{ fontWeight: 600 }} className="truncate"> <div style={{ fontWeight: 600 }} className="truncate">
{session?.username} {session?.username}
</div> </div>
<div className="hint truncate">{session?.ihasmail?.loginName}</div> <div className="hint truncate notranslate" translate="no">{session?.ihasmail?.loginName}</div>
</div> </div>
</div> </div>
<MenuSep /> <MenuSep />
@@ -145,7 +148,13 @@ export function AppShell({ children }: { children: ReactNode }) {
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} /> <ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
</nav> </nav>
</aside> </aside>
<main className="main">{children}</main> {/*
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.
*/}
<main className="main"><TranslateBoundary>{children}</TranslateBoundary></main>
</div> </div>
{isMobile && ( {isMobile && (
+2 -2
View File
@@ -53,7 +53,7 @@ export function LoginPage() {
<form className="login-card" onSubmit={submit}> <form className="login-card" onSubmit={submit}>
<div className="logo"> <div className="logo">
<img src="/img/logo.png" alt="" width={120} height={143} /> <img src="/img/logo.png" alt="" width={120} height={143} />
<h1>ihasmail</h1> <h1 className="notranslate" translate="no">ihasmail</h1>
<p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p> <p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p>
</div> </div>
{error && ( {error && (
@@ -98,7 +98,7 @@ export function LoginPage() {
One <p> with a break rather than two: .foot carries a 20px One <p> with a break rather than two: .foot carries a 20px
margin-top, which a second paragraph would repeat as a gap. margin-top, which a second paragraph would repeat as a gap.
*/} */}
ihasmail v{APP_VERSION} <span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
<br /> <br />
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a> <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>
{" · "} {" · "}
+2 -2
View File
@@ -79,10 +79,10 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
{ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>} {ev.description && <div className="ev-line"><AlignLeft size={15} /><span style={{ whiteSpace: "pre-wrap", maxHeight: 160, overflow: "auto" }}>{ev.description}</span></div>}
{alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>} {alerts.length > 0 && <div className="ev-line"><Bell size={15} /><span>{alerts.map((a) => ("offset" in a.trigger ? humanDuration(parseDuration(a.trigger.offset)) + (parseDuration(a.trigger.offset) < 0 ? " before" : " after") : "at " + a.trigger.when)).join(", ")}</span></div>}
{category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>} {category && <div className="ev-line"><span className="label-dot" style={{ background: category.color, width: 12, height: 12, marginTop: 3 }} /><span>{category.name}</span></div>}
<div className="ev-line"><CalIcon size={15} /><span>{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" : ""}</span></div> <div className="ev-line"><CalIcon size={15} /><span>{`${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" : ""}`}</span></div>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="ev-line" style={{ flexDirection: "column", gap: 2 }}> <div className="ev-line" style={{ flexDirection: "column", gap: 2 }}>
<div className="row gap-8"><Users size={15} /><span>{participants.length} participant{participants.length === 1 ? "" : "s"}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div> <div className="row gap-8"><Users size={15} /><span>{`${participants.length} participant${participants.length === 1 ? "" : "s"}`}</span><button className="icon-btn xs" title="Email everyone" onClick={() => openCompose({ to: participants.map(([, p]) => ({ name: p.name ?? null, email: participantEmail(p) })).filter((a) => a.email), subject: ev.title ?? "" })}><Mail size={13} /></button></div>
<div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}> <div style={{ paddingLeft: 24, maxHeight: 140, overflow: "auto", width: "100%" }}>
{participants.map(([k, p]) => ( {participants.map(([k, p]) => (
<div key={k} className="participant-row"> <div key={k} className="participant-row">
+1 -1
View File
@@ -167,7 +167,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
<input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} /> <input type="checkbox" checked={Boolean(picked[r.key])} onChange={() => toggle(r)} />
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />} {r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
<span className="grow truncate"> <span className="grow truncate">
{r.name ?? r.email} <span>{r.name ?? r.email}</span>
{r.name && <span className="hint"> · {r.email}</span>} {r.name && <span className="hint"> · {r.email}</span>}
</span> </span>
<span className="hint nowrap">{r.book}</span> <span className="hint nowrap">{r.book}</span>
+2 -2
View File
@@ -126,7 +126,7 @@ export function ContactsView({ id }: { id?: string }) {
<div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}> <div key={c.id} className={`contact-row ${id === c.id ? "active" : ""}`} onClick={() => navigate(`/contacts/${c.id}`)}>
<span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span> <span className="avatar" style={{ background: photo ? "transparent" : avatarColor(email ?? contactDisplayName(c)) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={16} /> : contactDisplayName(c).slice(0, 1).toUpperCase()}</span>
<div className="grow" style={{ minWidth: 0 }}> <div className="grow" style={{ minWidth: 0 }}>
<div className="c-name">{contactDisplayName(c)}{c.kind === "group" ? <span className="hint"> · group</span> : ""}</div> <div className="c-name"><span>{contactDisplayName(c)}</span>{c.kind === "group" ? <span className="hint"> · group</span> : null}</div>
<div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div> <div className="c-email">{email ?? Object.values(c.phones ?? {})[0]?.number ?? Object.values(c.organizations ?? {})[0]?.name ?? ""}</div>
</div> </div>
</div> </div>
@@ -201,7 +201,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
)} )}
{(org || Object.values(c.titles ?? {}).length > 1) && ( {(org || Object.values(c.titles ?? {}).length > 1) && (
<div className="contact-section"><h3>Work</h3> <div className="contact-section"><h3>Work</h3>
{org?.name && <div className="contact-kv"><span className="k">Company</span><span className="v row gap-8"><Building2 size={14} className="muted" />{org.name}{org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}</span></div>} {org?.name && <div className="contact-kv"><span className="k">Company</span><span className="v row gap-8"><Building2 size={14} className="muted" />{`${org.name}${org.units?.length ? ` · ${org.units.map((u) => u.name).join(", ")}` : ""}`}</span></div>}
{Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)} {Object.values(c.titles ?? {}).map((t, i) => <div key={i} className="contact-kv"><span className="k">{t.kind === "role" ? "Role" : "Title"}</span><span className="v">{t.name}</span></div>)}
</div> </div>
)} )}
+3 -3
View File
@@ -86,12 +86,12 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
<div className="row" style={{ alignItems: "flex-start" }}> <div className="row" style={{ alignItems: "flex-start" }}>
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} /> <Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
<div className="grow"> <div className="grow">
<div className="hint" style={{ marginBottom: 2 }}>{title}{method === "REPLY" && organizer ? "" : ""}</div> <div className="hint" style={{ marginBottom: 2 }}>{title}</div>
<h4>{ev.title || "(untitled event)"}</h4> <h4>{ev.title || "(untitled event)"}</h4>
{inst && <div className="small">{formatTimeRange(inst.start, inst.end, inst.allDay)}{ev.timeZone ? ` (${ev.timeZone})` : ""}</div>} {inst && <div className="small">{`${formatTimeRange(inst.start, inst.end, inst.allDay)}${ev.timeZone ? ` (${ev.timeZone})` : ""}`}</div>}
{location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>} {location && <div className="small muted row gap-4"><MapPin size={13} /> {location}</div>}
{organizer && <div className="small muted">Organizer: {organizer.name || participantEmail(organizer)}</div>} {organizer && <div className="small muted">Organizer: {organizer.name || participantEmail(organizer)}</div>}
{attendees.length > 0 && <div className="small muted">{attendees.length} attendee{attendees.length === 1 ? "" : "s"}</div>} {attendees.length > 0 && <div className="small muted">{`${attendees.length} attendee${attendees.length === 1 ? "" : "s"}`}</div>}
{method === "REPLY" && ( {method === "REPLY" && (
<div className="small" style={{ marginTop: 4 }}> <div className="small" style={{ marginTop: 4 }}>
{attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)} {attendees.map((a) => <div key={participantEmail(a) || a.name}>{a.name || participantEmail(a)}: <b>{a.participationStatus ?? "unknown"}</b></div>)}
+1 -1
View File
@@ -647,7 +647,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
<div className="msg-body"> <div className="msg-body">
<div className="msg-line1"> <div className="msg-line1">
<span className="msg-from truncate"> <span className="msg-from truncate">
{who} <span className="truncate">{who}</span>
{count > 1 && <span className="thread-count"> {count}</span>} {count > 1 && <span className="thread-count"> {count}</span>}
</span> </span>
<span className="msg-meta"> <span className="msg-meta">
+8 -4
View File
@@ -287,7 +287,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{addrMenu.node} {addrMenu.node}
{filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />} {filterOpen && <FilterFromMessageDialog email={e} mailboxId={Object.keys(e.mailboxIds)[0] ?? null} onClose={() => setFilterOpen(false)} />}
<Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl"> <Dialog open={showSource} onClose={() => setShowSource(false)} title="Original message" size="xl">
{source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>} {source === null ? <div className="center"><span className="spinner" /></div> : <pre className="code notranslate" translate="no" style={{ minHeight: 300, maxHeight: "65vh" }}>{source}</pre>}
</Dialog> </Dialog>
<Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg"> <Dialog open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
<dl className="message-details" style={{ margin: 0 }}> <dl className="message-details" style={{ margin: 0 }}>
@@ -444,7 +444,9 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
return ( return (
<> <>
<div ref={hostRef} className="body-host" /> {/* The sender's content, rendered as-is. Translating it would
rewrite what someone actually wrote. */}
<div ref={hostRef} className="body-host notranslate" translate="no" />
{hasQuote && ( {hasQuote && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}> <button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>} {quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
@@ -484,7 +486,9 @@ function TextBody({ text }: { text: string }) {
return ( return (
<> <>
<div ref={hostRef} className="body-host" /> {/* The sender's content, rendered as-is. Translating it would
rewrite what someone actually wrote. */}
<div ref={hostRef} className="body-host notranslate" translate="no" />
{quoted && ( {quoted && (
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}> <button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>} {quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}></span>}
@@ -556,5 +560,5 @@ function TextAttachment({ url }: { url: string }) {
useEffect(() => { useEffect(() => {
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load.")); fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
}, [url]); }, [url]);
return <pre className="code" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>; return <pre className="code notranslate" translate="no" style={{ maxHeight: "65vh" }}>{text ?? "Loading…"}</pre>;
} }
@@ -1,6 +1,7 @@
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
import { Switch, useIsTouch } from "@/ui/misc"; import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe"; import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { UI_LANGUAGES } from "@/lib/languages";
/** /**
* The theme cards, each previewing the background it actually paints. Kept as * The theme cards, each previewing the background it actually paints. Kept as
@@ -77,6 +78,27 @@ export function AppearanceSettings() {
</select> </select>
</div> </div>
</div> </div>
<h2>Language</h2>
<div className="field" style={{ maxWidth: 320 }}>
<label htmlFor="ui-language">Interface language</label>
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
{UI_LANGUAGES.map((l) => (
<option key={l.tag} value={l.tag}>{l.name}</option>
))}
</select>
</div>
{/*
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.
*/}
<p className="hint">
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.
</p>
<p className="hint">
This is separate from <strong>Language &amp; region</strong> 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.
</p>
<h2>Swiping</h2> <h2>Swiping</h2>
<p className="hint"> <p className="hint">
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. 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.
+3 -3
View File
@@ -157,7 +157,7 @@ function RulesEditor() {
{content && ( {content && (
<details style={{ marginTop: 20 }}> <details style={{ marginTop: 20 }}>
<summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary> <summary className="hint" style={{ cursor: "pointer" }}>Preview generated Sieve script</summary>
<pre className="code" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre> <pre className="code notranslate" translate="no" style={{ minHeight: 120, marginTop: 8 }}>{rulesToSieve(list)}</pre>
</details> </details>
)} )}
{editing && ( {editing && (
@@ -230,7 +230,7 @@ function ScriptsEditor() {
<div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div> <div className="field"><label>Script name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} disabled={Boolean(sel)} /></div>
<div className="field"> <div className="field">
<label>Sieve source</label> <label>Sieve source</label>
<textarea className="code" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} /> <textarea className="code notranslate" translate="no" value={content} onChange={(e) => setContent(e.target.value)} spellCheck={false} style={{ minHeight: 320 }} />
</div> </div>
{validation && <div className="error-box mb-16">{validation}</div>} {validation && <div className="error-box mb-16">{validation}</div>}
<div className="row"> <div className="row">
@@ -251,7 +251,7 @@ function ScriptsEditor() {
{sieve.scripts.map((s) => ( {sieve.scripts.map((s) => (
<div key={s.id} className="card"> <div key={s.id} className="card">
<div className="card-head"> <div className="card-head">
<h3>{s.name} {s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3> <h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>active</span>}</h3>
<button className="btn btn-sm" onClick={() => void open(s)}>Edit</button> <button className="btn btn-sm" onClick={() => void open(s)}>Edit</button>
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button> <button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
<button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button> <button className="icon-btn sm danger" aria-label="Delete script" onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
@@ -65,8 +65,7 @@ export function IdentitiesSettings() {
<p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p> <p className="hint mt-8">New identities must use an address this account is allowed to send from (aliases configured on the server).</p>
{hidden.length > 0 && ( {hidden.length > 0 && (
<p className="hint"> <p className="hint">
{hidden.length} {hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to {`${hidden.length} ${hidden.length === 1 ? "identity is" : "identities are"} hidden from the compose picker. Hiding every one of them would leave nothing to choose from, so in that case they are all offered again.`}
choose from, so in that case they are all offered again.
</p> </p>
)} )}
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />} {editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
@@ -24,7 +24,7 @@ export function NotificationsSettings() {
return ( return (
<div> <div>
<h1>Notifications</h1> <h1>Notifications</h1>
<p className="lead">Live updates are delivered via JMAP push ({pushConnected ? "connected" : "reconnecting…"}).</p> <p className="lead">{`Live updates are delivered via JMAP push (${pushConnected ? "connected" : "reconnecting…"}).`}</p>
<Switch <Switch
checked={s.desktopNotifications} checked={s.desktopNotifications}
onChange={async (v) => { onChange={async (v) => {
+1 -1
View File
@@ -91,7 +91,7 @@ export function SecuritySettings() {
<td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td> <td><div className="truncate" style={{ maxWidth: 320 }} title={r.userAgent}>{shortUa(r.userAgent)}</div>{r.id === current && <span className="badge" style={{ marginTop: 2 }}>this device</span>}</td>
<td className="mono small">{r.ip}</td> <td className="mono small">{r.ip}</td>
<td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td> <td>{formatFullDate(new Date(r.lastSeenAt).toISOString())}</td>
<td>{formatFullDate(new Date(r.expiresAt).toISOString())}{r.remember ? " (remembered)" : ""}</td> <td>{`${formatFullDate(new Date(r.expiresAt).toISOString())}${r.remember ? " (remembered)" : ""}`}</td>
<td /> <td />
</tr> </tr>
))} ))}
+2 -2
View File
@@ -120,7 +120,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}> <select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">Add a person or group</option> <option value="">Add a person or group</option>
{available.map((p) => ( {available.map((p) => (
<option key={p.id} value={p.id}>{p.name}{p.email ? ` <${p.email}>` : ""}{p.type !== "individual" ? ` (${p.type})` : ""}</option> <option key={p.id} value={p.id}>{`${p.name}${p.email ? ` <${p.email}>` : ""}${p.type !== "individual" ? ` (${p.type})` : ""}`}</option>
))} ))}
</select> </select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button> <button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
@@ -133,7 +133,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
return ( return (
<div key={pid} className="card"> <div key={pid} className="card">
<div className="card-head"> <div className="card-head">
<h3>{p?.name ?? pid}{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3> <h3><span>{p?.name ?? pid}</span>{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button> <button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
</div> </div>
<div className="row wrap" style={{ marginTop: 8 }}> <div className="row wrap" style={{ marginTop: 8 }}>