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:
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
{" · "}
|
{" · "}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 & 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.
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user