Finish extraction: 100%, and a coverage number worth believing

The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
This commit is contained in:
2026-08-31 10:41:24 -07:00
parent 46c1dc28e3
commit 3f4b33cb51
40 changed files with 228 additions and 123 deletions
@@ -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">