Merge pull request #148 from Coffey-Labs/i18n-extract-batch2
Finish extraction: 100%, and a coverage number worth believing
This commit is contained in:
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"><{from.email}></span>}
|
||||
{/* An address, not a sentence. */}
|
||||
{expanded && from && <span className="email addr notranslate" translate="no"><{from.email}></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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user