Configurable date and time formats, defaulting to the Stalwart locale
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
This commit is contained in:
@@ -2,6 +2,28 @@ import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
import {
|
||||
browserLocale,
|
||||
formatClock,
|
||||
formatDate,
|
||||
formatFullDateTime,
|
||||
getServerLocale,
|
||||
localeLabel,
|
||||
localeOptions,
|
||||
withPrefs,
|
||||
type DateFormat,
|
||||
} from "@/lib/datetime";
|
||||
|
||||
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
|
||||
const SAMPLE = new Date(2025, 10, 22, 18, 23);
|
||||
|
||||
const DATE_FORMATS: Array<{ value: DateFormat; label: string }> = [
|
||||
{ value: "auto", label: "Automatic" },
|
||||
{ value: "dmy-dot", label: "Day.Month.Year" },
|
||||
{ value: "dmy-slash", label: "Day/Month/Year" },
|
||||
{ value: "mdy-slash", label: "Month/Day/Year" },
|
||||
{ value: "ymd-dash", label: "Year-Month-Day (ISO 8601)" },
|
||||
];
|
||||
|
||||
export function GeneralSettings() {
|
||||
const s = useSettings((st) => st.settings);
|
||||
@@ -9,6 +31,8 @@ export function GeneralSettings() {
|
||||
const reset = useSettings((st) => st.reset);
|
||||
const exportJson = useSettings((st) => st.exportJson);
|
||||
const importJson = useSettings((st) => st.importJson);
|
||||
const serverLocale = getServerLocale();
|
||||
const autoLocale = serverLocale ?? browserLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -100,6 +124,35 @@ export function GeneralSettings() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Language & region</label>
|
||||
<select className="select" value={s.locale} onChange={(e) => update({ locale: e.target.value })}>
|
||||
<option value="">Automatic ({localeLabel(autoLocale)})</option>
|
||||
{localeOptions().map((o) => <option key={o.tag} value={o.tag}>{o.label} — {o.tag}</option>)}
|
||||
</select>
|
||||
<p className="hint">{serverLocale ? `Your mail server reports ${localeLabel(serverLocale)} (${serverLocale}).` : "Your mail server does not report a locale, so the browser's is used."} Dates, times and month names follow this choice.</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Date format</label>
|
||||
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
|
||||
{DATE_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Time format</label>
|
||||
<select className="select" value={s.timeFormat} onChange={(e) => update({ timeFormat: e.target.value as typeof s.timeFormat })}>
|
||||
<option value="auto">Automatic ({withPrefs({ locale: s.locale, timeFormat: "auto" }, () => formatClock(SAMPLE))})</option>
|
||||
<option value="24">24-hour clock (18:23)</option>
|
||||
<option value="12">12-hour clock (6:23 PM)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">Preview: {formatFullDateTime(SAMPLE)}</p>
|
||||
|
||||
<h2>Backup</h2>
|
||||
<div className="row wrap">
|
||||
@@ -113,3 +166,4 @@ export function GeneralSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user