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.
136 lines
8.8 KiB
TypeScript
136 lines
8.8 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Plus, Trash2, Star, Eye, EyeOff } from "lucide-react";
|
|
import { useSettings } from "@/store/settings";
|
|
import { useMail } from "@/store/mail";
|
|
import type { Identity } from "@/jmap/types";
|
|
import { isAlwaysVisible } from "@/lib/identityVisibility";
|
|
import { Dialog, confirmDialog } from "@/ui/dialog";
|
|
import { RichEditor, type RichEditorHandle } from "../compose/RichEditor";
|
|
import { toast } from "@/ui/toast";
|
|
import { parseAddressList, formatAddressList } from "@/lib/address";
|
|
import { htmlToText } from "@/lib/text";
|
|
import { sanitizeEditorHtml } from "@/lib/html";
|
|
import { externalizeDataImages, storeSignatureHtml, uploadSignatureImage } from "@/lib/signatureImages";
|
|
import { buildMarkerSignature, byteLength, compactHtml, signatureTooLong, SIGNATURE_LIMIT } from "@/lib/signatureHtml";
|
|
|
|
export function IdentitiesSettings() {
|
|
const identities = useMail((s) => s.identities);
|
|
const load = useMail((s) => s.loadIdentities);
|
|
const accountId = useMail((s) => s.accountId);
|
|
const setDefault = useMail((s) => s.setDefaultIdentity);
|
|
const defaultId = useSettings((s) => (accountId ? s.settings.defaultIdentityByAccount[accountId] : undefined)) ?? identities[0]?.id;
|
|
const [editing, setEditing] = useState<Partial<Identity> | null>(null);
|
|
const hidden = useSettings((s) => s.settings.hiddenIdentities);
|
|
const updateSettings = useSettings((s) => s.update);
|
|
const toggleHidden = (id: string) =>
|
|
updateSettings({ hiddenIdentities: hidden.includes(id) ? hidden.filter((x) => x !== id) : [...hidden, id] });
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
return (
|
|
<div>
|
|
<h1>Identities & signatures</h1>
|
|
<p className="lead">Each identity is a sender address with its own name, Reply-To and signature. The default identity is preselected when you compose; set a Reply-To when replies should go somewhere other than the From address.</p>
|
|
{identities.map((i) => (
|
|
<div key={i.id} className="card clickable" onClick={() => setEditing(i)}>
|
|
<div className="card-head">
|
|
<h3>{i.name ? `${i.name} <${i.email}>` : i.email} {i.id === defaultId && <span className="tag" style={{ background: "var(--accent)", color: "var(--accent-fg)", marginLeft: 6 }}>Default</span>}</h3>
|
|
{i.id !== defaultId && (
|
|
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(`${i.email} is now your default identity`); }}><Star size={14} /> Make default</button>
|
|
)}
|
|
{/*
|
|
Hiding is presentation only -- the identity still exists and still
|
|
receives, like an unsubscribed folder. The default cannot be
|
|
hidden, because it is what a new draft starts on.
|
|
*/}
|
|
<button
|
|
className="btn btn-sm btn-ghost"
|
|
disabled={isAlwaysVisible(i.id, [defaultId])}
|
|
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
|
|
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
|
|
>
|
|
{hidden.includes(i.id) ? <><Eye size={14} /> Show when composing</> : <><EyeOff size={14} /> Hide when composing</>}
|
|
</button>
|
|
{i.mayDelete && (
|
|
<button className="icon-btn sm danger" aria-label="Delete identity" onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: "Delete this identity?", confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
|
)}
|
|
</div>
|
|
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>Not offered when composing. It still receives mail, and you can still send from it by showing it again.</div>}
|
|
{(i.htmlSignature || i.textSignature) && <div className="hint" style={{ marginTop: 4 }}>{htmlToText(i.htmlSignature || i.textSignature).slice(0, 120)}</div>}
|
|
{i.replyTo?.length ? <div className="hint">Reply-To: {formatAddressList(i.replyTo)}</div> : null}
|
|
</div>
|
|
))}
|
|
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> Add identity</button>
|
|
<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.`}
|
|
</p>
|
|
)}
|
|
{editing && <IdentityDialog identity={editing} onClose={() => setEditing(null)} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; onClose: () => void }) {
|
|
const [name, setName] = useState(identity.name ?? "");
|
|
const [email, setEmail] = useState(identity.email ?? "");
|
|
const [replyTo, setReplyTo] = useState(formatAddressList(identity.replyTo));
|
|
const [html, setHtml] = useState(identity.htmlSignature || (identity.textSignature ? identity.textSignature.replace(/\n/g, "<br>") : ""));
|
|
const [busy, setBusy] = useState(false);
|
|
const ref = useRef<RichEditorHandle>(null);
|
|
const compact = compactHtml(sanitizeEditorHtml(html));
|
|
// The server's limit is on encoded bytes, so that is what to count and show.
|
|
const sigLen = byteLength(compact);
|
|
const tooLong = signatureTooLong(compact, htmlToText(compact));
|
|
const save = async () => {
|
|
setBusy(true);
|
|
try {
|
|
// 1) pasted pictures → stored files, 2) strip cruft, 3) fall back to a stored full copy.
|
|
const externalized = await externalizeDataImages(sanitizeEditorHtml(html));
|
|
const clean = compactHtml(externalized);
|
|
let htmlSignature = clean;
|
|
let textSignature = htmlToText(clean);
|
|
if (signatureTooLong(clean, textSignature)) {
|
|
const blobId = await storeSignatureHtml(clean);
|
|
({ htmlSignature, textSignature } = buildMarkerSignature(blobId, clean));
|
|
}
|
|
const patch: Partial<Identity> = {
|
|
name,
|
|
replyTo: replyTo.trim() ? parseAddressList(replyTo) : null,
|
|
htmlSignature,
|
|
textSignature,
|
|
};
|
|
if (!identity.id) patch.email = email.trim();
|
|
await useMail.getState().saveIdentity(identity.id ?? null, patch);
|
|
toast.success("Identity saved");
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
return (
|
|
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
|
<div className="field-row">
|
|
<div className="field"><label>Display name</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
|
|
<div className="field"><label>Email address</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
|
|
</div>
|
|
<div className="field"><label>Reply-To (optional)</label><input className="input" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} placeholder="[email protected]" /><span className="hint">Replies to mail sent from this identity go here instead of the From address.</span></div>
|
|
<div className="field">
|
|
<label>Signature</label>
|
|
<div style={{ border: `1px solid ${tooLong ? "var(--danger)" : "var(--border-strong)"}`, borderRadius: 8, minHeight: 180, display: "flex", flexDirection: "column" }}>
|
|
<RichEditor ref={ref} html={html} onChange={setHtml} placeholder="Your signature…" showToolbar imageUpload={uploadSignatureImage} />
|
|
</div>
|
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
|
<span className="hint">Images are stored in your Files (folder “ihasmail”) and embedded when you send.</span>
|
|
<span className="hint nowrap" style={tooLong ? { color: "var(--warn)", fontWeight: 600 } : undefined}>{sigLen.toLocaleString()} / {SIGNATURE_LIMIT.toLocaleString()}</span>
|
|
</div>
|
|
{tooLong && <div className="warn-box mt-8">This signature is larger than the server's {SIGNATURE_LIMIT}-byte limit. ihasmail will keep the full version in your Files and store a short text fallback on the server — other mail clients will see the plain-text version.</div>}
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|