Defend against Chrome rewriting the DOM, and add the language setting
Groundwork for un-shelving translations. Chrome's translator rewrites the rendered DOM directly, wrapping text nodes in <font> elements React has never heard of, and the next update can then call removeChild against a parent whose children have moved (facebook/react#11538). This is the structural defence against that, plus the setting the served language will read from. The language setting is `uiLanguage`, and it is deliberately not the `locale` field that already exists. That one is a formatting choice -- what calendar, clock and numerals to use -- and folding the two together would silently rewrite everybody's date format the first time they picked a language. German dates with an English interface is a real preference, and so is the reverse. It defaults to English when absent, which covers both a new account and every settings file written before this, and Accept-Language is not consulted: a served locale should be something the reader chose rather than something guessed and then written down as though they had. Only languages with strings shipped are offered, which today means English alone -- a picker entry without a catalogue behind it would leave the page claiming a language it is not in, which stops a reader translating a page they cannot read. `<html lang>` is set where applyTheme is set: at store module load, from the localStorage cache, before createRoot() has rendered anything. Not in an effect -- a lang that is briefly wrong is enough to raise the translate prompt on a page that needed none. There is no server-rendered alternative to reach for here: ihasmail serves a static shell and holds no account state, and the settings file lives in the reader's own JMAP Files, so reading it before the page existed would mean authenticating to Stalwart on every page load. The static lang="en" in index.html covers the first bytes; the store only ever corrects a reader who chose otherwise. Both halves are tested. translate="no" and class="notranslate" go on the narrow boundaries only: rendered email bodies, raw message source, attachment text, the generated and hand-edited Sieve, the brand and the login name. Not on <body> -- someone whose language ihasmail does not speak yet should still be able to translate the parts that are ours. Email bodies turn out to live in a shadow root, so React never reconciles them and they were never a crash risk; the marker there is about not rewriting what a sender actually wrote. Twenty-four fragile interpolation points were found with the TypeScript parser rather than grep, and fifteen refactored. Pluralisation and "count + label" pairs are collapsed into a single expression so the text is a lone child React updates with textContent, rather than a text node with conditional siblings to insert around. One of them -- InviteCard's {method === "REPLY" && organizer ? "" : ""} -- rendered an empty string either way and is simply gone. The boundary is scoped to the main content, so the header, folder tree and any open composer sit outside it and survive independently. It recovers by remounting the subtree, which costs nothing because everything inside re-derives from the stores, and it logs at info rather than error: a reader translating a page is expected and recovered from, and filing it as an error would put an entry in every console-reading reporter for behaviour that worked. It re-raises anything that is not a DOM mutation error, so a real bug still surfaces as one, and it gives up after three attempts rather than looping invisibly. Worth recording: the crash could not be reproduced on React 19.2.8. Wrapping 207-249 React-managed text nodes in <font>, exactly as the translator does, then driving in-place conditional toggles and navigations, left the app intact with the boundary never firing. The original issue is from React 16 and the reconciler has changed a great deal since. So this lands as defence whose premise is weaker than assumed rather than as a fix for something observed here, and the boundary is insurance rather than a load-bearing part. The notranslate markers and the collapsed interpolations stand on their own merits either way.
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 { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
import type { SwipeAction } from "@/lib/swipe";
|
||||
import { resolveUiLanguage } from "@/lib/languages";
|
||||
|
||||
/**
|
||||
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
|
||||
@@ -88,6 +89,22 @@ export interface Settings {
|
||||
weekStart: 0 | 1 | 6;
|
||||
/** "" = follow the mail server's locale, then the browser's. */
|
||||
locale: string;
|
||||
/**
|
||||
* The language the interface is written in, and what `<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;
|
||||
timeFormat: TimeFormat;
|
||||
calendarDefaultView: "month" | "week" | "day" | "agenda";
|
||||
@@ -182,6 +199,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
attachmentReminder: true,
|
||||
weekStart: 1,
|
||||
locale: "",
|
||||
uiLanguage: "en",
|
||||
dateFormat: "auto",
|
||||
timeFormat: "auto",
|
||||
calendarDefaultView: "week",
|
||||
@@ -286,6 +304,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(settings);
|
||||
applyLang(settings);
|
||||
// Dragging a splitter changes a device key on every frame and must not put
|
||||
// a request in the air; anything else is queued and coalesced.
|
||||
if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
|
||||
@@ -297,6 +316,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
set({ settings: DEFAULT_SETTINGS });
|
||||
applyTheme(DEFAULT_SETTINGS);
|
||||
applyDateTimePrefs(DEFAULT_SETTINGS);
|
||||
applyLang(DEFAULT_SETTINGS);
|
||||
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
|
||||
},
|
||||
exportJson() {
|
||||
@@ -318,6 +338,7 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(settings);
|
||||
applyLang(settings);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -325,6 +346,31 @@ function applyDateTimePrefs(s: Settings): void {
|
||||
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the served language on `<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`). */
|
||||
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
|
||||
|
||||
@@ -360,6 +406,10 @@ export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
applyTheme();
|
||||
// Before `createRoot().render()` in main.tsx, which imports this module on
|
||||
// the way in -- so the language is declared before React has produced a
|
||||
// single node, let alone painted one.
|
||||
applyLang();
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
|
||||
}
|
||||
|
||||
|
||||
@@ -912,6 +912,10 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
||||
.login-card .pw-wrap { position: relative; }
|
||||
.login-card .pw-wrap .icon-btn { position: absolute; right: 2px; top: 1px; }
|
||||
|
||||
/* The translate boundary is a wrapper with no appearance of its own; it must
|
||||
not interrupt the flex/grid chain the panes inside it rely on. */
|
||||
.translate-boundary { display: contents; }
|
||||
|
||||
/* ==========================================================================
|
||||
Touch gestures (see lib/touch.ts)
|
||||
========================================================================== */
|
||||
|
||||
@@ -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 { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { TranslateBoundary } from "@/ui/TranslateBoundary";
|
||||
|
||||
const PUSH_LABEL = {
|
||||
connected: "Live updates connected",
|
||||
@@ -71,7 +72,9 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</button>
|
||||
<Link href="/mail" className="brand">
|
||||
<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
|
||||
</span>
|
||||
</Link>
|
||||
@@ -97,7 +100,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<div style={{ fontWeight: 600 }} className="truncate">
|
||||
{session?.username}
|
||||
</div>
|
||||
<div className="hint truncate">{session?.ihasmail?.loginName}</div>
|
||||
<div className="hint truncate notranslate" translate="no">{session?.ihasmail?.loginName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MenuSep />
|
||||
@@ -145,7 +148,13 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<ModuleLink href="/files" icon={<FolderOpen size={20} />} label="Files" active={section === "files"} />
|
||||
</nav>
|
||||
</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>
|
||||
|
||||
{isMobile && (
|
||||
|
||||
@@ -53,7 +53,7 @@ export function LoginPage() {
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="logo">
|
||||
<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>
|
||||
</div>
|
||||
{error && (
|
||||
@@ -98,7 +98,7 @@ export function LoginPage() {
|
||||
One <p> with a break rather than two: .foot carries a 20px
|
||||
margin-top, which a second paragraph would repeat as a gap.
|
||||
*/}
|
||||
ihasmail v{APP_VERSION}
|
||||
<span className="notranslate" translate="no">ihasmail v{APP_VERSION}</span>
|
||||
<br />
|
||||
<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>}
|
||||
{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>}
|
||||
<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 && (
|
||||
<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%" }}>
|
||||
{participants.map(([k, p]) => (
|
||||
<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)} />
|
||||
{r.book.includes("·") ? <BookOpen size={16} className="faint" /> : <Book size={16} className="faint" />}
|
||||
<span className="grow truncate">
|
||||
{r.name ?? r.email}
|
||||
<span>{r.name ?? r.email}</span>
|
||||
{r.name && <span className="hint"> · {r.email}</span>}
|
||||
</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}`)}>
|
||||
<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="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>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
|
||||
)}
|
||||
{(org || Object.values(c.titles ?? {}).length > 1) && (
|
||||
<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>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -86,12 +86,12 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
<div className="row" style={{ alignItems: "flex-start" }}>
|
||||
<Calendar size={20} style={{ color: "var(--accent)", marginTop: 2 }} />
|
||||
<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>
|
||||
{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>}
|
||||
{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" && (
|
||||
<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>)}
|
||||
|
||||
@@ -647,7 +647,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
<div className="msg-body">
|
||||
<div className="msg-line1">
|
||||
<span className="msg-from truncate">
|
||||
{who}
|
||||
<span className="truncate">{who}</span>
|
||||
{count > 1 && <span className="thread-count"> {count}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
|
||||
@@ -287,7 +287,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{addrMenu.node}
|
||||
{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">
|
||||
{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 open={showHeaders} onClose={() => setShowHeaders(false)} title="Message headers" size="lg">
|
||||
<dl className="message-details" style={{ margin: 0 }}>
|
||||
@@ -444,7 +444,9 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
||||
|
||||
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 && (
|
||||
<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>}
|
||||
@@ -484,7 +486,9 @@ function TextBody({ text }: { text: string }) {
|
||||
|
||||
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 && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>•••</span>}
|
||||
@@ -556,5 +560,5 @@ function TextAttachment({ url }: { url: string }) {
|
||||
useEffect(() => {
|
||||
fetch(url, { credentials: "same-origin" }).then((r) => r.text()).then(setText).catch(() => setText("Could not load."));
|
||||
}, [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 { Switch, useIsTouch } from "@/ui/misc";
|
||||
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
|
||||
@@ -77,6 +78,27 @@ export function AppearanceSettings() {
|
||||
</select>
|
||||
</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>
|
||||
<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.
|
||||
|
||||
@@ -157,7 +157,7 @@ function RulesEditor() {
|
||||
{content && (
|
||||
<details style={{ marginTop: 20 }}>
|
||||
<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>
|
||||
)}
|
||||
{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>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>
|
||||
{validation && <div className="error-box mb-16">{validation}</div>}
|
||||
<div className="row">
|
||||
@@ -251,7 +251,7 @@ function ScriptsEditor() {
|
||||
{sieve.scripts.map((s) => (
|
||||
<div key={s.id} className="card">
|
||||
<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={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>
|
||||
|
||||
@@ -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>
|
||||
{hidden.length > 0 && (
|
||||
<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
|
||||
choose from, so in that case they are all offered again.
|
||||
{`${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.`}
|
||||
</p>
|
||||
)}
|
||||
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function NotificationsSettings() {
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
checked={s.desktopNotifications}
|
||||
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 className="mono small">{r.ip}</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 />
|
||||
</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)}>
|
||||
<option value="">Add a person or group…</option>
|
||||
{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>
|
||||
<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 (
|
||||
<div key={pid} className="card">
|
||||
<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>
|
||||
</div>
|
||||
<div className="row wrap" style={{ marginTop: 8 }}>
|
||||
|
||||
Reference in New Issue
Block a user