Merge pull request #259 from Coffey-Labs/i18n-third-pass

Third pass: the strings libraries build and views render raw
This commit is contained in:
Coffey Labs
2026-09-03 14:01:53 -07:00
committed by GitHub
4 changed files with 25 additions and 18 deletions
+15 -8
View File
@@ -13,6 +13,7 @@
*/ */
import { addDays, startOfDay } from "./dates"; import { addDays, startOfDay } from "./dates";
import { formatFullDateTime } from "./datetime"; import { formatFullDateTime } from "./datetime";
import { plural, t } from "@/lib/i18n";
export const SUBMISSION_CAP = "urn:ietf:params:jmap:submission"; 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. * surfaces as a failed send rather than anything the user can act on.
*/ */
export function scheduleError(at: Date, now: Date, maxMs: number): string | null { export function scheduleError(at: Date, now: Date, maxMs: number): string | null {
const t = at.getTime(); const ms = at.getTime();
if (Number.isNaN(t)) return "Pick a date and time."; if (Number.isNaN(ms)) return t("Pick a date and time.");
if (t < now.getTime() + MIN_LEAD_MS) return "Pick a time at least a minute from now."; if (ms < now.getTime() + MIN_LEAD_MS) return t("Pick a time at least a minute from now.");
if (maxMs > 0 && t > now.getTime() + maxMs) { if (maxMs > 0 && ms > now.getTime() + maxMs) {
return `This server will not hold a message longer than ${describeSpan(maxMs)}.`; return t("This server will not hold a message longer than {span}.", { span: describeSpan(maxMs) });
} }
return null; 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 { export function describeSpan(ms: number): string {
const days = Math.floor(ms / 86_400_000); 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)); 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. */ /** How a scheduled time reads in menus, banners and toasts. */
+8 -8
View File
@@ -466,7 +466,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
onProgress: (loaded, total) => patchAtt(key, a.id, { progress: Math.round((loaded / total) * 100) }, set), 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)) .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<ComposeState>((set, get) => ({
const up = await client.upload(accountId, blob, { type: a.type }); 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); patchAtt(key, a.id, { blobId: up.blobId, progress: 100, size: up.size || a.size }, set);
} catch (err) { } 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<ComposeState>((set, get) => ({
toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent")); toast.success(scheduling ? translate("Send scheduled for {when}", { when: formatScheduleTime(new Date(d.sendAt!)) }) : translate("Message sent"));
} catch (err) { } catch (err) {
toast.error(translate("Send failed: {error}", { error: (err as Error).message }), { 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, duration: 15000,
}); });
} }
@@ -580,7 +580,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
await doSend(); await doSend();
return; 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); const timer = window.setTimeout(() => void doSend(), delay * 1000);
set((s) => ({ pendingSends: { ...s.pendingSends, [key]: { timer, toastId, draft: d } } })); 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 mail = useMail.getState();
const accountId = mail.accountId!; const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; 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 }; const from: EmailAddress = { name: ident.name || null, email: ident.email };
let html = d.format === "html" ? d.html : ""; let html = d.format === "html" ? d.html : "";
@@ -869,15 +869,15 @@ async function sendInternal(d: Draft, _get: () => ComposeState): Promise<void> {
const mail = useMail.getState(); const mail = useMail.getState();
const accountId = mail.accountId!; const accountId = mail.accountId!;
const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; 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"));
if (d.attachments.some((a) => !a.blobId && !a.error)) throw new Error("Attachments are still uploading"); 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 scheduled = d.sendAt !== null && d.sendAt > Date.now();
const scheduledId = scheduled ? await ensureScheduledMailbox() : null; const scheduledId = scheduled ? await ensureScheduledMailbox() : null;
const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId }); const email = await buildEmailObject(d, { forSend: true, mailboxId: scheduledId });
const sentId = mail.roleId("sent"); const sentId = mail.roleId("sent");
const draftsId = mail.roleId("drafts"); const draftsId = mail.roleId("drafts");
const rcpts = uniqueAddresses([...d.to, ...d.cc, ...d.bcc]).map((a) => ({ email: a.email })); 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({ const sub = buildSubmission({
identityId: ident.id, identityId: ident.id,
fromEmail: ident.email, fromEmail: ident.email,
+1 -1
View File
@@ -18,7 +18,7 @@ export function ScheduleMenuItems({ maxMs, onPick, onCustom }: { maxMs: number;
<MenuSep /> <MenuSep />
<MenuTitle>{t("Schedule send")}</MenuTitle> <MenuTitle>{t("Schedule send")}</MenuTitle>
{presets.map((p) => ( {presets.map((p) => (
<MenuItem key={p.id} icon={<Clock size={16} />} label={p.label} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} /> <MenuItem key={p.id} icon={<Clock size={16} />} label={t(p.label)} kbd={formatScheduleTime(p.at)} onClick={() => onPick(p.at)} />
))} ))}
<MenuItem icon={<Clock size={16} />} label={t("Pick date and time…")} onClick={onCustom} /> <MenuItem icon={<Clock size={16} />} label={t("Pick date and time…")} onClick={onCustom} />
</> </>
+1 -1
View File
@@ -337,7 +337,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{e["header:List-Id:asText"] && <><dt>{translate("List")}</dt><dd>{e["header:List-Id:asText"]}</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> <dt>{translate("Size")}</dt><dd>{formatSize(e.size)}</dd>
{spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>} {spam && <><dt>{translate("Spam filter")}</dt><dd><SpamSummary report={spam} /></dd></>}
{receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>} {receiptRequested && <><dt>{translate("Receipt")}</dt><dd>{receipt.offer ? translate("Requested, to {address}. Never sent automatically.", { address: receipt.to!.email }) : translate(refusalText(receipt.refusal!))}</dd></>}
</dl> </dl>
)} )}
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && ( {receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (