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
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
import {
|
|
formatClock,
|
|
formatDate,
|
|
formatDayMonth,
|
|
formatFullDateTime,
|
|
formatMonthYear as fmtMonthYear,
|
|
formatWeekday,
|
|
relativeFormat,
|
|
} from "./datetime";
|
|
|
|
export function formatSize(bytes: number | null | undefined): string {
|
|
if (bytes == null || !Number.isFinite(bytes)) return "";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KB", "MB", "GB", "TB"];
|
|
let v = bytes / 1024;
|
|
let i = 0;
|
|
while (v >= 1024 && i < units.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
|
}
|
|
|
|
export function isSameDay(a: Date, b: Date): boolean {
|
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
}
|
|
|
|
/** Gmail-style compact date for list views. */
|
|
export function formatListDate(iso: string | null | undefined, now = new Date()): string {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
if (isSameDay(d, now)) return formatClock(d);
|
|
if (d.getFullYear() === now.getFullYear()) return formatDayMonth(d);
|
|
return formatDate(d);
|
|
}
|
|
|
|
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" or "Sa., 22.08.2026 15:14" */
|
|
export function formatFullDate(iso: string | null | undefined): string {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return formatFullDateTime(d);
|
|
}
|
|
|
|
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
const diff = (d.getTime() - now.getTime()) / 1000;
|
|
const abs = Math.abs(diff);
|
|
const rtf = relativeFormat();
|
|
if (!rtf) return formatListDate(iso, now);
|
|
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
|
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
|
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
|
if (abs < 86400 * 7) return rtf.format(Math.round(diff / 86400), "day");
|
|
return formatListDate(iso, now);
|
|
}
|
|
|
|
export function formatDateShort(d: Date): string {
|
|
return `${formatWeekday(d)}, ${formatDayMonth(d)}`;
|
|
}
|
|
|
|
export function formatTime(d: Date): string {
|
|
return formatClock(d);
|
|
}
|
|
|
|
export function formatMonthYear(d: Date): string {
|
|
return fmtMonthYear(d);
|
|
}
|
|
|
|
export function plural(n: number, one: string, many = `${one}s`): string {
|
|
return `${n} ${n === 1 ? one : many}`;
|
|
}
|
|
|
|
export function clamp(n: number, min: number, max: number): number {
|
|
return Math.min(max, Math.max(min, n));
|
|
}
|
|
|
|
export function truncate(s: string, n: number): string {
|
|
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
}
|
|
|
|
export function uid(prefix = "u"): string {
|
|
return `${prefix}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
|
}
|
|
|
|
export function debounce<T extends (...args: never[]) => void>(fn: T, ms: number): T & { cancel(): void } {
|
|
let t: number | null = null;
|
|
const wrapped = ((...args: Parameters<T>) => {
|
|
if (t) window.clearTimeout(t);
|
|
t = window.setTimeout(() => {
|
|
t = null;
|
|
fn(...args);
|
|
}, ms);
|
|
}) as T & { cancel(): void };
|
|
wrapped.cancel = () => {
|
|
if (t) window.clearTimeout(t);
|
|
t = null;
|
|
};
|
|
return wrapped;
|
|
}
|
|
|
|
export function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(" ");
|
|
}
|