diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts index 2bda4b2..b820fcf 100644 --- a/web/src/lib/schedule.ts +++ b/web/src/lib/schedule.ts @@ -13,6 +13,7 @@ */ import { addDays, startOfDay } from "./dates"; import { formatFullDateTime } from "./datetime"; +import { plural, t } from "@/lib/i18n"; export const SUBMISSION_CAP = "urn:ietf:params:jmap:submission"; @@ -96,21 +97,27 @@ export function schedulePresets(now: Date, maxMs: number): SchedulePreset[] { * surfaces as a failed send rather than anything the user can act on. */ export function scheduleError(at: Date, now: Date, maxMs: number): string | null { - const t = at.getTime(); - if (Number.isNaN(t)) return "Pick a date and time."; - if (t < now.getTime() + MIN_LEAD_MS) return "Pick a time at least a minute from now."; - if (maxMs > 0 && t > now.getTime() + maxMs) { - return `This server will not hold a message longer than ${describeSpan(maxMs)}.`; + const ms = at.getTime(); + if (Number.isNaN(ms)) return t("Pick a date and time."); + if (ms < now.getTime() + MIN_LEAD_MS) return t("Pick a time at least a minute from now."); + if (maxMs > 0 && ms > now.getTime() + maxMs) { + return t("This server will not hold a message longer than {span}.", { span: describeSpan(maxMs) }); } return null; } -/** "30 days", "7 days", "12 hours" -- for explaining the server's own limit. */ +/** + * "30 days", "7 days", "12 hours" -- for explaining the server's own limit. + * + * plural() rather than `day${n === 1 ? "" : "s"}`: that suffix trick is English + * grammar written into the code, and it produces "2 Tage" only by accident of + * the two languages agreeing. Russian needs three forms and Japanese one. + */ export function describeSpan(ms: number): string { const days = Math.floor(ms / 86_400_000); - if (days >= 1) return `${days} day${days === 1 ? "" : "s"}`; + if (days >= 1) return plural(days, { one: "{n} day", other: "{n} days" }); const hours = Math.max(1, Math.floor(ms / 3_600_000)); - return `${hours} hour${hours === 1 ? "" : "s"}`; + return plural(hours, { one: "{n} hour", other: "{n} hours" }); } /** How a scheduled time reads in menus, banners and toasts. */ diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index cef0d19..5688713 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -466,7 +466,7 @@ export const useCompose = create((set, get) => ({ onProgress: (loaded, total) => patchAtt(key, a.id, { progress: Math.round((loaded / total) * 100) }, set), }) .then((res) => patchAtt(key, a.id, { blobId: res.blobId, progress: 100, type: res.type || a.type, size: res.size }, set)) - .catch((err) => patchAtt(key, a.id, { error: (err as Error).message || "Upload failed" }, set)); + .catch((err) => patchAtt(key, a.id, { error: (err as Error).message || translate("Upload failed") }, set)); } }, @@ -524,7 +524,7 @@ export const useCompose = create((set, get) => ({ const up = await client.upload(accountId, blob, { type: a.type }); patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set); } catch (err) { - patchAtt(key, a.id, { error: (err as Error).message || "Could not attach" }, set); + patchAtt(key, a.id, { error: (err as Error).message || translate("Could not attach") }, set); } } }, @@ -568,7 +568,7 @@ export const useCompose = create((set, get) => ({ toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent")); } catch (err) { toast.error(translate("Send failed: {error}", { error: (err as Error).message }), { - action: { label: "Open draft", onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) }, + action: { label: translate("Open draft"), onClick: () => set((s) => ({ drafts: [...s.drafts, { ...d, sending: false, error: (err as Error).message }], activeKey: d.key })) }, duration: 15000, }); } @@ -580,7 +580,7 @@ export const useCompose = create((set, get) => ({ await doSend(); return; } - const toastId = toast.show("Sending…", { duration: delay * 1000, progress: true, action: { label: "Undo", onClick: () => get().undoSend(key) } }); + const toastId = toast.show(translate("Sending…"), { duration: delay * 1000, progress: true, action: { label: translate("Undo"), onClick: () => get().undoSend(key) } }); const timer = window.setTimeout(() => void doSend(), delay * 1000); set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } })); }, @@ -689,7 +689,7 @@ export async function buildEmailObject(d: Draft, opts: { forSend: boolean; mailb const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; - if (!ident) throw new Error("No sending identity available"); + if (!ident) throw new Error(translate("No sending identity available")); const from: EmailAddress = { name: ident.name || null, email: ident.email }; let html = d.format === "html" ? d.html : ""; @@ -869,15 +869,15 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise { const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; - if (!ident) throw new Error("No sending identity available"); - if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading"); + if (!ident) throw new Error(translate("No sending identity available")); + if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error(translate("Attachments are still uploading")); const scheduled = d.sendAt !== null && d.sendAt > Date.now(); const scheduledId = scheduled ? await ensureScheduledMailbox() : null; const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId }); const sentId = mail.roleId("sent"); const draftsId = mail.roleId("drafts"); const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email })); - if (!rcpts.length) throw new Error("No recipients"); + if (!rcpts.length) throw new Error(translate("No recipients")); const sub = buildSubmission({ identityId: ident.id, fromEmail: ident.email, diff --git a/web/src/views/compose/SchedulePicker.tsx b/web/src/views/compose/SchedulePicker.tsx index 8f88f6d..7846a29 100644 --- a/web/src/views/compose/SchedulePicker.tsx +++ b/web/src/views/compose/SchedulePicker.tsx @@ -18,7 +18,7 @@ export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number; {t("Schedule send")} {presets.map((p) => ( - } label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} /> + } label={t(p.label)} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} /> ))} } label={t("Pick date and time…")} onClick={onCustom} /> diff --git a/web/src/views/mail/MessageView.tsx b/web/src/views/mail/MessageView.tsx index 158c920..9d394d4 100644 --- a/web/src/views/mail/MessageView.tsx +++ b/web/src/views/mail/MessageView.tsx @@ -337,7 +337,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn {e["header:List-Id:asText"] && <>
{translate("List")}
{e["header:List-Id:asText"]}
}
{translate("Size")}
{formatSize(e.size)}
{spam && <>
{translate("Spam filter")}
} - {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}
} + {receiptRequested && <>
{translate("Receipt")}
{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}
} )} {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (