Merge pull request #148 from Coffey-Labs/i18n-extract-batch2

Finish extraction: 100%, and a coverage number worth believing
This commit is contained in:
Coffey Labs
2026-08-31 10:45:19 -07:00
committed by GitHub
40 changed files with 228 additions and 123 deletions
+18 -1
View File
@@ -20,6 +20,19 @@ const ATTRS = new Set(["title", "aria-label", "placeholder", "alt", "label", "hi
as dividers. Counting these as untranslated would put a floor under the
number that no amount of work could reach. */
const NOT_PROSE = /^[\s·—–\-—:;,.()[\]{}/|+×✓~<>#*@0-9]*$/u;
/*
* Text that is deliberately not translated is not "remaining work". Counting
* it put a floor under the number that no amount of effort could reach -- the
* report sat at 21 with only 6 real items left, which makes the number
* something to argue with rather than act on. Same rule the codemod uses.
*/
const CODE_TAGS = new Set(["code", "kbd", "pre", "samp", "var"]);
const optedOut = (node, src) => {
const opening = ts.isJsxElement(node) ? node.openingElement : ts.isJsxSelfClosingElement(node) ? node : null;
return Boolean(opening?.attributes.properties.some((a) =>
ts.isJsxAttribute(a) && a.name.getText(src) === "translate" &&
a.initializer && ts.isStringLiteral(a.initializer) && a.initializer.text === "no"));
};
const files = globSync("web/src/**/*.tsx").filter((f) => !f.includes("__tests__"));
const rows = [];
@@ -31,11 +44,15 @@ for (const file of files) {
let left = 0;
const wrapped = (text.match(/\bt\(\s*["'`]/g) || []).length + (text.match(/\bplural\(/g) || []).length;
const visit = (node) => {
if ((ts.isJsxElement(node) && CODE_TAGS.has(node.openingElement.tagName.getText(src).toLowerCase())) || optedOut(node, src)) return;
if (ts.isJsxText(node) && node.text.trim().length > 1 && !NOT_PROSE.test(node.text.trim())) left++;
if (ts.isJsxAttribute(node) && ATTRS.has(node.name.getText(src))) {
const i = node.initializer;
const lit = i && (ts.isStringLiteral(i) ? i : ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression) ? i.expression : null);
if (lit && lit.text.trim().length > 1) left++;
// The same prose test the text nodes get. Without it, placeholders that
// are format examples -- "123456" for a one-time code, "+1 555 0100" for
// a phone -- counted as untranslated work forever.
if (lit && lit.text.trim().length > 1 && !NOT_PROSE.test(lit.text.trim())) left++;
}
ts.forEachChild(node, visit);
};
+15 -2
View File
@@ -76,8 +76,21 @@ for (const file of files) {
const raw = c.text;
const body = raw.trim();
if (body.length < 2 || NOT_PROSE.test(body)) continue;
// Split around an interpolation: the fragments are not sentences.
if (meaningful.length > 1) {
/*
* "Split around an interpolation" is the dangerous case, and it is
* narrower than "has siblings". `<Plus /> New rule` is a phrase next
* to an icon: wrapping it alone is correct, and refusing it left a
* third of the remaining work to be done by hand for no reason.
* `Your script “{name}” was written by hand` is the real thing --
* a sibling that renders text, so the fragments are not sentences.
*/
const textSibling = kids.some((k) => k !== c && ts.isJsxExpression(k) && k.expression && !(() => {
let jsx = false;
const w = (n) => { if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) { jsx = true; return; } ts.forEachChild(n, w); };
w(k.expression);
return jsx;
})());
if (textSibling) {
const { line } = src.getLineAndCharacterOfPosition(c.getStart(src));
skipped.push({ file, line: line + 1, why: "text split around an expression", text: body.slice(0, 52) });
continue;
@@ -1,8 +1,15 @@
import { afterEach, describe, expect, it } from "vitest";
import { currentLanguage, interpolate, plural, setCatalog, t, type Catalog } from "@/lib/i18n";
import { renderToStaticMarkup } from "react-dom/server";
import { currentLanguage, interpolate, plural, setCatalog, t, tNode, type Catalog } from "@/lib/i18n";
const de: Catalog = {
strings: { "Archive": "Archivieren", "Move {n} to {folder}": "{n} nach {folder} verschieben" },
strings: {
"Archive": "Archivieren",
"Move {n} to {folder}": "{n} nach {folder} verschieben",
// German puts the parts in a different order, which is the whole reason
// the element is a named hole rather than a split sentence.
"Open {scheme} links here": "{scheme}-Links hier öffnen",
},
plurals: { "{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" } },
};
/* Russian is the reason plural() does not take (one, other): it needs three
@@ -77,3 +84,28 @@ describe("plural", () => {
.toBe("2 messages in Inbox");
});
});
describe("tNode", () => {
const render = (node: React.ReactNode) => renderToStaticMarkup(<>{node}</>);
it("keeps an element inside the sentence", () => {
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
.toBe("Open <code>mailto:</code> links here");
});
it("lets a translator move the element", () => {
// Splitting the sentence into two t() calls could not do this: the
// fragments would render in the English order whatever the catalogue said.
setCatalog("de", de);
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
.toBe("<code>mailto:</code>-Links hier öffnen");
});
it("leaves a placeholder alone when nothing is supplied for it", () => {
expect(render(tNode("Open {scheme} links here", {}))).toBe("Open {scheme} links here");
});
it("takes plain variables alongside elements", () => {
expect(render(tNode("{count} of {scheme}", { scheme: <b>x</b> }, { count: 3 }))).toBe("3 of <b>x</b>");
});
});
+37 -1
View File
@@ -1,4 +1,4 @@
import { useSyncExternalStore } from "react";
import { createElement, Fragment, useSyncExternalStore, type ReactNode } from "react";
import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
/**
@@ -83,6 +83,42 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
return interpolate(entry[category] ?? entry.other, { n, ...vars });
}
/**
* A translated sentence with elements inside it.
*
* Some sentences have a `<code>` or a `<kbd>` in the middle of them, and the
* two obvious approaches are both wrong. Splitting the sentence into two `t()`
* calls hands a translator "This browser cannot register apps for" and "links,
* in particular…", which are not sentences and cannot be reordered into a
* language that puts the verb somewhere else. Dropping the element and
* interpolating plain text keeps the sentence whole but loses the monospace
* that told the reader it was a literal.
*
* So the sentence stays whole and the elements are placeholders in it:
*
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
*
* A translator sees one sentence with a named hole and can put the hole
* wherever their language wants it.
*/
export function tNode(source: string, parts: Record<string, ReactNode>, vars?: Vars): ReactNode {
const translated = interpolate(current.strings[source] ?? source, vars);
const out: ReactNode[] = [];
let last = 0;
const re = /\{(\w+)\}/g;
let m: RegExpExecArray | null;
while ((m = re.exec(translated))) {
if (!Object.prototype.hasOwnProperty.call(parts, m[1]!)) continue;
if (m.index > last) out.push(translated.slice(last, m.index));
// Keyed, because this is an array and React asks; the index is stable for
// a given rendering of a given sentence.
out.push(createElement(Fragment, { key: `${m[1]}-${m.index}` }, parts[m[1]!]));
last = m.index + m[0].length;
}
if (last < translated.length) out.push(translated.slice(last));
return out;
}
/** The language in force, for anything that needs the tag itself. */
export function currentLanguage(): string {
return currentTag;
+9 -5
View File
@@ -168,19 +168,23 @@ export function AppShell({ children }: { children: ReactNode }) {
<nav className="mobile-tabbar" aria-label={t("Sections")}>
<Link href="/mail" className={section === "mail" || section === "search" ? "active" : ""}>
<Mail size={22} />
Mail
{t("Mail")}
</Link>
<Link href="/calendar" className={section === "calendar" ? "active" : ""}>
<Calendar size={22} />
Calendar
{t("Calendar")}
</Link>
<Link href="/contacts" className={section === "contacts" ? "active" : ""}>
<Users size={22} />
Contacts
{t("Contacts")}
</Link>
<Link href="/files" className={section === "files" ? "active" : ""}>
<FolderOpen size={22} />
Files
{t("Files")}
</Link>
</nav>
</>
@@ -209,7 +213,7 @@ function QuotaBar() {
<div className="quota" title={`${formatSize(q.used)} of ${formatSize(q.hardLimit)} used`}>
<div className="row" style={{ justifyContent: "space-between" }}>
<span>
{formatSize(q.used)} of {formatSize(q.hardLimit)}
{t("{used} of {total}", { used: formatSize(q.used), total: formatSize(q.hardLimit) })}
</span>
<ChevronsUpDown size={12} style={{ opacity: 0 }} />
</div>
+2 -2
View File
@@ -85,8 +85,8 @@ export function SearchBar() {
</div>
<div className="row" style={{ justifyContent: "space-between", marginTop: 4 }}>
<div className="row gap-16">
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> Has attachment</label>
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> Unread only</label>
<label className="check"><input type="checkbox" checked={advFields.hasAttachment} onChange={(e) => setAdvFields({ ...advFields, hasAttachment: e.target.checked })} /> {t("Has attachment")}</label>
<label className="check"><input type="checkbox" checked={advFields.unread} onChange={(e) => setAdvFields({ ...advFields, unread: e.target.checked })} /> {t("Unread only")}</label>
</div>
<div className="row">
<button type="button" className="btn btn-ghost" onClick={() => setAdv(false)}>{t("Cancel")}</button>
@@ -116,7 +116,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
{canEdit && (
<>
<MenuSep />
<MenuTitle><span className="row gap-4"><Tag size={12} /> Category</span></MenuTitle>
<MenuTitle><span className="row gap-4"><Tag size={12} /> {t("Category")}</span></MenuTitle>
{categories.map((c) => (
<MenuItem key={c.name} label={<span className="row gap-8"><span className="label-dot" style={{ background: c.color, width: 12, height: 12 }} />{c.name}</span>} checked={currentCat?.name === c.name} onClick={() => { onClose(); setCategory(currentCat?.name === c.name ? null : c); }} />
))}
+1 -1
View File
@@ -37,7 +37,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
<div className="field"><label>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
<div className="field"><label>{translate("Time zone")}</label>
<select className="select" value={tz} onChange={(e) => setTz(e.target.value)}>
<option value="">Default ({browserTimeZone})</option>
<option value="">{translate("Default ({zone})", { zone: browserTimeZone })}</option>
{listTimeZones().map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
+2 -2
View File
@@ -130,7 +130,7 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
<button key={v} className={effectiveView === v ? "active" : ""} onClick={() => go(v, anchor)}>{v[0]!.toUpperCase() + v.slice(1)}</button>
))}
</div>
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> Event</button>}
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>}
</div>
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
{effectiveView === "month" && <MonthView anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />}
@@ -169,7 +169,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
</div>
);
})}
+7 -7
View File
@@ -285,7 +285,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
)}
</div>
<div className="row wrap" style={{ gap: 16, marginBottom: 8 }}>
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> All day</label>
<label className="check"><input type="checkbox" checked={allDay} onChange={(e) => { setAllDay(e.target.checked); if (e.target.checked) { const s = new Date(start); s.setHours(0, 0, 0, 0); setStart(s); setEnd(new Date(s.getTime() + Math.max(DAY_MS, Math.ceil((end.getTime() - s.getTime()) / DAY_MS) * DAY_MS))); } }} /> {translate("All day")}</label>
{!allDay && (
<select className="select" style={{ width: "auto", height: 32 }} value={tz} onChange={(e) => setTz(e.target.value)} title={translate("Time zone")}>
{!listTimeZones().includes(tz) && <option value={tz}>{tz}</option>}
@@ -296,9 +296,9 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
<option value="none">{translate("Does not repeat")}</option>
<option value="daily">{translate("Daily")}</option>
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
<option value="weekly">{translate("Weekly on {weekday}", { weekday: formatWeekday(start, "long") })}</option>
<option value="weekdays">{translate("Every weekday")}</option>
<option value="monthly">Monthly on day {start.getDate()}</option>
<option value="monthly">{translate("Monthly on day {day}", { day: start.getDate() })}</option>
<option value="yearly">{translate("Yearly")}</option>
<option value="custom">{translate("Custom…")}</option>
</select>
@@ -342,7 +342,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
</div>
<div className="field"><label>{translate("Meeting link")}</label><input className="input" value={vurl} onChange={(e) => setVurl(e.target.value)} placeholder={translate("https://meet.example.com/…")} /></div>
<div className="field">
<label><Users size={13} /> Guests</label>
<label><Users size={13} /> {translate("Guests")}</label>
<div className="input" style={{ height: "auto", minHeight: 38, padding: "4px 8px" }}>
<RecipientInput value={attendees} onChange={setAttendees} placeholder={translate("Add guests by name or email")} />
</div>
@@ -351,7 +351,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
<Switch checked={sendInvites} onChange={setSendInvites} label={translate("Send invitation emails to guests")} />
{Object.keys(fb).length > 0 && (
<div className="freebusy">
<div className="hint">Availability on {formatNumericDate(start)}</div>
<div className="hint">{translate("Availability on {date}", { date: formatNumericDate(start) })}</div>
{attendees.filter((a) => fb[a.email]).map((a) => (
<div key={a.email} className="fb-row">
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
@@ -383,7 +383,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
<button className="icon-btn sm danger" onClick={() => setAlerts(alerts.filter((_, j) => j !== i))} aria-label={translate("Remove reminder")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> Add reminder</button>
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAlerts([...alerts, 10])}><Plus size={14} /> {translate("Add reminder")}</button>
</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={() => setShowMore((v) => !v)}>{showMore ? "Fewer options" : "More options"}</button>
@@ -400,7 +400,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
{categories.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
</select>
</div>
<div className="field"><label>{translate("Color")}</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>Use {category ? "category" : "calendar"} color</button>}</div></div>
<div className="field"><label>{translate("Color")}</label><div className="row wrap"><ColorSwatches value={color} onChange={setColor} />{color && <button className="btn btn-ghost btn-sm" onClick={() => setColor(null)}>{category ? translate("Use category color") : translate("Use calendar color")}</button>}</div></div>
</div>
)}
</div>
+3 -3
View File
@@ -99,9 +99,9 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
{myKeys.length > 0 && !isOrganizer && (
<div className="row" style={{ marginTop: 10, gap: 6 }}>
<span className="hint">{t("Going?")}</span>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> Yes</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> Maybe</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> No</button>
<button className={`btn btn-sm ${myStatus === "accepted" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("accepted")}><Check size={14} /> {t("Yes")}</button>
<button className={`btn btn-sm ${myStatus === "tentative" ? "btn-primary" : ""}`} disabled={busy} onClick={() => void rsvp("tentative")}><HelpCircle size={14} /> {t("Maybe")}</button>
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={busy} onClick={() => void rsvp("declined")}><X size={14} /> {t("No")}</button>
</div>
)}
</Popover>
+1 -1
View File
@@ -246,7 +246,7 @@ export function Composer({ draft }: { draft: Draft }) {
<div className="composer-foot">
<span className="send-group">
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? `Hand to the server, held until ${formatScheduleTime(new Date(d.sendAt))} (Ctrl+Enter)` : "Send (Ctrl+Enter)"}>
{d.sendAt !== null ? <><Clock size={16} /> Schedule send</> : <><Send size={16} /> Send</>}
{d.sendAt !== null ? <><Clock size={16} /> {translate("Schedule send")}</> : <><Send size={16} /> {translate("Send")}</>}
</button>
<button className="btn btn-primary" onClick={sendMenu.open} aria-label={translate("Send options")}><ChevronDown size={16} /></button>
</span>
+1 -1
View File
@@ -73,7 +73,7 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
{files.sharedAccounts.length > 0 && (
<div className="row wrap gap-4" style={{ marginBottom: 10 }}>
<button className={`btn btn-sm ${viewingShare ? "" : "btn-primary"}`} onClick={() => openAccount(files.ownAccountId)}>
<HardDrive size={14} /> My files
<HardDrive size={14} /> {t("My files")}
</button>
{files.sharedAccounts.map((a) => (
<button key={a.id} className={`btn btn-sm ${files.accountId === a.id ? "btn-primary" : ""}`} onClick={() => openAccount(a.id)}>
+1 -1
View File
@@ -178,7 +178,7 @@ export function RecipientPicker({ onPick, onClose }: { onPick: (field: Field, ad
</div>
{!ownBooks.length && !subscribed.length && (
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> No address books yet.</p>
<p className="hint" style={{ marginTop: 8 }}><Users size={12} /> {t("No address books yet.")}</p>
)}
</Dialog>
);
+1 -2
View File
@@ -58,8 +58,7 @@ export function ScheduleDialog({ open, maxMs, initial, onClose, onPick }: {
<p className="hint" style={{ color: "var(--danger)" }}>{error}</p>
) : (
<p className="hint">
The message waits on the server, so it goes out whether or not ihasmail is open.
{maxMs > 0 && ` This server holds a message for up to ${describeSpan(maxMs)}.`}
{`${t("The message waits on the server, so it goes out whether or not ihasmail is open.")}${maxMs > 0 ? ` ${t("This server holds a message for up to {span}.", { span: describeSpan(maxMs) })}` : ""}`}
</p>
)}
</Dialog>
+4 -4
View File
@@ -163,7 +163,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
{photoSrc ? <img src={photoSrc} alt="" /> : <Camera size={28} />}
<input type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onPhoto(f); e.target.value = ""; }} />
</label>
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> Remove photo</button>}
{photoSrc && <button className="btn btn-ghost btn-sm" onClick={() => { setPhoto(null); setRemovePhoto(true); }}><X size={14} /> {t("Remove photo")}</button>}
<span className="spacer" />
<div className="field" style={{ marginBottom: 0, width: 160 }}>
<label>{t("Type")}</label>
@@ -234,7 +234,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
<button className="icon-btn sm danger" onClick={() => setEmails(emails.filter((_, j) => j !== i))} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setEmails([...emails, { key: newKey("e"), address: "", ctx: emails.length ? "work" : "private" }])}><Plus size={14} /> Add email</button>
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setEmails([...emails, { key: newKey("e"), address: "", ctx: emails.length ? "work" : "private" }])}><Plus size={14} /> {t("Add email")}</button>
</div>
</div>
<div className="field">
@@ -247,7 +247,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
<button className="icon-btn sm danger" onClick={() => setPhones(phones.filter((_, j) => j !== i))} aria-label={t("Remove")}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setPhones([...phones, { key: newKey("p"), number: "", ctx: "mobile" }])}><Plus size={14} /> Add phone</button>
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setPhones([...phones, { key: newKey("p"), number: "", ctx: "mobile" }])}><Plus size={14} /> {t("Add phone")}</button>
</div>
</div>
<div className="field">
@@ -269,7 +269,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
</div>
</div>
))}
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAddrs([...addrs, { key: newKey("a"), ctx: "private", street: "", city: "", region: "", postcode: "", country: "" }])}><Plus size={14} /> Add address</button>
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "flex-start" }} onClick={() => setAddrs([...addrs, { key: newKey("a"), ctx: "private", street: "", city: "", region: "", postcode: "", country: "" }])}><Plus size={14} /> {t("Add address")}</button>
</div>
</div>
<div className="field-row">
+2 -2
View File
@@ -170,10 +170,10 @@ export function ContactsSidebar() {
{/* Import and export lived in the pane this replaced. */}
<div style={{ padding: "12px 8px" }} className="col gap-8">
<label className="btn btn-sm btn-block">
<Upload size={14} /> Import vCard
<Upload size={14} /> {t("Import vCard")}
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
</label>
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> Export {sel.bookId === "all" ? "all" : "book"}</button>
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button>
</div>
<Popover anchor={menu.anchor} onClose={menu.close} width={210}>
+5 -5
View File
@@ -166,8 +166,8 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
<div className="row" style={{ marginBottom: 12 }}>
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> Edit</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> vCard</button>
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: `Delete ${name}?`, confirmLabel: "Delete", danger: true })) { try { await contacts.destroyCards([c.id]); toast.success("Contact deleted"); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div>
<div className="contact-hero">
@@ -223,13 +223,13 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
</div>
)}
{c.kind === "group" && (
<div className="contact-section"><h3>Members ({Object.keys(c.members ?? {}).length})</h3>
<div className="contact-section"><h3>{translate("Members ({count})", { count: Object.keys(c.members ?? {}).length })}</h3>
{members.map((m) => <div key={m.id} className="contact-kv"><span className="k"><Avatar who={{ name: contactDisplayName(m), email: contactEmails(m)[0]?.email }} size="sm" /></span><span className="v"><a href={`/contacts/${m.id}`} onClick={(e) => { e.preventDefault(); navigate(`/contacts/${m.id}`); }}>{contactDisplayName(m)}</a> <span className="hint">{contactEmails(m)[0]?.email}</span></span></div>)}
{members.length > 0 && <button className="btn btn-sm mt-8" onClick={() => useCompose.getState().open({ to: members.flatMap((m) => contactEmails(m).slice(0, 1)) })}><Mail size={14} /> Email group</button>}
{members.length > 0 && <button className="btn btn-sm mt-8" onClick={() => useCompose.getState().open({ to: members.flatMap((m) => contactEmails(m).slice(0, 1)) })}><Mail size={14} /> {translate("Email group")}</button>}
</div>
)}
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {formatDate(new Date(c.updated))}</p>}
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> {translate("Updated {date}", { date: formatDate(new Date(c.updated)) })}</p>}
</div>
);
}
+2 -2
View File
@@ -120,9 +120,9 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
</span>
))}
</div>
<button className="btn btn-sm" onClick={() => inputRef.current?.click()}><Upload size={16} /> Upload</button>
<button className="btn btn-sm" onClick={() => inputRef.current?.click()}><Upload size={16} /> {t("Upload")}</button>
<input ref={inputRef} type="file" multiple hidden onChange={(e) => { const l = Array.from(e.target.files ?? []); if (l.length) void files.upload(parentId, l); e.target.value = ""; }} />
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> New folder</button>
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: "New folder", placeholder: "Folder name" }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }}><FolderPlus size={16} /> {t("New folder")}</button>
</div>
{files.uploads.length > 0 && (
<div className="list-hint" style={{ flexDirection: "column", alignItems: "stretch", gap: 4 }}>
+2 -2
View File
@@ -45,9 +45,9 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
looking for a problem they do not have.
*/}
{damage ? (
<p>Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.</p>
<p>{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}</p>
) : loaded ? (
<p>Your active Sieve script was written by hand, so rules can't be added automatically. Open <b>{t("Settings → Filters & rules")}</b> to edit the script or switch to managed rules.</p>
<p>{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} <b>{t("Settings → Filters & rules")}</b> {t("to edit the script or switch to managed rules.")}</p>
) : (
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
)}
+2 -2
View File
@@ -91,7 +91,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
<h4>{ev.title || "(untitled event)"}</h4>
{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>}
{organizer && <div className="small muted">{t("Organizer: {name}", { name: organizer.name || participantEmail(organizer) })}</div>}
{attendees.length > 0 && <div className="small muted">{`${attendees.length} attendee${attendees.length === 1 ? "" : "s"}`}</div>}
{method === "REPLY" && (
<div className="small" style={{ marginTop: 4 }}>
@@ -109,7 +109,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
<button className={`btn btn-sm ${myStatus === "declined" ? "btn-danger" : ""}`} disabled={Boolean(busy)} onClick={() => void respond("declined")}><X size={14} /> {myStatus === "declined" ? "Declined" : "No"}</button>
</>
) : (
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> Add to calendar</button>
!existing && <button className="btn btn-sm" disabled={Boolean(busy)} onClick={() => void addToCalendar()}><Calendar size={14} /> {t("Add to calendar")}</button>
)}
{existing && inst && <button className="btn btn-ghost btn-sm" onClick={() => navigate(`/calendar/day/${inst.start.toISOString().slice(0, 10)}`)}>{t("Open in calendar")}</button>}
</div>
+1 -1
View File
@@ -75,7 +75,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
{q.trim() && !labels.some((l) => l.name.toLowerCase() === q.trim().toLowerCase()) && (
<button className="menu-item" onClick={create}>
<Plus size={16} />
<span>Create {q.trim()}</span>
<span>{t("Create “{name}”", { name: q.trim() })}</span>
</button>
)}
{!labels.length && !q && <div className="hint" style={{ padding: "4px 10px 8px" }}>{t("Type a name to create your first label.")}</div>}
+2 -1
View File
@@ -16,6 +16,7 @@ import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
import { t as translate } from "@/lib/i18n";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
const [, navigate] = useLocation();
@@ -353,7 +354,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
<div className="no-thread">
<img src="/img/logo.png" alt="" />
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
<div className="hint">Select a conversation to read it here · Press <kbd className="kbd">?</kbd> for shortcuts</div>
<div className="hint">{translate("Select a conversation to read it here · Press")} <kbd className="kbd">?</kbd> {translate("for shortcuts")}</div>
</div>
)}
</div>
+1 -1
View File
@@ -384,7 +384,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
reason this entry survives at all. */}
{shared && <MenuItem icon={<Share2 size={16} />} label={t("Stop sharing")} onClick={onShare} />}
<MenuSep />
<MenuTitle><span className="row gap-4"><Palette size={12} /> Colour</span></MenuTitle>
<MenuTitle><span className="row gap-4"><Palette size={12} /> {t("Colour")}</span></MenuTitle>
<div className="color-grid" style={{ gridTemplateColumns: "repeat(6, 26px)", padding: "4px 10px 8px" }}>
{CALENDAR_COLORS.map((c) => (
<button
+2 -2
View File
@@ -14,7 +14,7 @@ import { useCompose } from "@/store/compose";
import { haptic, usePullToRefresh, useTouchRow, PULL_TRIGGER } from "@/lib/touch";
import { describeSwipe, type SwipeAction, type SwipeDescriptor, type SwipeIcon } from "@/lib/swipe";
import { FilterFromMessageDialog } from "./FilterFromMessage";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
/**
* The glyph on the strip a swipe reveals. Sized larger than the toolbar's
@@ -248,7 +248,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
/>
{selCount > 0 ? (
<>
<span className="tb-count">{selCount} selected</span>
<span className="tb-count">{plural(selCount, { one: "{n} selected", other: "{n} selected" })}</span>
<span className="tb-sep" />
<button className="icon-btn" title={t("Archive (e)")} onClick={() => void actions.archive()}><Archive size={19} /></button>
<button className="icon-btn" title={isTrashOrJunk ? "Delete forever" : "Delete (#)"} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
+12 -10
View File
@@ -150,13 +150,14 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<div className="who">
<div className="from" onContextMenu={(ev) => from && addrMenu.open(ev, from)}>
<span className="addr">{displayName(from)}</span>
{expanded && from && <span className="email addr">&lt;{from.email}&gt;</span>}
{/* An address, not a sentence. */}
{expanded && from && <span className="email addr notranslate" translate="no">&lt;{from.email}&gt;</span>}
{isHighPriority && <span className="tag" style={{ background: "var(--danger)" }}>{translate("Important")}</span>}
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> Unverified</span>}
{authFailed && <span className="tag" style={{ background: "var(--warn)" }} title={e["header:Authentication-Results:asText"] ?? ""}><ShieldAlert size={12} /> {translate("Unverified")}</span>}
</div>
{expanded ? (
<div className="to">
<span className="truncate">to {summarizeRecipients(e)}</span>
<span className="truncate">{translate("to {recipients}", { recipients: summarizeRecipients(e) })}</span>
<button onClick={(ev) => { ev.stopPropagation(); setDetails((v) => !v); }} aria-label={translate("Show details")} title={translate("Show details")}>
{details ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
@@ -222,9 +223,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<div className="receipt-banner" style={{ margin: "0 16px 8px" }}>
<CheckCheck size={16} />
<span className="grow">
The sender asked for a read receipt.
{translate("The sender asked for a read receipt.")}
{receipt.redirected && (
<> It would go to <strong>{receipt.to!.email}</strong>, which is not where the message came from.</>
<> {translate("It would go to")} <strong>{receipt.to!.email}</strong>{translate(", which is not where the message came from.")}</>
)}
</span>
<button
@@ -248,7 +250,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{scheduled && (
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
<Clock size={16} />
<span className="grow">Waiting on the server goes out {formatScheduleTime(new Date(scheduled.sendAt))}.</span>
<span className="grow">{translate("Waiting on the server — goes out {when}.", { when: formatScheduleTime(new Date(scheduled.sendAt)) })}</span>
<button
onClick={async () => {
try {
@@ -269,7 +271,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
<ImageIcon size={16} />
<span className="grow">{translate("Remote images are blocked to protect your privacy.")}</span>
<button onClick={() => setAllowRemote(true)}>{translate("Show images")}</button>
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>Always from {from.email}</button>}
{from && <button onClick={() => updateSettings({ trustedImageSenders: [...settings.trustedImageSenders, from.email.toLowerCase()] })}>{translate("Always from {email}", { email: from.email })}</button>}
</div>
)}
{icsPart && <InviteCard email={e} part={icsPart} />}
@@ -543,15 +545,15 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
})}
{attachments.length > 1 && (
<button className="btn btn-ghost btn-sm" style={{ alignSelf: "center" }} onClick={() => { for (const a of attachments) { if (!a.blobId) continue; const l = document.createElement("a"); l.href = client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type); l.download = a.name ?? ""; l.click(); } }}>
<Download size={14} /> Download all
<Download size={14} /> {translate("Download all")}
</button>
)}
</div>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> Download</a>}>
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? "Preview"} size="xl" footer={preview && <a className="btn" href={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file", preview.type)} download><Download size={16} /> {translate("Download")}</a>}>
{preview?.type.startsWith("image/") && <img src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "image", preview.type, true)} alt={preview.name ?? ""} style={{ maxHeight: "70vh", display: "block", margin: "0 auto" }} />}
{preview?.type === "application/pdf" && <iframe title={translate("PDF")} src={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.pdf", preview.type, true)} style={{ width: "100%", height: "70vh", border: 0 }} />}
{preview?.type === "text/plain" && <TextAttachment url={client.downloadUrl(accountId, preview.blobId!, preview.name ?? "file.txt", preview.type, true)} />}
<p className="hint" style={{ marginTop: 8 }}>From: {displayName(email.from?.[0])}</p>
<p className="hint" style={{ marginTop: 8 }}>{translate("From: {sender}", { sender: displayName(email.from?.[0]) })}</p>
</Dialog>
</>
);
+5 -5
View File
@@ -12,7 +12,7 @@ import { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker";
import { threadScrollTarget } from "@/lib/threadScroll";
import { useEdgeBack } from "@/lib/touch";
import { t } from "@/lib/i18n";
import { plural, t } from "@/lib/i18n";
/** How long the opening scroll keeps its place while bodies and images land. */
const HOLD_MS = 2000;
@@ -268,7 +268,7 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
</div>
)}
</div>
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{messages.length} messages</span>}
{messages.length > 1 && <span className="muted small nowrap" style={{ marginTop: 6 }}>{plural(messages.length, { one: "{n} message", other: "{n} messages" })}</span>}
</div>
{error && <div className="error-box" style={{ margin: 16 }}>{error}</div>}
{loading && !messages.length && <Spinner label={t("Loading conversation…")} />}
@@ -286,9 +286,9 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
{last && (
<div className="reply-box">
<div className="reply-prompt">
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> Reply</button>
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> Reply all</button>
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> Forward</button>
<button onClick={() => void reply(last, "reply")}><Reply size={16} /> {t("Reply")}</button>
<button onClick={() => void reply(last, "replyAll")}><ReplyAll size={16} /> {t("Reply all")}</button>
<button onClick={() => void reply(last, "forward")}><Forward size={16} /> {t("Forward")}</button>
</div>
</div>
)}
+8 -7
View File
@@ -2,7 +2,7 @@ import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
export function AboutSettings() {
const session = useSession((s) => s.session);
@@ -12,12 +12,13 @@ export function AboutSettings() {
return (
<div>
<h1>{t("About ihasmail")}</h1>
<p className="lead">A fast, friendly, open-source webmail for <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a>, built on JMAP.</p>
<p className="lead">{tNode("A fast, friendly, open-source webmail for {server}, built on JMAP.", { server: <a href="https://stalw.art" target="_blank" rel="noreferrer">{t("Stalwart Mail Server")}</a> })}</p>
<div className="row" style={{ gap: 16, alignItems: "center", marginBottom: 16 }}>
<img src="/img/logo.png" alt={t("ihasmail")} width={96} />
<div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail v{APP_VERSION}</div>
<div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
{/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700, fontSize: "1.2em" }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
</div>
</div>
<h2>{t("Server")}</h2>
@@ -26,12 +27,12 @@ export function AboutSettings() {
<tr><td>{t("Signed in as")}</td><td>{session?.username}</td></tr>
<tr><td>{t("Stalwart")}</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
<tr><td>{t("Accounts")}</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
<tr><td>{t("Max upload")}</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
<tr><td>{t("Max upload")}</td><td>{t("{size} MB", { size: Math.round(client.maxSizeUpload / 1048576) })}</td></tr>
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
<p className="hint">ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: <strong>{t("v2026.8.30+pr129")}</strong> was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead <code>+g1fa6578</code>. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.</p>
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
<h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
@@ -45,7 +45,7 @@ export function AppearanceSettings() {
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>{translate("ihasmail")}</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
<strong>{translate("ihasmail")}</strong> {translate("is the palette from")} <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>{translate(", and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.")}
</p>
<Switch
checked={s.themeMessageBody}
@@ -98,7 +98,8 @@ export function AppearanceSettings() {
{translate("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>{translate("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.
{translate("This is separate from")} <strong>{translate("Language & region")}</strong> {translate("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>{translate("Swiping")}</h2>
+1 -1
View File
@@ -59,7 +59,7 @@ export function CalendarSettings() {
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
</div>
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> New category</button>
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
<h2>{t("Working hours")}</h2>
<div className="field-row">
+11 -11
View File
@@ -9,7 +9,7 @@ import { confirmDialog, promptDialog } from "@/ui/dialog";
import { Switch, Spinner } from "@/ui/misc";
import { toast } from "@/ui/toast";
import type { SieveScript } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
export function FiltersSettings() {
const sieve = useSieve();
@@ -33,8 +33,8 @@ export function FiltersSettings() {
<h1>{t("Filters & rules")}</h1>
<p className="lead">{t("Sort incoming mail automatically. Rules run on the server (Sieve), so they work for every client you use.")}</p>
<div className="view-switch" style={{ marginBottom: 16 }}>
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> Rules</button>
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> Scripts (advanced)</button>
<button className={tab === "rules" ? "active" : ""} onClick={() => setTab("rules")}><Wand2 size={15} /> {t("Rules")}</button>
<button className={tab === "scripts" ? "active" : ""} onClick={() => setTab("scripts")}><Code size={15} /> {t("Scripts (advanced)")}</button>
</div>
{sieve.loading && !sieve.scripts.length ? <Spinner /> : tab === "rules" ? <RulesEditor /> : <ScriptsEditor />}
</div>
@@ -88,7 +88,7 @@ function RulesEditor() {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>{t("Only part of your filter script arrived.")}</b></div>
<p style={{ margin: "0 0 8px" }}>It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.</p>
<p style={{ margin: "0 0 8px" }}>{t("It {damage}, so the rules in it can't be shown or edited — saving what did arrive would write it back over the rest. Reload the page to try again. Your rules are still on the server; nothing here has changed them.", { damage })}</p>
<button className="btn" onClick={() => window.location.reload()}>{t("Reload")}</button>
</div>
);
@@ -97,16 +97,16 @@ function RulesEditor() {
if (rules === null) {
return (
<div className="warn-box">
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>Your active script “{script?.name}” was written by hand.</b></div>
<p style={{ margin: "0 0 8px" }}>The visual rule editor only manages scripts it created. You can edit the script in the <b>{t("Scripts")}</b> tab, or start fresh with rules (the existing script will be kept but deactivated).</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Switch to rules?", message: `“${script?.name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.`, confirmLabel: "Continue" })) void save([]); }}>{t("Start with rules")}</button>
<div className="row gap-8" style={{ marginBottom: 8 }}><AlertTriangle size={18} /> <b>{t("Your active script “{name}” was written by hand.", { name: script?.name ?? "" })}</b></div>
<p style={{ margin: "0 0 8px" }}>{tNode("The visual rule editor only manages scripts it created. You can edit the script in the {tab} tab, or start fresh with rules (the existing script will be kept but deactivated).", { tab: <b>{t("Scripts")}</b> })}</p>
<button className="btn" onClick={async () => { if (await confirmDialog({ title: t("Switch to rules?"), message: t("“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.", { name: script?.name ?? "" }), confirmLabel: t("Continue") })) void save([]); }}>{t("Start with rules")}</button>
</div>
);
}
return (
<div>
{activeIsOther && <div className="warn-box mb-16">Another script (“{script?.name}”) is active. Saving rules here will activate the “ihasmail” script instead.</div>}
{activeIsOther && <div className="warn-box mb-16">{t("Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.", { name: script?.name ?? "" })}</div>}
{list.length === 0 && <div className="empty" style={{ padding: 32 }}><Wand2 size={32} /><h3>{t("No filters yet")}</h3><p>{t("Create a rule to move newsletters to a folder, flag important senders, or forward mail.")}</p></div>}
{list.map((r, i) => (
<div
@@ -150,7 +150,7 @@ function RulesEditor() {
</div>
))}
<div className="row" style={{ marginTop: 12 }}>
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> New rule</button>
<button className="btn" onClick={() => setEditing(newRule())}><Plus size={16} /> {t("New rule")}</button>
<span className="spacer" />
{dirty && <button className="btn btn-ghost" onClick={() => setLocal(null)}>{t("Discard changes")}</button>}
<button className="btn btn-primary" disabled={!dirty || saving} onClick={() => void save(list)}>{saving ? "Saving…" : "Save filters"}</button>
@@ -236,7 +236,7 @@ function ScriptsEditor() {
{validation && <div className="error-box mb-16">{validation}</div>}
<div className="row">
<button className="btn btn-ghost" onClick={() => { setSel(null); setName(""); setContent(""); }}>{t("Cancel")}</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> Validate</button>
<button className="btn" disabled={busy} onClick={async () => { setBusy(true); const err = await sieve.validate(content); setValidation(err); setBusy(false); if (!err) toast.success("Script is valid"); }}><Play size={14} /> {t("Validate")}</button>
<span className="spacer" />
<button className="btn" disabled={busy} onClick={() => void save(false)}>{t("Save")}</button>
<button className="btn btn-primary" disabled={busy} onClick={() => void save(true)}>{t("Save & activate")}</button>
@@ -259,7 +259,7 @@ function ScriptsEditor() {
</div>
</div>
))}
<button className="btn" onClick={async () => { const n = await promptDialog({ title: "New script", placeholder: "Script name" }); if (n) { setName(n); setContent('require ["fileinto"];\n\n'); } }}><Plus size={16} /> New script</button>
<button className="btn" onClick={async () => { const n = await promptDialog({ title: "New script", placeholder: "Script name" }); if (n) { setName(n); setContent('require ["fileinto"];\n\n'); } }}><Plus size={16} /> {t("New script")}</button>
</div>
);
}
+2 -2
View File
@@ -35,8 +35,8 @@ export function FoldersSettings() {
return (
<div>
<h1>{t("Folders")}</h1>
<p className="lead">Create, rename and hide folders. {q && q.hardLimit ? `Storage: ${formatSize(q.used)} of ${formatSize(q.hardLimit)} used.` : ""}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> New folder</button>
<p className="lead">{`${t("Create, rename and hide folders.")} ${q && q.hardLimit ? t("Storage: {used} of {total} used.", { used: formatSize(q.used), total: formatSize(q.hardLimit) }) : ""}`}</p>
<button className="btn mb-16" onClick={() => void create()}><Plus size={16} /> {t("New folder")}</button>
<table className="sessions-table">
<thead><tr><th>{t("Folder")}</th><th>{t("Messages")}</th><th>{t("Unread")}</th><th /></tr></thead>
<tbody>
+11 -12
View File
@@ -3,7 +3,7 @@ import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast";
import { useState } from "react";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
import {
canUnregisterMailtoHandler,
isInstalledApp,
@@ -132,7 +132,7 @@ export function GeneralSettings() {
<div className="field">
<label>{t("Time zone")}</label>
<select className="select" value={s.timeZone ?? ""} onChange={(e) => update({ timeZone: e.target.value || null })}>
<option value="">Browser default ({browserTimeZone})</option>
<option value="">{t("Browser default ({zone})", { zone: browserTimeZone })}</option>
{listTimeZones().map((tz) => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
@@ -149,10 +149,10 @@ export function GeneralSettings() {
<div className="field">
<label>{t("Language & region")}</label>
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
<option value="">Automatic ({localeLabel(autoLocale)})</option>
<option value="">{t("Automatic ({locale})", { locale: localeLabel(autoLocale) })}</option>
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} {o.tag}</option>)}
</select>
<p className="hint">{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.</p>
<p className="hint">{`${serverLocale ? t("Your mail server reports {name} ({tag}).", { name: localeLabel(serverLocale), tag: serverLocale }) : t("Your mail server does not report a locale, so the browser's is used.")} ${t("Dates, times and month names follow this choice.")}`}</p>
</div>
<div className="field">
<label>{t("Date format")}</label>
@@ -167,13 +167,13 @@ export function GeneralSettings() {
<div className="field">
<label>{t("Time format")}</label>
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
<option value="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
<option value="auto">{t("Automatic ({example})", { example: withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE)) })}</option>
<option value="24">{t("24-hour clock (18:23)")}</option>
<option value="12">{t("12-hour clock (6:23 PM)")}</option>
</select>
</div>
</div>
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
<p className="hint">{t("Preview: {example}", { example: formatFullDateTime(SAMPLE) })}</p>
<h2>{t("Default mail app")}</h2>
<MailHandlerSettings />
@@ -182,8 +182,8 @@ export function GeneralSettings() {
<div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button>
<label className="btn">
Import settings
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? "Settings imported" : "Invalid settings file"); e.target.value = ""; }} />
{t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />
</label>
<button className="btn btn-ghost" onClick={() => { reset(); toast.show("Settings reset to defaults"); }}>{t("Reset to defaults")}</button>
</div>
@@ -217,17 +217,16 @@ function MailHandlerSettings() {
};
if (support === "unsupported") {
return <p className="hint">This browser cannot register apps for <code>mailto:</code> links. Safari, in particular, has no such API you can still make ihasmail the default from your operating system if you install it as an app.</p>;
return <p className="hint">{tNode("This browser cannot register apps for {scheme} links. Safari, in particular, has no such API — you can still make ihasmail the default from your operating system if you install it as an app.", { scheme: <code>mailto:</code> })}</p>;
}
if (support === "insecure") {
return <p className="hint">Registering for <code>mailto:</code> links requires a secure (HTTPS) connection.</p>;
return <p className="hint">{tNode("Registering for {scheme} links requires a secure (HTTPS) connection.", { scheme: <code>mailto:</code> })}</p>;
}
return (
<>
<p className="hint">
Open <code>mailto:</code> links in web pages, documents and other apps in ihasmail instead of a desktop mail client.
Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).
{tNode("Open {scheme} links — in web pages, documents and other apps — in ihasmail instead of a desktop mail client. Your browser will ask you to confirm, and you can change it later in its own settings (Chrome: Settings Privacy and security Site settings Protocol handlers; Firefox: Settings General Applications).", { scheme: <code>mailto:</code> })}
</p>
<div className="row wrap">
<button className="btn btn-primary" onClick={ask}>{requested ? "Ask again" : "Make ihasmail the default mail app"}</button>
@@ -38,7 +38,7 @@ export function IdentitiesSettings() {
<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 }}>{t("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>
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); setDefault(i.id); toast.success(t("{email} is now your default identity", { email: i.email })); }}><Star size={14} /> {t("Make default")}</button>
)}
{/*
Hiding is presentation only -- the identity still exists and still
@@ -51,7 +51,7 @@ export function IdentitiesSettings() {
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</>}
{hidden.includes(i.id) ? <><Eye size={14} /> {t("Show when composing")}</> : <><EyeOff size={14} /> {t("Hide when composing")}</>}
</button>
{i.mayDelete && (
<button className="icon-btn sm danger" aria-label={t("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>
@@ -59,10 +59,10 @@ export function IdentitiesSettings() {
</div>
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("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}
{i.replyTo?.length ? <div className="hint">{t("Reply-To: {addresses}", { addresses: 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>
<button className="btn" onClick={() => setEditing({ name: "", email: identities[0]?.email ?? "", textSignature: "", htmlSignature: "", replyTo: null, bcc: null })}><Plus size={16} /> {t("Add identity")}</button>
<p className="hint mt-8">{t("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">
@@ -129,7 +129,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
<span className="hint">{t("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>}
{tooLong && <div className="warn-box mt-8">{t("This signature is larger than the server's {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.", { limit: SIGNATURE_LIMIT })}</div>}
</div>
</Dialog>
);
+2 -2
View File
@@ -38,8 +38,8 @@ export function LabelsSettings() {
</div>
</div>
))}
<button className="btn" onClick={() => void add()}><Plus size={16} /> New label</button>
<p className="hint mt-8">Tip: press <kbd className="kbd">l</kbd> on a conversation to apply labels. Search with <code>label:name</code>.</p>
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
<p className="hint mt-8">{t("Tip: press")} <kbd className="kbd">l</kbd> {t("on a conversation to apply labels. Search with")} <code>label:name</code>.</p>
</div>
);
}
+5 -5
View File
@@ -34,7 +34,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
{applyMailbox && (
<label className="check left" style={{ marginRight: "auto" }}>
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
<span>Also apply to existing messages in <b>{applyMailbox.name}</b></span>
<span>{translate("Also apply to existing messages in")} <b>{applyMailbox.name}</b></span>
</label>
)}
<button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" onClick={() => onSave(r, applyNow && Boolean(applyMailbox))} disabled={!r.name.trim()}>{saveLabel ?? "Done"}</button></>}>
@@ -87,7 +87,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
</div>
);
})}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> Add condition</button>
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, tests: [...r.tests, { type: "header", header: "subject", op: "contains", value: "" }] })}><Plus size={14} /> {translate("Add condition")}</button>
<div className="row" style={{ margin: "16px 0 8px" }}><span className="label">{translate("Then")}</span></div>
{r.actions.map((a, i) => (
@@ -141,12 +141,12 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
{!folders.some((f) => f.path === a.mailbox) && <option value={a.mailbox}>{a.mailbox}</option>}
<option value="__new__">{translate(" New folder…")}</option>
</select>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> {translate("keep copy")}</label>
</div>
) : a.type === "redirect" ? (
<div className="row">
<input className="input" type="email" placeholder={translate("[email protected]")} value={a.address} onChange={(e) => setAction(i, { ...a, address: e.target.value })} />
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> keep copy</label>
<label className="check nowrap"><input type="checkbox" checked={Boolean(a.copy)} onChange={(e) => setAction(i, { ...a, copy: e.target.checked })} /> {translate("keep copy")}</label>
</div>
) : a.type === "reject" ? (
<input className="input" placeholder={translate("Reason")} value={a.reason} onChange={(e) => setAction(i, { ...a, reason: e.target.value })} />
@@ -156,7 +156,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
<button className="icon-btn sm danger" aria-label={translate("Remove action")} onClick={() => setR({ ...r, actions: r.actions.filter((_, j) => j !== i) })} disabled={r.actions.length <= 1}><Trash2 size={16} /></button>
</div>
))}
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> Add action</button>
<button className="btn btn-ghost btn-sm" onClick={() => setR({ ...r, actions: [...r.actions, { type: "stop" }] })}><Plus size={14} /> {translate("Add action")}</button>
</Dialog>
);
}
+3 -3
View File
@@ -59,7 +59,7 @@ export function SecuritySettings() {
return (
<div>
<h1>{t("Security & sessions")}</h1>
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
<p className="lead">{t("You're signed in as")} <b>{session?.username}</b>{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}</p>
<h2>{t("Password")}</h2>
{unsupported ? (
@@ -303,9 +303,9 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
<p>{t("Copy it into")} <b>{issued.description}</b> {t("now — it isn't shown again.")}</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> Use your usual address as the username.</p>
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("Use your usual address as the username.")}</p>
</div>
)}
</Dialog>
+1 -1
View File
@@ -53,7 +53,7 @@ export function SettingsView({ section }: { section?: string }) {
<div className="settings-content">
{section && (
<button className="btn btn-ghost btn-sm" style={{ marginBottom: 8, marginLeft: -8 }} onClick={() => navigate("/settings")}>
<ArrowLeft size={16} /> All settings
<ArrowLeft size={16} /> {t("All settings")}
</button>
)}
<Suspense fallback={<Spinner />}>{current ? current.el : <GeneralSettings />}</Suspense>
+1 -1
View File
@@ -17,7 +17,7 @@ export function ShortcutsSettings() {
return (
<div>
<h1>{t("Keyboard shortcuts")}</h1>
<p className="lead">Gmail-style shortcuts are always on. Press <kbd className="kbd">?</kbd> anywhere to see this list.</p>
<p className="lead">{t("Gmail-style shortcuts are always on. Press")} <kbd className="kbd">?</kbd> {t("anywhere to see this list.")}</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
<div key={group}>
+2 -2
View File
@@ -20,11 +20,11 @@ export function TemplatesSettings() {
<h3>{t.name}</h3>
<button className="icon-btn sm danger" aria-label={translate("Delete template")} onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
</div>
{t.subject && <div className="hint">Subject: {t.subject}</div>}
{t.subject && <div className="hint">{translate("Subject: {subject}", { subject: t.subject })}</div>}
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
</div>
))}
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> {translate("New template")}</button>
{editing && (
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>{translate("Save")}</button></>}>
<div className="field-row">