Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
This commit is contained in:
@@ -248,8 +248,8 @@ function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={`Switch to ${label}`}
|
||||
title={`Switch to ${label}`}
|
||||
aria-label={t("Switch to {theme}", { theme: label })}
|
||||
title={t("Switch to {theme}", { theme: label })}
|
||||
onClick={() => update({ theme: next })}
|
||||
>
|
||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||
|
||||
@@ -40,10 +40,10 @@ export function LoginPage() {
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "invalid_credentials") {
|
||||
setError("Invalid username or password.");
|
||||
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
|
||||
else setError(err.message || "Could not sign in.");
|
||||
} else setError("Network error. Please check your connection.");
|
||||
setError(t("Invalid username or password."));
|
||||
} else if (err.code === "rate_limited") setError(t("Too many attempts. Please wait a few minutes and try again."));
|
||||
else setError(err.message || t("Could not sign in."));
|
||||
} else setError(t("Network error. Please check your connection."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export function LoginPage() {
|
||||
<label htmlFor="p">{t("Password")}</label>
|
||||
<div className="pw-wrap">
|
||||
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
|
||||
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
|
||||
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? t("Hide password") : t("Show password")} tabIndex={-1}>
|
||||
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
const { start, end, allDay } = ctx;
|
||||
return (
|
||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
||||
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
||||
<MenuItem icon={<Plus size={16} />} label={allDay ? t("New all-day event on {date}", { date: formatDayMonth(start) }) : t("New event at {time}", { time: formatTime(start) })} onClick={() => onCreate(start, end, allDay)} />
|
||||
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label={t("New all-day event")} onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
|
||||
<MenuSep />
|
||||
<MenuItem icon={<CalIcon size={16} />} label={t("Go to day")} onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
||||
@@ -77,16 +77,16 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? "Colour updated" : "Custom colour removed");
|
||||
const setColor = (color: string | null) => void patch({ color }, color ? t("Colour updated") : t("Custom colour removed"));
|
||||
const setCategory = (cat: { name: string; color: string } | null) => {
|
||||
const categoriesPatch = cat ? { [cat.name]: true } : null;
|
||||
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? `Categorised as ${cat.name}` : "Category cleared");
|
||||
void patch({ categories: categoriesPatch, color: cat ? cat.color : null }, cat ? t("Categorised as {name}", { name: cat.name }) : t("Category cleared"));
|
||||
};
|
||||
const duplicate = async () => {
|
||||
const { id: _i, baseEventId: _b, uid: _u, utcStart: _s, utcEnd: _e, isOrigin: _o, calendarIds, created: _c, updated: _up, sequence: _sq, recurrenceId: _ri, recurrenceIdTimeZone: _rt, ...rest } = ev as CalendarEvent & Record<string, unknown>;
|
||||
try {
|
||||
await cal.createEvent({ ...rest, title: `Copy of ${ev.title ?? "event"}`, participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
toast.success("Event duplicated");
|
||||
await cal.createEvent({ ...rest, title: t("Copy of {title}", { title: ev.title ?? t("event") }), participants: undefined, replyTo: undefined, organizerCalendarAddress: undefined } as Partial<CalendarEvent>, Object.keys(calendarIds)[0] ?? Object.keys(cal.calendars)[0]!, false);
|
||||
toast.success(t("Event duplicated"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
||||
let scope: EventScope | null = "series";
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else if (!(await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true }))) {
|
||||
} else if (!(await confirmDialog({ title: t("Delete this event?"), confirmLabel: t("Delete"), danger: true }))) {
|
||||
scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
|
||||
const data: Partial<Calendar> = { name: name.trim(), color, description: description || null, timeZone: tz || null, includeInAvailability: avail };
|
||||
if (calendar.id) await cal.updateCalendar(calendar.id, data);
|
||||
else await cal.createCalendar(data);
|
||||
toast.success("Calendar saved");
|
||||
toast.success(translate("Calendar saved"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -31,7 +31,7 @@ export function CalendarDialog({ calendar, onClose }: { calendar: Partial<Calend
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={calendar.id ? "Edit calendar" : "New calendar"} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
|
||||
<Dialog open onClose={onClose} title={calendar.id ? translate("Edit calendar") : translate("New calendar")} size="sm" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void save()}>{translate("Save")}</button></>}>
|
||||
<div className="field"><label>{translate("Name")}</label><input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>{translate("Color")}</label><ColorSwatches value={color} onChange={setColor} /></div>
|
||||
<div className="field"><label>{translate("Description")}</label><input className="input" value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { toast } from "@/ui/toast";
|
||||
import type { Calendar } from "@/jmap/types";
|
||||
import { CalendarDialog } from "./CalendarDialog";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
export function CalendarSidebar() {
|
||||
const [location, navigate] = useLocation();
|
||||
@@ -136,14 +136,14 @@ export function CalendarSidebar() {
|
||||
onClick={async () => {
|
||||
const who = Object.keys(menuCal.shareWith ?? {}).length;
|
||||
if (!(await confirmDialog({
|
||||
title: `Stop sharing “${menuCal.name}”?`,
|
||||
message: `${who === 1 ? "One person" : `${who} people`} will lose access. Events in it are not affected.`,
|
||||
confirmLabel: "Stop sharing",
|
||||
title: t("Stop sharing “{name}”?", { name: menuCal.name }),
|
||||
message: plural(who, { one: "{n} person will lose access. Events in it are not affected.", other: "{n} people will lose access. Events in it are not affected." }),
|
||||
confirmLabel: t("Stop sharing"),
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await cal.updateCalendar(menuCal.id, { shareWith: null });
|
||||
toast.success("No longer shared");
|
||||
toast.success(t("No longer shared"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export function CalendarSidebar() {
|
||||
)}
|
||||
<MenuItem icon={<Star size={16} />} label={t("Make default")} disabled={menuCal.isDefault} onClick={() => void cal.updateCalendar(menuCal.id, { isDefault: true } as Partial<Calendar>).catch((err) => toast.error((err as Error).message))} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuCal.name}”?`, message: "All events in this calendar will be deleted.", confirmLabel: "Delete", danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuCal.myRights.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuCal.name }), message: t("All events in this calendar will be deleted."), confirmLabel: t("Delete"), danger: true })) void cal.destroyCalendar(menuCal.id).catch((err) => toast.error((err as Error).message)); }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
@@ -173,11 +173,11 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
|
||||
const save = async () => {
|
||||
if (!calendarId) {
|
||||
toast.error("Choose a calendar");
|
||||
toast.error(translate("Choose a calendar"));
|
||||
return;
|
||||
}
|
||||
if (end <= start) {
|
||||
toast.error("End must be after start");
|
||||
toast.error(translate("End must be after start"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -259,7 +259,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
}, [start]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={editing ? "Edit event" : "New event"} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : editing ? "Save" : attendees.length && sendInvites ? "Send invites" : "Create"}</button></>}>
|
||||
<Dialog open onClose={onClose} title={editing ? translate("Edit event") : translate("New event")} size="lg" footer={<><button className="btn" onClick={onClose}>{translate("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? translate("Saving…") : editing ? translate("Save") : attendees.length && sendInvites ? translate("Send invites") : translate("Create")}</button></>}>
|
||||
<div className="event-form">
|
||||
{ev && isRecurring(ev) && (
|
||||
<div className="info-box mb-16">
|
||||
@@ -334,7 +334,7 @@ function EventForm({ init, base, scope, editing, onClose, settingsTz, defaultAle
|
||||
)}
|
||||
<div className="field-row">
|
||||
<div className="field"><label>{translate("Calendar")}</label>
|
||||
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? "An occurrence cannot be moved to another calendar on its own" : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
<select className="select" value={calendarId} disabled={oneDate} title={oneDate ? translate("An occurrence cannot be moved to another calendar on its own") : undefined} onChange={(e) => setCalendarId(e.target.value)}>
|
||||
{calendars.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
if (isRecurring(ev) && isOccurrence(ev)) {
|
||||
scope = await askDeleteScope(ev);
|
||||
} else {
|
||||
const ok = await confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
|
||||
const ok = await confirmDialog({ title: t("Delete this event?"), confirmLabel: t("Delete"), danger: true });
|
||||
if (!ok) scope = null;
|
||||
}
|
||||
if (!scope) return;
|
||||
@@ -56,7 +56,7 @@ export function EventPopover({ inst, anchor, onClose, onEdit }: { inst: EventIns
|
||||
setBusy(true);
|
||||
try {
|
||||
await cal.rsvp(ev, status);
|
||||
toast.success("Response sent");
|
||||
toast.success(t("Response sent"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { choiceDialog, confirmDialog } from "@/ui/dialog";
|
||||
import { isOccurrence, isRecurring, isThisAndFutureRefusal, type EventScope } from "@/store/calendar";
|
||||
import type { CalendarEvent } from "@/jmap/types";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Ask which of a series a change is meant for, when there is a choice.
|
||||
@@ -32,22 +33,22 @@ export async function askScope(
|
||||
/** The scope question for deleting. */
|
||||
export const askDeleteScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Delete this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Removes this date and leaves the rest of the series.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Deletes the whole series. This cannot be undone.",
|
||||
title: t("Delete this event?"),
|
||||
occurrenceLabel: t("This occurrence"),
|
||||
occurrenceHint: t("Removes this date and leaves the rest of the series."),
|
||||
seriesLabel: t("All occurrences"),
|
||||
seriesHint: t("Deletes the whole series. This cannot be undone."),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
/** The scope question for editing. */
|
||||
export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =>
|
||||
askScope(event, {
|
||||
title: "Change this event?",
|
||||
occurrenceLabel: "This occurrence",
|
||||
occurrenceHint: "Applies to this date only.",
|
||||
seriesLabel: "All occurrences",
|
||||
seriesHint: "Applies to every date in the series.",
|
||||
title: t("Change this event?"),
|
||||
occurrenceLabel: t("This occurrence"),
|
||||
occurrenceHint: t("Applies to this date only."),
|
||||
seriesLabel: t("All occurrences"),
|
||||
seriesHint: t("Applies to every date in the series."),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -57,7 +58,12 @@ export const askEditScope = (event: CalendarEvent): Promise<EventScope | null> =
|
||||
export function droppedMessage(dropped: string[]): string | null {
|
||||
if (!dropped.length) return null;
|
||||
const names = dropped.map((d) => d.replace(/^@/, "")).join(", ");
|
||||
return `Saved for this date. ${names} ${dropped.length === 1 ? "applies" : "apply"} to the whole series and was left unchanged.`;
|
||||
// One sentence per branch rather than a verb slot: which words agree with
|
||||
// the count, and where they sit, is not the same in every language.
|
||||
return plural(dropped.length, {
|
||||
one: "Saved for this date. {names} applies to the whole series and was left unchanged.",
|
||||
other: "Saved for this date. {names} apply to the whole series and were left unchanged.",
|
||||
}, { names });
|
||||
}
|
||||
|
||||
|
||||
@@ -80,9 +86,9 @@ export async function runScoped<T>(scope: EventScope, run: (scope: EventScope) =
|
||||
} catch (err) {
|
||||
if (scope !== "occurrence" || !isThisAndFutureRefusal(err)) throw err;
|
||||
const ok = await confirmDialog({
|
||||
title: "This date cannot be changed on its own",
|
||||
message: "It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?",
|
||||
confirmLabel: "Apply to series",
|
||||
title: t("This date cannot be changed on its own"),
|
||||
message: t("It belongs to a change that was applied to this and all later occurrences, which the server will only edit as a whole. Apply to the entire series instead?"),
|
||||
confirmLabel: t("Apply to series"),
|
||||
});
|
||||
return ok ? await run("series") : null;
|
||||
}
|
||||
|
||||
@@ -82,28 +82,28 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
const doSend = async () => {
|
||||
const all = [...d.to, ...d.cc, ...d.bcc];
|
||||
if (!all.length) {
|
||||
toast.error("Please add at least one recipient");
|
||||
toast.error(translate("Please add at least one recipient"));
|
||||
return;
|
||||
}
|
||||
const bad = all.filter((a) => !isValidEmail(a.email));
|
||||
if (bad.length) {
|
||||
toast.error(`Invalid address: ${bad[0]!.email}`);
|
||||
toast.error(translate("Invalid address: {address}", { address: bad[0]!.email }));
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => a.error)) {
|
||||
toast.error("Remove attachments that failed to upload first");
|
||||
toast.error(translate("Remove attachments that failed to upload first"));
|
||||
return;
|
||||
}
|
||||
if (d.attachments.some((a) => !a.blobId)) {
|
||||
toast.error("Attachments are still uploading");
|
||||
toast.error(translate("Attachments are still uploading"));
|
||||
return;
|
||||
}
|
||||
if (!d.subject.trim()) {
|
||||
const ok = await confirmDialog({ title: "Send without a subject?", confirmLabel: "Send anyway" });
|
||||
const ok = await confirmDialog({ title: translate("Send without a subject?"), confirmLabel: translate("Send anyway") });
|
||||
if (!ok) return;
|
||||
}
|
||||
if (settings.attachmentReminder && !d.attachments.length && /\b(attach(ed|ment|ing)?|enclosed|anbei|ci-joint|adjunto)\b/i.test(bodyText) ) {
|
||||
const ok = await confirmDialog({ title: "Did you forget the attachment?", message: "Your message mentions an attachment, but nothing is attached.", confirmLabel: "Send anyway" });
|
||||
const ok = await confirmDialog({ title: translate("Did you forget the attachment?"), message: translate("Your message mentions an attachment, but nothing is attached."), confirmLabel: translate("Send anyway") });
|
||||
if (!ok) return;
|
||||
}
|
||||
await send(key);
|
||||
@@ -169,7 +169,7 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<span className="title">{title}</span>
|
||||
<span className="status">{status}</span>
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={translate("Minimize")} title={translate("Minimize")} onClick={() => patch({ minimized: true })}><Minus size={16} /></button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? "Restore" : "Maximize"} title={d.maximized ? "Restore" : "Full screen"} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
{!isMobile && <button className="icon-btn sm" aria-label={d.maximized ? translate("Restore") : translate("Maximize")} title={d.maximized ? translate("Restore") : translate("Full screen")} onClick={() => patch({ maximized: !d.maximized })}>{d.maximized ? <Minimize2 size={16} /> : <Maximize2 size={16} />}</button>}
|
||||
<button className="icon-btn sm" aria-label={translate("Close")} title={translate("Save & close (Esc)")} onClick={() => void close(key)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="composer-body">
|
||||
@@ -251,14 +251,14 @@ 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)"}>
|
||||
<button className="btn btn-primary" onClick={() => void doSend()} disabled={d.sending} title={d.sendAt !== null ? translate("Hand to the server, held until {when} (Ctrl+Enter)", { when: formatScheduleTime(new Date(d.sendAt)) }) : translate("Send (Ctrl+Enter)")}>
|
||||
{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>
|
||||
<Popover anchor={sendMenu.anchor} onClose={sendMenu.close} side="top" width={280}>
|
||||
<MenuItem icon={<Send size={16} />} label={d.sendAt !== null ? "Send now instead" : "Send"} kbd={d.sendAt !== null ? undefined : "Ctrl+↵"} onClick={() => { if (d.sendAt !== null) patch({ sendAt: null }); sendMenu.close(); void doSend(); }} />
|
||||
<MenuItem icon={<Clock size={16} />} label={`Undo window: ${settings.undoSendSeconds}s`} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
<MenuItem icon={<Clock size={16} />} label={translate("Undo window: {seconds}s", { seconds: settings.undoSendSeconds })} onClick={() => updateSettings({ undoSendSeconds: settings.undoSendSeconds >= 30 ? 0 : settings.undoSendSeconds + 5 })} />
|
||||
{canSchedule && <ScheduleMenuItems maxMs={scheduleMax} onPick={scheduleFor} onCustom={() => { sendMenu.close(); setScheduleOpen(true); }} />}
|
||||
</Popover>
|
||||
{addressBookOpen && (
|
||||
@@ -299,13 +299,13 @@ export function Composer({ draft }: { draft: Draft }) {
|
||||
<MenuItem label={translate("Normal")} checked={d.priority === "normal"} onClick={() => patch({ priority: "normal" })} />
|
||||
<MenuItem label={translate("Low")} checked={d.priority === "low"} onClick={() => patch({ priority: "low" })} />
|
||||
<MenuSep />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: "Save as template", defaultValue: d.subject || "Template", placeholder: "Template name" }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<ChevronsDown size={16} />} label={translate("Save as template")} onClick={async () => { const name = await promptDialog({ title: translate("Save as template"), defaultValue: d.subject || translate("Template"), placeholder: translate("Template name") }); if (name) updateSettings({ templates: [...useSettings.getState().settings.templates, { id: `t${Date.now()}`, name, subject: d.subject, html: d.format === "html" ? d.html : textToHtml(d.text) }] }); }} />
|
||||
<MenuItem icon={<FileText size={16} />} label={translate("Save draft now")} onClick={() => void saveDraft(key)} />
|
||||
</Popover>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{totalSize > 20 * 1024 * 1024 && <span className="hint row gap-4" title={translate("Large attachments may be rejected by some servers")}><AlertTriangle size={14} /> {formatSize(totalSize)}</span>}
|
||||
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: "Discard this draft?", confirmLabel: "Discard", danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
<button className="icon-btn danger" title={translate("Discard draft")} aria-label={translate("Discard draft")} onClick={async () => { if (!d.dirty && !d.draftId) { void close(key, { discard: true }); return; } if (await confirmDialog({ title: translate("Discard this draft?"), confirmLabel: translate("Discard"), danger: true })) void close(key, { discard: true }); }}><Trash2 size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
||||
|
||||
const save = async () => {
|
||||
if (!bookId) {
|
||||
toast.error("Choose an address book");
|
||||
toast.error(t("Choose an address book"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -117,7 +117,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
||||
} else if (removePhoto) obj.media = null;
|
||||
if (isNew) {
|
||||
const id = await contacts.createCard(obj as Partial<ContactCard>, bookId);
|
||||
toast.success("Contact created");
|
||||
toast.success(t("Contact created"));
|
||||
onSaved(id);
|
||||
} else {
|
||||
const patch: Record<string, unknown> = { ...obj };
|
||||
@@ -125,7 +125,7 @@ export function ContactEditor({ card, defaultBookId, onClose, onSaved }: Props)
|
||||
if (curBook !== bookId) patch.addressBookIds = { [bookId]: true };
|
||||
if (!photo && !removePhoto) delete patch.media;
|
||||
await contacts.updateCard(card.id!, patch);
|
||||
toast.success("Contact saved");
|
||||
toast.success(t("Contact saved"));
|
||||
onSaved(card.id!);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Re-read the session so newly shared books appear without a sign-in.
|
||||
@@ -183,7 +183,7 @@ export function ContactsSidebar() {
|
||||
icon={<Pencil size={16} />}
|
||||
label={t("Rename")}
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "Rename address book", defaultValue: menuBook.name });
|
||||
const name = await promptDialog({ title: t("Rename address book"), defaultValue: menuBook.name });
|
||||
if (!name?.trim() || name === menuBook.name) return;
|
||||
try {
|
||||
await contacts.updateBook(menuBook.id, { name: name.trim() });
|
||||
@@ -203,14 +203,14 @@ export function ContactsSidebar() {
|
||||
onClick={async () => {
|
||||
const who = Object.keys(menuBook.shareWith ?? {}).length;
|
||||
if (!(await confirmDialog({
|
||||
title: `Stop sharing “${menuBook.name}”?`,
|
||||
message: `${who === 1 ? "One person" : `${who} people`} will lose access. The contacts in it are not affected.`,
|
||||
confirmLabel: "Stop sharing",
|
||||
title: t("Stop sharing “{name}”?", { name: menuBook.name }),
|
||||
message: plural(who, { one: "{n} person will lose access. The contacts in it are not affected.", other: "{n} people will lose access. The contacts in it are not affected." }),
|
||||
confirmLabel: t("Stop sharing"),
|
||||
danger: true,
|
||||
}))) return;
|
||||
try {
|
||||
await contacts.updateBook(menuBook.id, { shareWith: null });
|
||||
toast.success("No longer shared");
|
||||
toast.success(t("No longer shared"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -224,7 +224,7 @@ export function ContactsSidebar() {
|
||||
label={t("Delete")}
|
||||
disabled={menuBook.isDefault}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuBook.name}”?`, message: "The contacts in it go too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
if (!(await confirmDialog({ title: t("Delete “{name}”?", { name: menuBook.name }), message: t("The contacts in it go too."), confirmLabel: t("Delete"), danger: true }))) return;
|
||||
try {
|
||||
await contacts.destroyBook(menuBook.id);
|
||||
if (sel.bookId === menuBook.id) contacts.select({ accountId: null, bookId: "all" });
|
||||
|
||||
@@ -11,7 +11,7 @@ import { confirmDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { avatarColor } from "@/lib/address";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { plural, t as translate } from "@/lib/i18n";
|
||||
|
||||
export function ContactsView({ id }: { id?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
@@ -92,12 +92,12 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
const importFile = async (f: File) => {
|
||||
const book = bookId !== "all" ? contacts.books[bookId] : (books.find((b) => b.isDefault) ?? books[0]);
|
||||
if (!book) {
|
||||
toast.error("Create an address book first");
|
||||
toast.error(translate("Create an address book first"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = await contacts.importVCard(await f.text(), book.id);
|
||||
toast.success(`Imported ${n} contact${n === 1 ? "" : "s"}`);
|
||||
toast.success(plural(n, { one: "Imported {n} contact", other: "Imported {n} contacts" }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
</div>
|
||||
<div className="contacts-scroll">
|
||||
{contacts.loading && !contacts.loaded ? <Spinner label={translate("Loading contacts…")} /> : !list.length ? (
|
||||
<Empty icon={<Users size={36} />} title={q ? "No matches" : "No contacts yet"}>{q ? "Try another search." : "Add a contact or import a vCard file."}</Empty>
|
||||
<Empty icon={<Users size={36} />} title={q ? translate("No matches") : translate("No contacts yet")}>{q ? translate("Try another search.") : translate("Add a contact or import a vCard file.")}</Empty>
|
||||
) : groups.map((g) => (
|
||||
<div key={g.letter}>
|
||||
<div className="contact-letter">{g.letter}</div>
|
||||
@@ -168,7 +168,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
|
||||
<span className="spacer" />
|
||||
<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>
|
||||
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { await contacts.destroyCards([c.id]); toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
<div className="contact-hero">
|
||||
<span className="avatar xl" style={{ background: photo ? "transparent" : avatarColor(contactEmails(c)[0]?.email ?? name) }}>{photo ? <img src={photo} alt="" /> : c.kind === "group" ? <Users size={36} /> : name.slice(0, 1).toUpperCase()}</span>
|
||||
|
||||
@@ -242,7 +242,7 @@ export function FilesTree() {
|
||||
icon={<FolderPlus size={16} />}
|
||||
label={t("New folder")}
|
||||
onClick={async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name" });
|
||||
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useFiles.getState().mkdir(menuNode?.id ?? null, name.trim());
|
||||
@@ -276,11 +276,11 @@ export function FilesTree() {
|
||||
label={t("Delete")}
|
||||
disabled={!menuNode.myRights?.mayDelete}
|
||||
onClick={async () => {
|
||||
if (!(await confirmDialog({ title: `Delete “${menuNode.name}”?`, message: "Everything inside it goes too.", confirmLabel: "Delete", danger: true }))) return;
|
||||
if (!(await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), message: t("Everything inside it goes too."), confirmLabel: t("Delete"), danger: true }))) return;
|
||||
try {
|
||||
await useFiles.getState().destroy([menuNode.id]);
|
||||
if (currentId === menuNode.id) navigate("/files");
|
||||
toast.success("Deleted");
|
||||
toast.success(t("Deleted"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
</div>
|
||||
<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} /> {t("New folder")}</button>
|
||||
<button className="btn btn-sm" onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("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 }}>
|
||||
@@ -177,17 +177,17 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||
{!menuNode && (
|
||||
<>
|
||||
<MenuItem icon={<Upload size={16} />} label={t("Upload files…")} onClick={() => inputRef.current?.click()} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} 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); } } }} />
|
||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
{menuNode && (
|
||||
<>
|
||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : <MenuItem icon={<Download size={16} />} label={t("Download")} onClick={() => download(menuNode)} />}
|
||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: "Rename", defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
|
||||
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||
<MenuSep />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: `Delete “${menuNode.name}”?`, confirmLabel: "Delete", danger: true })) { try { await files.destroy([menuNode.id]); toast.success("Deleted"); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), confirmLabel: t("Delete"), danger: true })) { try { await files.destroy([menuNode.id]); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
@@ -207,7 +207,7 @@ function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void })
|
||||
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
|
||||
const path = files.pathTo(cur);
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Move “${node.name}”`} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success("Moved"); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
|
||||
<Dialog open onClose={onClose} title={t("Move “{name}”", { name: node.name })} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success(t("Moved")); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
|
||||
<div className="breadcrumb mb-8">
|
||||
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
||||
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
||||
|
||||
@@ -56,8 +56,8 @@ export function useAddressMenu() {
|
||||
label={t("Copy email address")}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(menu.address.email).then(
|
||||
() => toast.show("Address copied"),
|
||||
() => toast.error("Could not copy the address"),
|
||||
() => toast.show(t("Address copied")),
|
||||
() => toast.error(t("Could not copy the address")),
|
||||
);
|
||||
close();
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
import { plural, t, tNode } from "@/lib/i18n";
|
||||
|
||||
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
|
||||
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
|
||||
@@ -79,24 +79,30 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
|
||||
export async function saveAndApply(r: SieveRule, existing: SieveRule[], applyMailboxId: Id | null) {
|
||||
const sieve = useSieve.getState();
|
||||
const created = !existing.some((x) => x.id === r.id);
|
||||
const verb = created ? "created" : "saved";
|
||||
const saved = !created;
|
||||
try {
|
||||
await sieve.saveRules(upsertRule(existing, r));
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filter: ${(err as Error).message}`);
|
||||
toast.error(t("Could not save filter: {error}", { error: (err as Error).message }));
|
||||
return;
|
||||
}
|
||||
if (!applyMailboxId) {
|
||||
toast.success(`Filter ${verb} — it will run on new mail`);
|
||||
toast.success(saved ? t("Filter saved — it will run on new mail") : t("Filter created — it will run on new mail"));
|
||||
return;
|
||||
}
|
||||
const tid = toast.show("Applying filter to existing messages…", { duration: 0 });
|
||||
const tid = toast.show(t("Applying filter to existing messages…"), { duration: 0 });
|
||||
try {
|
||||
const res = await applyRuleToMailbox(r, applyMailboxId);
|
||||
toast.dismiss(tid);
|
||||
toast.success(`Filter ${verb} · applied to ${res.matched} of ${res.scanned} message${res.scanned === 1 ? "" : "s"}${res.skippedActions.length ? ` (skipped: ${res.skippedActions.join("; ")})` : ""}`, { duration: 8000 });
|
||||
toast.success(
|
||||
(saved ? t("Filter saved") : t("Filter created"))
|
||||
+ " · "
|
||||
+ plural(res.scanned, { one: "applied to {matched} of {n} message", other: "applied to {matched} of {n} messages" }, { matched: res.matched })
|
||||
+ (res.skippedActions.length ? " " + t("(skipped: {actions})", { actions: res.skippedActions.join("; ") }) : ""),
|
||||
{ duration: 8000 },
|
||||
);
|
||||
} catch (err) {
|
||||
toast.dismiss(tid);
|
||||
toast.error(`Filter saved, but applying it failed: ${(err as Error).message}`);
|
||||
toast.error(t("Filter saved, but applying it failed: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
if (!calId) throw new Error("No calendar available");
|
||||
const id = await cal.importEvent(ev, calId);
|
||||
setExisting(await cal.getEvent(id));
|
||||
toast.success("Added to your calendar");
|
||||
toast.success(t("Added to your calendar"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
@@ -116,7 +116,7 @@ export function InviteCard({ email, part }: { email: Email; part: EmailBodyPart
|
||||
)}
|
||||
{method === "CANCEL" && existing && (
|
||||
<div className="rsvp">
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success("Removed from calendar"); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
|
||||
<button className="btn btn-sm btn-danger" disabled={Boolean(busy)} onClick={async () => { try { await cal.destroyEvent(existing, false, "series"); setExisting(null); toast.success(t("Removed from calendar")); } catch (err) { toast.error((err as Error).message); } }}>{t("Remove from calendar")}</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{email.id}</span>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LabelPicker({ ids, anchor, onClose, onApplied }: { ids: Id[]; an
|
||||
<input
|
||||
className="input sm"
|
||||
autoFocus
|
||||
placeholder={labels.length ? "Search or create label" : "New label name"}
|
||||
placeholder={labels.length ? t("Search or create label") : t("New label name")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -58,7 +58,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isUnknownMailbox({ mailboxId, mailboxes, loaded: mailboxesLoaded, search }) || !inboxId) return;
|
||||
toast.show("That folder no longer exists. Showing your inbox instead.");
|
||||
toast.show(translate("That folder no longer exists. Showing your inbox instead."));
|
||||
navigate(`/mail/${inboxId}`, { replace: true });
|
||||
}, [search, mailboxId, mailboxesLoaded, mailboxes, inboxId, navigate]);
|
||||
|
||||
@@ -362,7 +362,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
)}
|
||||
{movePicker && (
|
||||
<MailboxPicker
|
||||
title={`Move ${movePicker.ids.length} message${movePicker.ids.length === 1 ? "" : "s"} to…`}
|
||||
title={plural(movePicker.ids.length, { one: "Move {n} message to…", other: "Move {n} messages to…" })}
|
||||
onClose={() => setMovePicker(null)}
|
||||
onPick={(mbId) => {
|
||||
setMovePicker(null);
|
||||
@@ -375,7 +375,7 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
|
||||
ids={labelPicker.ids}
|
||||
anchor={labelPicker.anchor}
|
||||
onClose={() => setLabelPicker(null)}
|
||||
onApplied={() => toast.show("Labels updated")}
|
||||
onApplied={() => toast.show(translate("Labels updated"))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||
import { haptic, useTouchRow } from "@/lib/touch";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
|
||||
const ROLE_ICONS: Record<string, ReactNode> = {
|
||||
@@ -105,11 +105,11 @@ export function MailboxTree() {
|
||||
}, [mailboxes, showHidden, expanded]);
|
||||
|
||||
const createFolder = async (parentId: Id | null) => {
|
||||
const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" });
|
||||
const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
await useMail.getState().createMailbox(name.trim(), parentId);
|
||||
toast.success(`Folder “${name.trim()}” created`);
|
||||
toast.success(t("Folder “{name}” created", { name: name.trim() }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -344,11 +344,11 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
}
|
||||
};
|
||||
const remove = async () => {
|
||||
const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true });
|
||||
const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: plural(m.totalEmails, { one: "This permanently deletes the folder and its {n} message.", other: "This permanently deletes the folder and its {n} messages." }), confirmLabel: t("Delete"), danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await useMail.getState().destroyMailbox(m.id, true);
|
||||
toast.success("Folder deleted");
|
||||
toast.success(t("Folder deleted"));
|
||||
navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
|
||||
@@ -251,8 +251,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
<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>
|
||||
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? "Not spam" : "Report spam (!)"} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<button className="icon-btn" title={isTrashOrJunk ? t("Delete forever") : t("Delete (#)")} onClick={() => void actions.trash()}><Trash2 size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={mailbox?.role === "junk" ? t("Not spam") : t("Report spam (!)")} onClick={() => void actions.spam()}>{mailbox?.role === "junk" ? <ShieldCheck size={19} /> : <AlertOctagon size={19} />}</button>
|
||||
<span className="tb-sep" />
|
||||
<button className="icon-btn" title={t("Mark as read (Shift+I)")} onClick={() => void actions.read(true)}><MailOpen size={19} /></button>
|
||||
<button className="icon-btn hide-mobile" title={t("Mark as unread (Shift+U)")} onClick={() => void actions.read(false)}><Mail size={19} /></button>
|
||||
@@ -383,8 +383,8 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
))}
|
||||
</div>
|
||||
) : ids.length === 0 && list && !list.loading ? (
|
||||
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? "No results" : mailbox?.role === "inbox" ? "You're all caught up" : "Nothing here"}>
|
||||
{isSearch ? "Try different keywords or filters." : mailbox?.role === "inbox" ? "No new mail in your inbox." : "This folder is empty."}
|
||||
<Empty icon={isSearch ? <Search size={40} /> : <Inbox size={40} />} title={isSearch ? t("No results") : mailbox?.role === "inbox" ? t("You're all caught up") : t("Nothing here")}>
|
||||
{isSearch ? t("Try different keywords or filters.") : mailbox?.role === "inbox" ? t("No new mail in your inbox.") : t("This folder is empty.")}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="mail-list-inner" style={{ height: virtualizer.getTotalSize() }}>
|
||||
@@ -417,7 +417,7 @@ export function MessageList({ title, list, openThreadId, focusId, setFocusId, on
|
||||
>
|
||||
<span className="msg-swipe-act">
|
||||
{SWIPE_ICON[strip.desc.icon]}
|
||||
<span>{strip.desc.label}</span>
|
||||
<span>{t(strip.desc.label)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -613,7 +613,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
const ghost = document.createElement("div");
|
||||
ghost.className = "drag-ghost";
|
||||
ghost.textContent = `${ids.length > 1 ? `${ids.length} conversations` : e.subject || "(no subject)"}`;
|
||||
ghost.textContent = ids.length > 1 ? plural(ids.length, { one: "{n} conversation", other: "{n} conversations" }) : e.subject || t("(no subject)");
|
||||
document.body.appendChild(ghost);
|
||||
ev.dataTransfer.setDragImage(ghost, 10, 10);
|
||||
setTimeout(() => ghost.remove(), 0);
|
||||
@@ -659,7 +659,7 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
</div>
|
||||
<div className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)" }}>{t("Draft")}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
<span className="msg-subject">{e.subject || t("(no subject)")}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
<button className={`msg-star ${starred ? "on" : ""}`} style={{ marginLeft: "auto" }} onClick={(ev) => { ev.stopPropagation(); onStar(e.id, !starred); }} aria-label={t("Star")}>
|
||||
<Star size={16} fill={starred ? "currentColor" : "none"} />
|
||||
@@ -676,17 +676,17 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
|
||||
<span className="msg-main">
|
||||
{isDrafts && <span style={{ color: "var(--danger)", flex: "0 0 auto" }}>{t("Draft")}</span>}
|
||||
{rowLabels.length > 0 && <span className="msg-labels">{rowLabels.map((l) => <span key={l.keyword} className="tag" style={{ background: l.color }}>{l.name}</span>)}</span>}
|
||||
<span className="msg-subject">{e.subject || "(no subject)"}</span>
|
||||
<span className="msg-subject">{e.subject || t("(no subject)")}</span>
|
||||
{showPreview && <span className="msg-preview">{latest.preview}</span>}
|
||||
</span>
|
||||
<span className="msg-meta">
|
||||
{(answered || forwarded) && <span className="msg-answered" title={answered ? "Replied" : "Forwarded"}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
|
||||
{(answered || forwarded) && <span className="msg-answered" title={answered ? t("Replied") : t("Forwarded")}>{answered ? <Reply size={14} /> : <Forward size={14} />}</span>}
|
||||
{hasAtt && <Paperclip size={14} className="msg-attach" />}
|
||||
<span className="msg-date">{formatListDate(latest.receivedAt)}</span>
|
||||
<span className="msg-actions">
|
||||
<button className="icon-btn sm" title={t("Archive")} onClick={(ev) => { ev.stopPropagation(); onArchive(e.id); }}><Archive size={16} /></button>
|
||||
<button className="icon-btn sm" title={t("Delete")} onClick={(ev) => { ev.stopPropagation(); onTrash(e.id); }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm" title={unread ? "Mark as read" : "Mark as unread"} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
||||
<button className="icon-btn sm" title={unread ? t("Mark as read") : t("Mark as unread")} onClick={(ev) => { ev.stopPropagation(); onRead(e.id, unread); }}>{unread ? <MailOpen size={16} /> : <Mail size={16} />}</button>
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
try {
|
||||
setSource(await client.fetchBlobText(accountId, e.blobId, "message/rfc822"));
|
||||
} catch (err) {
|
||||
setSource(`Could not load source: ${(err as Error).message}`);
|
||||
setSource(translate("Could not load source: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -130,7 +130,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
if (mailto) {
|
||||
const fields = draftFromMailto(mailto);
|
||||
useCompose.getState().open({ ...fields, subject: fields.subject || "unsubscribe", html: fields.html ?? "<div>unsubscribe</div>", text: fields.text ?? "unsubscribe" });
|
||||
toast.show("Unsubscribe message prepared — just hit Send");
|
||||
toast.show(translate("Unsubscribe message prepared — just hit Send"));
|
||||
} else if (http) {
|
||||
window.open(http, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
@@ -212,7 +212,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
{e.cc?.length ? <><dt>{translate("Cc")}</dt><dd><AddressList list={e.cc} onContext={addrMenu.open} /></dd></> : null}
|
||||
{e.bcc?.length ? <><dt>{translate("Bcc")}</dt><dd><AddressList list={e.bcc} onContext={addrMenu.open} /></dd></> : null}
|
||||
<dt>{translate("Date")}</dt><dd>{formatFullDate(e.sentAt ?? e.receivedAt)}</dd>
|
||||
<dt>{translate("Subject")}</dt><dd>{e.subject || "(no subject)"}</dd>
|
||||
<dt>{translate("Subject")}</dt><dd>{e.subject || translate("(no subject)")}</dd>
|
||||
{e.messageId?.[0] && <><dt>{translate("Message-ID")}</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
|
||||
@@ -235,10 +235,10 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
setReceiptDone("sending");
|
||||
try {
|
||||
await sendReadReceipt(e);
|
||||
toast.success("Read receipt sent");
|
||||
toast.success(translate("Read receipt sent"));
|
||||
} catch (err) {
|
||||
setReceiptDone(null);
|
||||
toast.error(`Could not send the receipt: ${(err as Error).message}`);
|
||||
toast.error(translate("Could not send the receipt: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -255,9 +255,9 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancelScheduled(e.id);
|
||||
toast.success("Send cancelled — the message is back in Drafts");
|
||||
toast.success(translate("Send cancelled — the message is back in Drafts"));
|
||||
} catch (err) {
|
||||
toast.error(`Could not cancel: ${(err as Error).message}`);
|
||||
toast.error(translate("Could not cancel: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -452,9 +452,9 @@ function HtmlBody({ html, bodyStyle, themed, onShowImages }: { html: string; bod
|
||||
rewrite what someone actually wrote. */}
|
||||
<div ref={hostRef} className="body-host notranslate" translate="no" />
|
||||
{hasQuote && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? "Hide quoted text" : "Show quoted text"}>
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)} title={quoteOpen ? translate("Hide quoted text") : translate("Show quoted text")}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
{quoteOpen ? translate("Hide quoted text") : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -496,7 +496,7 @@ function TextBody({ text }: { text: string }) {
|
||||
{quoted && (
|
||||
<button className="quote-toggle" onClick={() => setQuoteOpen((v) => !v)}>
|
||||
{quoteOpen ? <ChevronUp size={12} /> : <span style={{ letterSpacing: 2 }}>{translate("•••")}</span>}
|
||||
{quoteOpen ? "Hide quoted text" : ""}
|
||||
{quoteOpen ? translate("Hide quoted text") : ""}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -549,7 +549,7 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
||||
</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} /> {translate("Download")}</a>}>
|
||||
<Dialog open={Boolean(preview)} onClose={() => setPreview(null)} title={preview?.name ?? translate("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)} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { client } from "@/jmap/client";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
|
||||
export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||
const contacts = useContacts();
|
||||
@@ -19,7 +19,7 @@ export function VCardCard({ part, accountId }: { part: EmailBodyPart; accountId:
|
||||
if (!book) throw new Error("No address book available");
|
||||
const n = await contacts.importVCard(text, book.id);
|
||||
setDone(true);
|
||||
toast.success(`Added ${n} contact${n === 1 ? "" : "s"}`);
|
||||
toast.success(plural(n, { one: "Added {n} contact", other: "Added {n} contacts" }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function CalendarSettings() {
|
||||
<div className="card-head">
|
||||
<span className="label-dot" style={{ background: c.color, width: 14, height: 14 }} />
|
||||
<h3>{c.name}</h3>
|
||||
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: "Rename category", defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}>✎</button>
|
||||
<button className="icon-btn sm" title={t("Rename")} onClick={async () => { const n = await promptDialog({ title: t("Rename category"), defaultValue: c.name }); if (n?.trim()) update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, name: n.trim() } : x)) }); }}>✎</button>
|
||||
<button className="icon-btn sm danger" aria-label={t("Delete category")} onClick={() => update({ eventCategories: s.eventCategories.filter((_, j) => j !== i) })}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}><ColorSwatches value={c.color} onChange={(col) => update({ eventCategories: s.eventCategories.map((x, j) => (j === i ? { ...x, color: col } : x)) })} /></div>
|
||||
|
||||
@@ -73,9 +73,9 @@ function RulesEditor() {
|
||||
try {
|
||||
await sieve.saveRules(next);
|
||||
setLocal(null);
|
||||
toast.success("Filters saved");
|
||||
toast.success(t("Filters saved"));
|
||||
} catch (err) {
|
||||
toast.error(`Could not save filters: ${(err as Error).message}`);
|
||||
toast.error(t("Could not save filters: {error}", { error: (err as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -203,7 +203,7 @@ function ScriptsEditor() {
|
||||
|
||||
const save = async (activate: boolean) => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Script name is required");
|
||||
toast.error(t("Script name is required"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -211,11 +211,11 @@ function ScriptsEditor() {
|
||||
const err = await sieve.validate(content);
|
||||
setValidation(err);
|
||||
if (err) {
|
||||
toast.error("Script has errors");
|
||||
toast.error(t("Script has errors"));
|
||||
return;
|
||||
}
|
||||
await sieve.saveScript(sel?.id ?? null, name.trim(), content, activate);
|
||||
toast.success("Script saved");
|
||||
toast.success(t("Script saved"));
|
||||
setSel(null);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -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} /> {t("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(t("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>
|
||||
@@ -254,8 +254,8 @@ function ScriptsEditor() {
|
||||
<div className="card-head">
|
||||
<h3><span>{s.name} </span>{s.isActive && <span className="tag" style={{ background: "var(--success)" }}>{t("active")}</span>}</h3>
|
||||
<button className="btn btn-sm" onClick={() => void open(s)}>{t("Edit")}</button>
|
||||
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? "Deactivate" : "Activate"}</button>
|
||||
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: `Delete script “${s.name}”?`, confirmLabel: "Delete", danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
<button className="btn btn-sm" onClick={async () => { try { await sieve.activate(s.isActive ? null : s.id); } catch (err) { toast.error((err as Error).message); } }}><Power size={14} /> {s.isActive ? t("Deactivate") : t("Activate")}</button>
|
||||
<button className="icon-btn sm danger" aria-label={t("Delete script")} onClick={async () => { if (await confirmDialog({ title: t("Delete script “{name}”?", { name: s.name }), confirmLabel: t("Delete"), danger: true })) { try { await sieve.destroy(s.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from "@/ui/toast";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { ShareDialog } from "./ShareDialog";
|
||||
import type { Mailbox } from "@/jmap/types";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
|
||||
export function FoldersSettings() {
|
||||
@@ -18,7 +18,7 @@ export function FoldersSettings() {
|
||||
const q = quotas.find((x) => x.resourceType === "octets");
|
||||
|
||||
const create = async () => {
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for subfolders, e.g. Work/Invoices)" });
|
||||
const name = await promptDialog({ title: t("New folder"), placeholder: t("Folder name (use / for subfolders, e.g. Work/Invoices)") });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const parts = name.split("/").map((p) => p.trim()).filter(Boolean);
|
||||
@@ -27,7 +27,7 @@ export function FoldersSettings() {
|
||||
const existing = Object.values(useMail.getState().mailboxes).find((m) => (m.parentId ?? null) === parentId && m.name.toLowerCase() === part.toLowerCase());
|
||||
parentId = existing ? existing.id : await useMail.getState().createMailbox(part, parentId);
|
||||
}
|
||||
toast.success("Folder created");
|
||||
toast.success(t("Folder created"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -51,9 +51,9 @@ export function FoldersSettings() {
|
||||
<button className="icon-btn sm" title={t("Rename")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { const n = await // The server's own name, never the localised one: this box writes
|
||||
// back whatever it is prefilled with.
|
||||
promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (n?.trim() && n !== m.name) { try { await useMail.getState().updateMailbox(m.id, { name: n.trim() }); } catch (err) { toast.error((err as Error).message); } } }}><Pencil size={16} /></button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? "Hide" : "Show"} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
<button className="icon-btn sm" title={m.isSubscribed ? t("Hide") : t("Show")} disabled={m.role === "inbox"} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })}>{m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />}</button>
|
||||
{Object.keys(m.shareWith ?? {}).length > 0 && <button className="icon-btn sm" title={t("Stop sharing")} onClick={() => setShare(m)}><Share2 size={16} /></button>}
|
||||
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: `Delete “${m.name}”?`, message: `${m.totalEmails} message(s) will be permanently deleted.`, confirmLabel: "Delete", danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
<button className="icon-btn sm danger" title={t("Delete")} disabled={Boolean(m.role) && m.role !== "subscribed"} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: m.name }), message: plural(m.totalEmails, { one: "{n} message will be permanently deleted.", other: "{n} messages will be permanently deleted." }), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyMailbox(m.id, true); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -185,7 +185,7 @@ export function GeneralSettings() {
|
||||
{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>
|
||||
<button className="btn btn-ghost" onClick={() => { reset(); toast.show(t("Settings reset to defaults")); }}>{t("Reset to defaults")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -204,16 +204,16 @@ function MailHandlerSettings() {
|
||||
try {
|
||||
registerMailtoHandler();
|
||||
setRequested(true);
|
||||
toast.success("Your browser will ask whether to open mail links in ihasmail");
|
||||
toast.success(t("Your browser will ask whether to open mail links in ihasmail"));
|
||||
} catch (err) {
|
||||
toast.error(`Your browser refused the request: ${(err as Error).message}`);
|
||||
toast.error(t("Your browser refused the request: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
unregisterMailtoHandler();
|
||||
setRequested(false);
|
||||
toast.show("Removed. Mail links will open in whatever your browser falls back to.");
|
||||
toast.show(t("Removed. Mail links will open in whatever your browser falls back to."));
|
||||
};
|
||||
|
||||
if (support === "unsupported") {
|
||||
|
||||
@@ -48,13 +48,13 @@ export function IdentitiesSettings() {
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={isAlwaysVisible(i.id, [defaultId])}
|
||||
title={isAlwaysVisible(i.id, [defaultId]) ? "The default identity is always offered when composing" : hidden.includes(i.id) ? "Show this in the compose picker" : "Hide this from the compose picker"}
|
||||
title={isAlwaysVisible(i.id, [defaultId]) ? t("The default identity is always offered when composing") : hidden.includes(i.id) ? t("Show this in the compose picker") : t("Hide this from the compose picker")}
|
||||
onClick={(e) => { e.stopPropagation(); toggleHidden(i.id); }}
|
||||
>
|
||||
{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>
|
||||
<button className="icon-btn sm danger" aria-label={t("Delete identity")} onClick={async (e) => { e.stopPropagation(); if (await confirmDialog({ title: t("Delete this identity?"), confirmLabel: t("Delete"), danger: true })) { try { await useMail.getState().destroyIdentity(i.id); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={16} /></button>
|
||||
)}
|
||||
</div>
|
||||
{hidden.includes(i.id) && <div className="hint" style={{ marginTop: 4 }}>{t("Not offered when composing. It still receives mail, and you can still send from it by showing it again.")}</div>}
|
||||
@@ -105,7 +105,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
|
||||
};
|
||||
if (!identity.id) patch.email = email.trim();
|
||||
await useMail.getState().saveIdentity(identity.id ?? null, patch);
|
||||
toast.success("Identity saved");
|
||||
toast.success(t("Identity saved"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
@@ -114,7 +114,7 @@ function IdentityDialog({ identity, onClose }: { identity: Partial<Identity>; on
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={identity.id ? "Edit identity" : "New identity"} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? "Saving…" : "Save"}</button></>}>
|
||||
<Dialog open onClose={onClose} title={identity.id ? t("Edit identity") : t("New identity")} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? t("Saving…") : t("Save")}</button></>}>
|
||||
<div className="field-row">
|
||||
<div className="field"><label>{t("Display name")}</label><input className="input" value={name} onChange={(e) => setName(e.target.value)} /></div>
|
||||
<div className="field"><label>{t("Email address")}</label><input className="input" type="email" value={email} disabled={Boolean(identity.id)} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function LabelsSettings() {
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
const name = await promptDialog({ title: "New label", placeholder: "Label name" });
|
||||
const name = await promptDialog({ title: t("New label"), placeholder: t("Label name") });
|
||||
if (!name?.trim()) return;
|
||||
const keyword = name.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || `label${Date.now()}`;
|
||||
if (labels.some((l) => l.keyword === keyword)) return;
|
||||
|
||||
@@ -30,7 +30,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
|
||||
const setAction = (i: number, a: SieveAction) => setR({ ...r, actions: r.actions.map((x, j) => (j === i ? a : x)) });
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? "New rule" : "Edit rule")} size="lg" footer={<>
|
||||
<Dialog open onClose={onClose} title={title ?? (rule.name === "New filter" ? translate("New rule") : translate("Edit rule"))} size="lg" footer={<>
|
||||
{applyMailbox && (
|
||||
<label className="check left" style={{ marginRight: "auto" }}>
|
||||
<input type="checkbox" checked={applyNow} onChange={(e) => setApplyNow(e.target.checked)} />
|
||||
@@ -116,7 +116,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
|
||||
const v = e.target.value;
|
||||
if (v === "__new__") {
|
||||
// Create a folder on the fly ("Parent/Child" creates nested folders).
|
||||
const name = await promptDialog({ title: "New folder", placeholder: "Folder name (use / for a subfolder, e.g. Work/Invoices)" });
|
||||
const name = await promptDialog({ title: translate("New folder"), placeholder: translate("Folder name (use / for a subfolder, e.g. Work/Invoices)") });
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
const mail = useMail.getState();
|
||||
@@ -128,7 +128,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
|
||||
}
|
||||
const path = useMail.getState().mailboxPath(parentId!);
|
||||
setAction(i, { ...a, mailbox: path, mailboxId: parentId! });
|
||||
toast.success(`Folder “${path}” created`);
|
||||
toast.success(translate("Folder “{name}” created", { name: path }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSession } from "@/store/session";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||
import { t, tNode } from "@/lib/i18n";
|
||||
import { plural, t, tNode } from "@/lib/i18n";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
@@ -100,7 +100,7 @@ export function SecuritySettings() {
|
||||
</table>
|
||||
)}
|
||||
<div className="row mt-16">
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>{t("Sign out all other sessions")}</button>
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: t("Sign out other sessions?"), confirmLabel: t("Sign out others") })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(plural(r.revoked, { one: "Signed out {n} other session", other: "Signed out {n} other sessions" })); void load(); } }}>{t("Sign out all other sessions")}</button>
|
||||
<button className="btn btn-ghost" onClick={() => void logout()}>{t("Sign out here")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,7 +119,7 @@ function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChange
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (next !== confirm) {
|
||||
toast.error("The new passwords don't match");
|
||||
toast.error(t("The new passwords don't match"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -188,7 +188,7 @@ function TwoFactorOff({ reload }: { reload: () => Promise<void> }) {
|
||||
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
|
||||
setDisabling(false);
|
||||
await reload();
|
||||
toast.success("Two-factor authentication is off");
|
||||
toast.success(t("Two-factor authentication is off"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
@@ -256,16 +256,16 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
|
||||
|
||||
const revoke = async (row: AppPasswordRow) => {
|
||||
const ok = await confirmDialog({
|
||||
title: `Revoke "${row.description}"?`,
|
||||
message: "Anything signed in with this password stops working immediately.",
|
||||
confirmLabel: "Revoke",
|
||||
title: t("Revoke “{name}”?", { name: row.description }),
|
||||
message: t("Anything signed in with this password stops working immediately."),
|
||||
confirmLabel: t("Revoke"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) });
|
||||
await reload();
|
||||
toast.success("App password revoked");
|
||||
toast.success(t("App password revoked"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
@@ -321,7 +321,7 @@ function CopyableSecret({ value }: { value: string }) {
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
title={t("Copy")}
|
||||
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
|
||||
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success(t("Copied")), () => toast.error(t("Could not copy")))}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
|
||||
const err = res.notUpdated?.[id];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
toast.success("Sharing updated");
|
||||
toast.success(t("Sharing updated"));
|
||||
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
|
||||
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
|
||||
if (kind === "AddressBook") void useContacts.getState().loadBooks();
|
||||
@@ -104,7 +104,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
|
||||
<Dialog open onClose={onClose} title={t("Share “{name}”", { name })} size="lg" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{t("Save")}</button></>}>
|
||||
{/* The list of who it is shared with is rendered whether or not anybody
|
||||
can be *added*. It used to sit inside the branch below, so a server
|
||||
with directory queries switched off -- which is the default, and which
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TemplatesSettings() {
|
||||
))}
|
||||
<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></>}>
|
||||
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? translate("Edit template") : translate("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">
|
||||
<div className="field"><label>{translate("Name")}</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
|
||||
<div className="field"><label>{translate("Subject (optional)")}</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
||||
|
||||
Reference in New Issue
Block a user