Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1
85 lines
3.6 KiB
TypeScript
85 lines
3.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMail } from "@/store/mail";
|
|
import { Switch } from "@/ui/misc";
|
|
import { toast } from "@/ui/toast";
|
|
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
|
import { formatFullDateTime } from "@/lib/datetime";
|
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
|
import { client, CAP } from "@/jmap/client";
|
|
|
|
export function VacationSettings() {
|
|
const vacation = useMail((s) => s.vacation);
|
|
const load = useMail((s) => s.loadVacation);
|
|
const save = useMail((s) => s.saveVacation);
|
|
const [enabled, setEnabled] = useState(false);
|
|
const [subject, setSubject] = useState("");
|
|
const [body, setBody] = useState("");
|
|
const [from, setFrom] = useState("");
|
|
const [to, setTo] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const available = client.hasCapability(CAP.vacation);
|
|
// The date pickers themselves are native controls and follow the browser's
|
|
// locale; echo the value back in the user's chosen format so there is no doubt.
|
|
useSettings((s) => dateTimeKey(s.settings));
|
|
const echo = (v: string) => {
|
|
const d = fromInputDateTime(v);
|
|
return v && !Number.isNaN(d.getTime()) ? formatFullDateTime(d) : "";
|
|
};
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
useEffect(() => {
|
|
if (!vacation) return;
|
|
setEnabled(vacation.isEnabled);
|
|
setSubject(vacation.subject ?? "");
|
|
setBody(vacation.textBody ?? "");
|
|
setFrom(vacation.fromDate ? toInputDateTime(new Date(vacation.fromDate)) : "");
|
|
setTo(vacation.toDate ? toInputDateTime(new Date(vacation.toDate)) : "");
|
|
}, [vacation]);
|
|
|
|
if (!available) return <div><h1>Out of office</h1><p className="lead">Vacation responses are not available for this account.</p></div>;
|
|
|
|
const submit = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await save({
|
|
isEnabled: enabled,
|
|
subject: subject || null,
|
|
textBody: body || null,
|
|
htmlBody: null,
|
|
fromDate: from ? toUTCDate(fromInputDateTime(from)) : null,
|
|
toDate: to ? toUTCDate(fromInputDateTime(to)) : null,
|
|
});
|
|
toast.success(enabled ? "Auto-reply is on" : "Auto-reply saved");
|
|
} catch (err) {
|
|
toast.error((err as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h1>Out of office</h1>
|
|
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
|
|
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
|
|
<div className="field-row mt-16">
|
|
<div className="field">
|
|
<label>Starts (optional)</label>
|
|
<input className="input" type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} />
|
|
{echo(from) && <p className="hint">{echo(from)}</p>}
|
|
</div>
|
|
<div className="field">
|
|
<label>Ends (optional)</label>
|
|
<input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} />
|
|
{echo(to) && <p className="hint">{echo(to)}</p>}
|
|
</div>
|
|
</div>
|
|
<div className="field"><label>Subject</label><input className="input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Out of office" /></div>
|
|
<div className="field"><label>Message</label><textarea className="textarea" rows={7} value={body} onChange={(e) => setBody(e.target.value)} placeholder="Thanks for your message. I'm away until … and will reply when I'm back." /></div>
|
|
<button className="btn btn-primary" disabled={busy} onClick={() => void submit()}>Save</button>
|
|
</div>
|
|
);
|
|
}
|