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:
@@ -50,6 +50,7 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
|
|||||||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||||
|
|
||||||
**Settings**
|
**Settings**
|
||||||
|
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits
|
||||||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||||
|
|
||||||
**Platform**
|
**Platform**
|
||||||
@@ -64,10 +65,10 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─
|
|||||||
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
JMAP client + stores • /api/jmap, /api/blob, /api/upload, /api/events (SSE), /api/image
|
||||||
```
|
```
|
||||||
|
|
||||||
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates, vCard, …).
|
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
|
||||||
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
|
||||||
|
|
||||||
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push. Features degrade gracefully when a capability is missing.
|
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale). Features degrade gracefully when a capability is missing.
|
||||||
|
|
||||||
## Quick start (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
@@ -129,6 +130,8 @@ Verified against the mock server and, for the core mail flows, against a live St
|
|||||||
- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server.
|
- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server.
|
||||||
- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass.
|
- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass.
|
||||||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||||
|
- Date **pickers** (`<input type="datetime-local">` in the event editor and out-of-office settings) are native browser controls and always follow the browser's own locale — no page can restyle them. The chosen format is echoed underneath the out-of-office fields so the entered instant is unambiguous.
|
||||||
|
- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand.
|
||||||
|
|
||||||
## Roadmap / not yet
|
## Roadmap / not yet
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -11,6 +11,7 @@ import {
|
|||||||
expandTemplate,
|
expandTemplate,
|
||||||
fetchUpstreamSession,
|
fetchUpstreamSession,
|
||||||
forgetUpstreamSession,
|
forgetUpstreamSession,
|
||||||
|
getAccountLocale,
|
||||||
getUpstreamSession,
|
getUpstreamSession,
|
||||||
localizeSession,
|
localizeSession,
|
||||||
} from "./upstream.js";
|
} from "./upstream.js";
|
||||||
@@ -170,7 +171,8 @@ export function createApp(): Hono<Env> {
|
|||||||
ip,
|
ip,
|
||||||
});
|
});
|
||||||
setSessionCookie(c, cookie, session.remember);
|
setSessionCookie(c, cookie, session.remember);
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session)));
|
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||||
|
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return upstreamFailure(c, err);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
@@ -180,7 +182,8 @@ export function createApp(): Hono<Env> {
|
|||||||
const session = c.get("session");
|
const session = c.get("session");
|
||||||
try {
|
try {
|
||||||
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session)));
|
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||||
|
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UpstreamError && err.status === 401) {
|
if (err instanceof UpstreamError && err.status === 401) {
|
||||||
sessions.destroy(session.id);
|
sessions.destroy(session.id);
|
||||||
@@ -350,7 +353,7 @@ export function createApp(): Hono<Env> {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionExtras(session: LiveSession) {
|
function sessionExtras(session: LiveSession, userLocale: string | null = null) {
|
||||||
return {
|
return {
|
||||||
ihasmail: {
|
ihasmail: {
|
||||||
appName: config.appName,
|
appName: config.appName,
|
||||||
@@ -359,6 +362,8 @@ function sessionExtras(session: LiveSession) {
|
|||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
loginName: session.username,
|
loginName: session.username,
|
||||||
remember: session.remember,
|
remember: session.remember,
|
||||||
|
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||||
|
userLocale,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { randomUUID } from "node:crypto";
|
|||||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||||
const ACCOUNT = "a1";
|
const ACCOUNT = "a1";
|
||||||
const USER = process.env.MOCK_USER ?? "[email protected]";
|
const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||||
|
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||||
|
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||||
|
|
||||||
type Obj = Record<string, unknown>;
|
type Obj = Record<string, unknown>;
|
||||||
@@ -254,6 +256,12 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlers: Record<string, Handler> = {
|
const handlers: Record<string, Handler> = {
|
||||||
|
// Stalwart's directory extension - the client reads the account locale from here.
|
||||||
|
"x:Account/get": (a) => {
|
||||||
|
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||||
|
const list = ids.filter((id) => id === ACCOUNT).map((id) => ({ id, name: USER, locale: MOCK_LOCALE, timeZone: null }));
|
||||||
|
return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => id !== ACCOUNT) };
|
||||||
|
},
|
||||||
"Mailbox/get": genericGet(mailboxes),
|
"Mailbox/get": genericGet(mailboxes),
|
||||||
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
"Mailbox/set": (a) => { const r = genericSet(mailboxes, "m", (o) => Object.assign(o, { ...mb(o.id as string, o.name as string, null, (o.parentId as string) ?? null), ...o }))(a); recount(); return r; },
|
||||||
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
"Mailbox/changes": () => ({ accountId: ACCOUNT, oldState: "1", newState: String(state.n), hasMoreChanges: false, created: [], updated: [], destroyed: [] }),
|
||||||
@@ -352,9 +360,9 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = () => ({
|
const session = () => ({
|
||||||
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {} },
|
capabilities: { "urn:ietf:params:jmap:core": { maxSizeUpload: 50000000, maxConcurrentUpload: 4, maxSizeRequest: 10000000, maxConcurrentRequests: 4, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500, collationAlgorithms: ["i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": { implementation: "mock" }, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:calendars:parse": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:contacts:parse": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:principals:availability": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:blob": {}, "urn:ietf:params:jmap:filenode": {}, "urn:stalwart:jmap": {} },
|
||||||
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
|
accounts: { [ACCOUNT]: { name: USER, isPersonal: true, isReadOnly: false, accountCapabilities: { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {}, "urn:ietf:params:jmap:vacationresponse": {}, "urn:ietf:params:jmap:sieve": {}, "urn:ietf:params:jmap:calendars": {}, "urn:ietf:params:jmap:contacts": {}, "urn:ietf:params:jmap:principals": {}, "urn:ietf:params:jmap:quota": {}, "urn:ietf:params:jmap:filenode": {} } } },
|
||||||
primaryAccounts: Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])),
|
primaryAccounts: { ...Object.fromEntries(["mail", "submission", "vacationresponse", "sieve", "calendars", "contacts", "principals", "quota", "filenode", "blob"].map((c) => [`urn:ietf:params:jmap:${c}`, ACCOUNT])), "urn:stalwart:jmap": ACCOUNT },
|
||||||
username: USER,
|
username: USER,
|
||||||
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
apiUrl: `http://127.0.0.1:${PORT}/jmap/`,
|
||||||
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
downloadUrl: `http://127.0.0.1:${PORT}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { SessionStore } from "./sessions.js";
|
import { SessionStore } from "./sessions.js";
|
||||||
|
import { normalizeLocale } from "./upstream.js";
|
||||||
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
import { deriveKey, open, seal, sha256 } from "./crypto.js";
|
||||||
import { RateLimiter } from "./ratelimit.js";
|
import { RateLimiter } from "./ratelimit.js";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
@@ -46,3 +47,19 @@ test("rate limiter blocks after max hits in window", () => {
|
|||||||
rl.reset("k");
|
rl.reset("k");
|
||||||
assert.equal(rl.check("k"), true);
|
assert.equal(rl.check("k"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes Stalwart account locales to BCP-47 tags", () => {
|
||||||
|
assert.equal(normalizeLocale("de_DE"), "de-DE");
|
||||||
|
assert.equal(normalizeLocale("de_DE.UTF-8"), "de-DE");
|
||||||
|
assert.equal(normalizeLocale("ca_ES@valencia"), "ca-ES");
|
||||||
|
assert.equal(normalizeLocale("sr_RS@latin"), "sr-Latn-RS");
|
||||||
|
assert.equal(normalizeLocale("uz_UZ@cyrillic"), "uz-Cyrl-UZ");
|
||||||
|
assert.equal(normalizeLocale("ru_RU@cyrillic"), "ru-RU");
|
||||||
|
assert.equal(normalizeLocale("en"), "en");
|
||||||
|
assert.equal(normalizeLocale("POSIX"), null);
|
||||||
|
assert.equal(normalizeLocale("C"), null);
|
||||||
|
assert.equal(normalizeLocale(""), null);
|
||||||
|
assert.equal(normalizeLocale(undefined), null);
|
||||||
|
assert.equal(normalizeLocale({ locale: "de_DE" }), null);
|
||||||
|
assert.equal(normalizeLocale("../etc/passwd"), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,100 @@ export async function getUpstreamSession(sessionId: string, authorization: strin
|
|||||||
|
|
||||||
export function forgetUpstreamSession(sessionId: string): void {
|
export function forgetUpstreamSession(sessionId: string): void {
|
||||||
sessionCache.delete(sessionId);
|
sessionCache.delete(sessionId);
|
||||||
|
localeCache.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Account locale */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STALWART_CAP = "urn:stalwart:jmap";
|
||||||
|
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||||
|
const localeCache = new Map<string, { locale: string | null; fetchedAt: number }>();
|
||||||
|
const LOCALE_CACHE_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||||
|
* "sr_RS@latin" is Latin Serbian (sr-Latn-RS), not sr-RS. Anything not listed
|
||||||
|
* here (@valencia, @saaho, @euro …) carries no script and is dropped.
|
||||||
|
*/
|
||||||
|
const SCRIPT_MODIFIERS: Record<string, string> = {
|
||||||
|
latin: "Latn",
|
||||||
|
latn: "Latn",
|
||||||
|
cyrillic: "Cyrl",
|
||||||
|
cyrl: "Cyrl",
|
||||||
|
devanagari: "Deva",
|
||||||
|
iqtelif: "Latn",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a POSIX-style locale ("de_DE.UTF-8@euro") into a BCP-47 tag
|
||||||
|
* ("de-DE"). Returns null for the locale-less values ("C", "POSIX") and for
|
||||||
|
* anything that does not look like a language tag.
|
||||||
|
*/
|
||||||
|
export function normalizeLocale(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const [head, modifier] = raw.trim().split("@");
|
||||||
|
const base = head!.split(".")[0]!.replace(/_/g, "-");
|
||||||
|
if (!base || base === "C" || base.toUpperCase() === "POSIX") return null;
|
||||||
|
if (!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$/.test(base)) return null;
|
||||||
|
const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined;
|
||||||
|
try {
|
||||||
|
const [canonical] = Intl.getCanonicalLocales(base);
|
||||||
|
if (!canonical) return null;
|
||||||
|
if (!script) return canonical;
|
||||||
|
const loc = new Intl.Locale(canonical);
|
||||||
|
// Adding the script only helps when it differs from the one the locale
|
||||||
|
// already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU).
|
||||||
|
const implied = loc.script ?? loc.maximize().script;
|
||||||
|
return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort lookup of the locale configured for this account in Stalwart's
|
||||||
|
* directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not
|
||||||
|
* expose it — or that deny a regular user the `sysAccountGet` permission —
|
||||||
|
* simply yield null and the client falls back to the browser locale.
|
||||||
|
*/
|
||||||
|
async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||||
|
if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null;
|
||||||
|
const accountId =
|
||||||
|
session.primaryAccounts?.[STALWART_CAP] ??
|
||||||
|
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||||
|
Object.keys(session.accounts ?? {})[0];
|
||||||
|
if (!accountId) return null;
|
||||||
|
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
using: [JMAP_CORE, STALWART_CAP],
|
||||||
|
methodCalls: [["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "l"]],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||||
|
const call = body.methodResponses?.[0];
|
||||||
|
if (!call || call[0] !== "x:Account/get") return null;
|
||||||
|
const list = call[1]?.list;
|
||||||
|
if (!Array.isArray(list) || !list.length) return null;
|
||||||
|
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||||
|
const cached = localeCache.get(sessionId);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale;
|
||||||
|
let locale: string | null = null;
|
||||||
|
try {
|
||||||
|
locale = await fetchAccountLocale(authorization, session);
|
||||||
|
} catch {
|
||||||
|
/* the server locale is a nicety - never fail the session over it */
|
||||||
|
}
|
||||||
|
localeCache.set(sessionId, { locale, fetchedAt: Date.now() });
|
||||||
|
return locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface JmapSession {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
loginName: string;
|
loginName: string;
|
||||||
remember: boolean;
|
remember: boolean;
|
||||||
|
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
||||||
|
userLocale?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
formatClock,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
formatDayMonth,
|
||||||
|
formatFullDateTime,
|
||||||
|
formatHourLabel,
|
||||||
|
localeOptions,
|
||||||
|
normalizeLocale,
|
||||||
|
resolvedLocale,
|
||||||
|
setDateTimePrefs,
|
||||||
|
setServerLocale,
|
||||||
|
uses24Hour,
|
||||||
|
withPrefs,
|
||||||
|
} from "../datetime";
|
||||||
|
|
||||||
|
const SAMPLE = new Date(2025, 10, 22, 18, 23, 45); // Sat 22 Nov 2025, 18:23 local
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setDateTimePrefs({ locale: "", dateFormat: "auto", timeFormat: "auto" });
|
||||||
|
setServerLocale(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeLocale", () => {
|
||||||
|
it("converts POSIX locales to BCP-47", () => {
|
||||||
|
expect(normalizeLocale("de_DE")).toBe("de-DE");
|
||||||
|
expect(normalizeLocale("de_DE.UTF-8")).toBe("de-DE");
|
||||||
|
expect(normalizeLocale("ca_ES@valencia")).toBe("ca-ES");
|
||||||
|
expect(normalizeLocale("en_US.UTF-8@euro")).toBe("en-US");
|
||||||
|
});
|
||||||
|
it("keeps script modifiers that change the locale", () => {
|
||||||
|
expect(normalizeLocale("sr_RS@latin")).toBe("sr-Latn-RS");
|
||||||
|
expect(normalizeLocale("uz_UZ@cyrillic")).toBe("uz-Cyrl-UZ");
|
||||||
|
expect(normalizeLocale("tt_RU@iqtelif")).toBe("tt-Latn-RU");
|
||||||
|
// …and drops the ones that name a dialect, variant or currency instead.
|
||||||
|
expect(normalizeLocale("ca_ES@valencia")).toBe("ca-ES");
|
||||||
|
expect(normalizeLocale("de_DE@euro")).toBe("de-DE");
|
||||||
|
expect(normalizeLocale("aa_ER@saaho")).toBe("aa-ER");
|
||||||
|
});
|
||||||
|
it("does not add a script the locale already has", () => {
|
||||||
|
expect(normalizeLocale("ru_RU@cyrillic")).toBe("ru-RU");
|
||||||
|
expect(normalizeLocale("de_DE@latin")).toBe("de-DE");
|
||||||
|
});
|
||||||
|
it("rejects locale-less and invalid values", () => {
|
||||||
|
expect(normalizeLocale("POSIX")).toBeNull();
|
||||||
|
expect(normalizeLocale("C")).toBeNull();
|
||||||
|
expect(normalizeLocale("")).toBeNull();
|
||||||
|
expect(normalizeLocale(null)).toBeNull();
|
||||||
|
expect(normalizeLocale("not a locale!")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale resolution", () => {
|
||||||
|
it("prefers the explicit setting over the server locale", () => {
|
||||||
|
setServerLocale("de_DE");
|
||||||
|
expect(resolvedLocale()).toBe("de-DE");
|
||||||
|
setDateTimePrefs({ locale: "fr-FR" });
|
||||||
|
expect(resolvedLocale()).toBe("fr-FR");
|
||||||
|
});
|
||||||
|
it("falls back to the browser when nothing is configured", () => {
|
||||||
|
expect(resolvedLocale()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explicit date formats", () => {
|
||||||
|
it("formats German dotted dates", () => {
|
||||||
|
setDateTimePrefs({ dateFormat: "dmy-dot", timeFormat: "24" });
|
||||||
|
expect(formatDate(SAMPLE)).toBe("22.11.2025");
|
||||||
|
expect(formatDayMonth(SAMPLE)).toBe("22.11.");
|
||||||
|
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||||
|
expect(formatDateTime(SAMPLE)).toBe("22.11.2025 18:23");
|
||||||
|
});
|
||||||
|
it("formats ISO 8601 dates", () => {
|
||||||
|
setDateTimePrefs({ dateFormat: "ymd-dash", timeFormat: "24" });
|
||||||
|
expect(formatDate(SAMPLE)).toBe("2025-11-22");
|
||||||
|
expect(formatDayMonth(SAMPLE)).toBe("11-22");
|
||||||
|
expect(formatDateTime(SAMPLE)).toBe("2025-11-22 18:23");
|
||||||
|
});
|
||||||
|
it("formats day/month/year and month/day/year", () => {
|
||||||
|
setDateTimePrefs({ dateFormat: "dmy-slash" });
|
||||||
|
expect(formatDate(SAMPLE)).toBe("22/11/2025");
|
||||||
|
setDateTimePrefs({ dateFormat: "mdy-slash" });
|
||||||
|
expect(formatDate(SAMPLE)).toBe("11/22/2025");
|
||||||
|
});
|
||||||
|
it("keeps the weekday in message headers", () => {
|
||||||
|
setDateTimePrefs({ locale: "en-GB", dateFormat: "dmy-dot", timeFormat: "24" });
|
||||||
|
expect(formatFullDateTime(SAMPLE)).toBe("Sat, 22.11.2025 18:23");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clock preference", () => {
|
||||||
|
it("honours 24-hour regardless of locale", () => {
|
||||||
|
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
|
||||||
|
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||||
|
expect(uses24Hour()).toBe(true);
|
||||||
|
expect(formatHourLabel(13)).toBe("13");
|
||||||
|
expect(formatHourLabel(9)).toBe("09");
|
||||||
|
});
|
||||||
|
it("honours 12-hour regardless of locale", () => {
|
||||||
|
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
|
||||||
|
expect(formatClock(SAMPLE)).toBe("6:23 PM");
|
||||||
|
expect(uses24Hour()).toBe(false);
|
||||||
|
});
|
||||||
|
it("follows the locale when set to automatic", () => {
|
||||||
|
setDateTimePrefs({ locale: "de-DE", timeFormat: "auto" });
|
||||||
|
expect(uses24Hour()).toBe(true);
|
||||||
|
expect(formatClock(SAMPLE)).toBe("18:23");
|
||||||
|
setDateTimePrefs({ locale: "en-US", timeFormat: "auto" });
|
||||||
|
expect(uses24Hour()).toBe(false);
|
||||||
|
expect(formatClock(SAMPLE)).toMatch(/6:23\s?PM/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("automatic date format", () => {
|
||||||
|
it("follows the locale's own order", () => {
|
||||||
|
setDateTimePrefs({ locale: "de-DE", dateFormat: "auto" });
|
||||||
|
expect(formatDate(SAMPLE)).toMatch(/22\.\s?Nov\.?\s?2025/);
|
||||||
|
setDateTimePrefs({ locale: "en-US", dateFormat: "auto" });
|
||||||
|
expect(formatDate(SAMPLE)).toBe("Nov 22, 2025");
|
||||||
|
});
|
||||||
|
it("uses the server locale when no explicit choice is made", () => {
|
||||||
|
setServerLocale("de_DE");
|
||||||
|
expect(formatDate(SAMPLE)).toMatch(/22\./);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withPrefs", () => {
|
||||||
|
it("formats a preview without leaking the override", () => {
|
||||||
|
setDateTimePrefs({ locale: "en-US", dateFormat: "mdy-slash" });
|
||||||
|
expect(withPrefs({ dateFormat: "ymd-dash" }, () => formatDate(SAMPLE))).toBe("2025-11-22");
|
||||||
|
expect(formatDate(SAMPLE)).toBe("11/22/2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("script variants render in their own script", () => {
|
||||||
|
it("distinguishes Latin from Cyrillic Serbian", () => {
|
||||||
|
setServerLocale("sr_RS@latin");
|
||||||
|
const latin = formatDate(SAMPLE);
|
||||||
|
setServerLocale("sr_RS");
|
||||||
|
const cyrillic = formatDate(SAMPLE);
|
||||||
|
expect(latin).toMatch(/[a-z]/i);
|
||||||
|
expect(cyrillic).toMatch(/[\u0400-\u04FF]/);
|
||||||
|
expect(latin).not.toBe(cyrillic);
|
||||||
|
});
|
||||||
|
it("distinguishes Cyrillic from Latin Uzbek", () => {
|
||||||
|
setServerLocale("uz_UZ@cyrillic");
|
||||||
|
expect(formatDate(SAMPLE)).toMatch(/[\u0400-\u04FF]/);
|
||||||
|
setServerLocale("uz_UZ");
|
||||||
|
expect(formatDate(SAMPLE)).not.toMatch(/[\u0400-\u04FF]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("digit systems stay consistent within one string", () => {
|
||||||
|
const ARABIC_INDIC = /[\u0660-\u0669]/;
|
||||||
|
const LATIN_DIGIT = /[0-9]/;
|
||||||
|
|
||||||
|
it("uses the locale's own digits for locale date orders", () => {
|
||||||
|
setDateTimePrefs({ locale: "ar-EG", dateFormat: "dmy-dot", timeFormat: "24" });
|
||||||
|
const out = formatDateTime(SAMPLE);
|
||||||
|
expect(out).toMatch(ARABIC_INDIC);
|
||||||
|
expect(out).not.toMatch(LATIN_DIGIT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins ISO 8601 to Latin digits, clock included", () => {
|
||||||
|
setDateTimePrefs({ locale: "ar-EG", dateFormat: "ymd-dash", timeFormat: "24" });
|
||||||
|
const out = formatDateTime(SAMPLE);
|
||||||
|
expect(out).toContain("2025-11-22");
|
||||||
|
expect(out).toContain("18:23");
|
||||||
|
expect(out).not.toMatch(ARABIC_INDIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps calendar hour labels in the same digits as the dates", () => {
|
||||||
|
setDateTimePrefs({ locale: "ar-EG", dateFormat: "dmy-dot", timeFormat: "24" });
|
||||||
|
expect(formatHourLabel(13)).toMatch(ARABIC_INDIC);
|
||||||
|
setDateTimePrefs({ locale: "ar-EG", dateFormat: "ymd-dash", timeFormat: "24" });
|
||||||
|
expect(formatHourLabel(13)).toBe("13");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale options", () => {
|
||||||
|
it("offers every locale ICU has data for, named in its own language", () => {
|
||||||
|
const opts = localeOptions();
|
||||||
|
expect(opts.length).toBeGreaterThan(500);
|
||||||
|
const tags = opts.map((o) => o.tag);
|
||||||
|
for (const tag of ["de-DE", "en-US", "sw-KE", "ka-GE", "yue-HK", "sr-Latn-RS", "uz-Cyrl-UZ"]) {
|
||||||
|
expect(tags).toContain(tag);
|
||||||
|
}
|
||||||
|
expect(opts.find((o) => o.tag === "de-DE")?.label).toBe("Deutsch (Deutschland)");
|
||||||
|
expect(opts.every((o) => o.label && o.label !== o.tag)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a server locale that is not in the generated list", () => {
|
||||||
|
setServerLocale("de_DE_u_ca_buddhist");
|
||||||
|
const tags = localeOptions().map((o) => o.tag);
|
||||||
|
expect(tags).toContain("de-DE-u-ca-buddhist");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatClock, formatDayMonth, formatDayMonthTime, formatWeekdayDate } from "./datetime";
|
||||||
|
|
||||||
export const DAY_MS = 86_400_000;
|
export const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
export function startOfDay(d: Date): Date {
|
export function startOfDay(d: Date): Date {
|
||||||
@@ -219,14 +221,13 @@ export function listTimeZones(): string[] {
|
|||||||
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
|
export function formatTimeRange(start: Date, end: Date, allDay: boolean): string {
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
const lastDay = new Date(end.getTime() - 1);
|
const lastDay = new Date(end.getTime() - 1);
|
||||||
if (isSameDay(start, lastDay)) return start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
if (isSameDay(start, lastDay)) return formatWeekdayDate(start);
|
||||||
return `${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${lastDay.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
return `${formatDayMonth(start)} – ${formatDayMonth(lastDay)}`;
|
||||||
}
|
}
|
||||||
const t = (d: Date) => d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
||||||
if (isSameDay(start, end)) {
|
if (isSameDay(start, end)) {
|
||||||
return `${start.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })} · ${t(start)} – ${t(end)}`;
|
return `${formatWeekdayDate(start)} · ${formatClock(start)} – ${formatClock(end)}`;
|
||||||
}
|
}
|
||||||
return `${start.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })} – ${end.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}`;
|
return `${formatDayMonthTime(start)} – ${formatDayMonthTime(end)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** For <input type="datetime-local"> */
|
/** For <input type="datetime-local"> */
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Locale-aware date and time formatting.
|
||||||
|
*
|
||||||
|
* Every user-visible date in the app goes through here so that a single set of
|
||||||
|
* preferences (language/region, date order, 12h vs 24h clock) controls all of
|
||||||
|
* them. The preferences live in the settings store; this module keeps a plain
|
||||||
|
* copy so formatting stays a synchronous, non-React call.
|
||||||
|
*
|
||||||
|
* `locale` is the explicit user choice; when it is empty we fall back to the
|
||||||
|
* locale Stalwart reports for the account, and finally to the browser's.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LOCALE_TAGS } from "./locales";
|
||||||
|
|
||||||
|
export type DateFormat = "auto" | "dmy-dot" | "dmy-slash" | "mdy-slash" | "ymd-dash";
|
||||||
|
export type TimeFormat = "auto" | "12" | "24";
|
||||||
|
|
||||||
|
export interface DateTimePrefs {
|
||||||
|
/** BCP-47 tag, or "" for automatic (server → browser). */
|
||||||
|
locale: string;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
timeFormat: TimeFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: DateTimePrefs = { locale: "", dateFormat: "auto", timeFormat: "auto" };
|
||||||
|
|
||||||
|
let prefs: DateTimePrefs = DEFAULT_PREFS;
|
||||||
|
let serverLocale: string | null = null;
|
||||||
|
|
||||||
|
export function setDateTimePrefs(p: Partial<DateTimePrefs>): void {
|
||||||
|
prefs = { ...prefs, ...p };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` with temporarily overridden preferences — used to render previews. */
|
||||||
|
export function withPrefs<T>(over: Partial<DateTimePrefs>, fn: () => T): T {
|
||||||
|
const saved = prefs;
|
||||||
|
prefs = { ...prefs, ...over };
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
prefs = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale reported by Stalwart for this account (normalised), or null. */
|
||||||
|
export function setServerLocale(raw: string | null | undefined): void {
|
||||||
|
serverLocale = normalizeLocale(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerLocale(): string | null {
|
||||||
|
return serverLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* glibc locale modifiers that name a script rather than a dialect or a
|
||||||
|
* currency: "sr_RS@latin" means Latin Serbian, which is a different tag
|
||||||
|
* (sr-Latn-RS) and not just sr-RS. Modifiers not listed here (@valencia,
|
||||||
|
* @saaho, @euro …) carry no script and are dropped.
|
||||||
|
*/
|
||||||
|
const SCRIPT_MODIFIERS: Record<string, string> = {
|
||||||
|
latin: "Latn",
|
||||||
|
latn: "Latn",
|
||||||
|
cyrillic: "Cyrl",
|
||||||
|
cyrl: "Cyrl",
|
||||||
|
devanagari: "Deva",
|
||||||
|
iqtelif: "Latn",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a POSIX-style locale ("de_DE.UTF-8@euro") or BCP-47 tag into a plain
|
||||||
|
* BCP-47 tag, or null when it is unusable ("POSIX", "C", garbage).
|
||||||
|
*/
|
||||||
|
export function normalizeLocale(raw: string | null | undefined): string | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const [head, modifier] = raw.trim().split("@");
|
||||||
|
const base = head!.split(".")[0]!.replace(/_/g, "-");
|
||||||
|
if (!base || base === "C" || base.toUpperCase() === "POSIX") return null;
|
||||||
|
const script = modifier ? SCRIPT_MODIFIERS[modifier.toLowerCase()] : undefined;
|
||||||
|
try {
|
||||||
|
const [canonical] = Intl.getCanonicalLocales(base);
|
||||||
|
if (!canonical) return null;
|
||||||
|
if (!script) return canonical;
|
||||||
|
const loc = new Intl.Locale(canonical);
|
||||||
|
// Adding the script only helps when it differs from the one the locale
|
||||||
|
// already implies (ru-RU is Cyrillic, so "ru_RU@cyrillic" is just ru-RU).
|
||||||
|
const implied = loc.script ?? loc.maximize().script;
|
||||||
|
return implied === script ? canonical : new Intl.Locale(canonical, { script }).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The locale Intl should use: explicit choice → server → browser default. */
|
||||||
|
export function resolvedLocale(): string | undefined {
|
||||||
|
return prefs.locale || serverLocale || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the effective locale came from — used to label the "Automatic" option. */
|
||||||
|
export function localeSource(): "explicit" | "server" | "browser" {
|
||||||
|
if (prefs.locale) return "explicit";
|
||||||
|
if (serverLocale) return "server";
|
||||||
|
return "browser";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browserLocale(): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat().resolvedOptions().locale;
|
||||||
|
} catch {
|
||||||
|
return "en-US";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelCache = new Map<string, string>();
|
||||||
|
|
||||||
|
/** Human-readable name of a locale tag, in that locale ("Deutsch (Deutschland)"). */
|
||||||
|
export function localeLabel(tag: string): string {
|
||||||
|
const hit = labelCache.get(tag);
|
||||||
|
if (hit) return hit;
|
||||||
|
let label = tag;
|
||||||
|
try {
|
||||||
|
label = new Intl.DisplayNames([tag], { type: "language" }).of(tag) ?? tag;
|
||||||
|
} catch {
|
||||||
|
/* keep the tag */
|
||||||
|
}
|
||||||
|
labelCache.set(tag, label);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Intl plumbing */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const cache = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
const numCache = new Map<string, Intl.NumberFormat>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale the formatters actually run in. ISO 8601 is defined in Latin
|
||||||
|
* digits, so choosing it pins the numbering system for the clock too — a date
|
||||||
|
* and time in one line must not mix digit systems.
|
||||||
|
*/
|
||||||
|
function formattingLocale(): string | undefined {
|
||||||
|
const loc = resolvedLocale();
|
||||||
|
if (prefs.dateFormat !== "ymd-dash") return loc;
|
||||||
|
try {
|
||||||
|
return new Intl.Locale(loc ?? browserLocale(), { numberingSystem: "latn" }).toString();
|
||||||
|
} catch {
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function intl(opts: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||||
|
const loc = formattingLocale();
|
||||||
|
const key = `${loc ?? "*"}|${JSON.stringify(opts)}`;
|
||||||
|
let f = cache.get(key);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat(loc, opts);
|
||||||
|
cache.set(key, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zero-padded number in the locale's own digits (١٨ for ar-EG, 18 for de-DE). */
|
||||||
|
function num(value: number, digits: number): string {
|
||||||
|
const loc = formattingLocale();
|
||||||
|
const key = `${loc ?? "*"}|${digits}`;
|
||||||
|
let f = numCache.get(key);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.NumberFormat(loc, { minimumIntegerDigits: digits, useGrouping: false });
|
||||||
|
numCache.set(key, f);
|
||||||
|
}
|
||||||
|
return f.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time-of-day options honouring the 12h/24h preference. */
|
||||||
|
export function timeOptions(): Intl.DateTimeFormatOptions {
|
||||||
|
switch (prefs.timeFormat) {
|
||||||
|
case "24":
|
||||||
|
return { hour: "2-digit", minute: "2-digit", hourCycle: "h23" };
|
||||||
|
case "12":
|
||||||
|
return { hour: "numeric", minute: "2-digit", hourCycle: "h12" };
|
||||||
|
default:
|
||||||
|
return { hour: "numeric", minute: "2-digit" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the effective clock is 24-hour (explicit setting, else locale). */
|
||||||
|
export function uses24Hour(): boolean {
|
||||||
|
if (prefs.timeFormat === "24") return true;
|
||||||
|
if (prefs.timeFormat === "12") return false;
|
||||||
|
try {
|
||||||
|
const hc = new Intl.DateTimeFormat(formattingLocale(), { hour: "numeric" }).resolvedOptions().hourCycle;
|
||||||
|
return hc === "h23" || hc === "h24";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutoDateFormat(): boolean {
|
||||||
|
return prefs.dateFormat === "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Building blocks */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** All-numeric date in the configured order (never used when dateFormat is "auto"). */
|
||||||
|
function numeric(d: Date, withYear: boolean): string {
|
||||||
|
const dd = num(d.getDate(), 2);
|
||||||
|
const mm = num(d.getMonth() + 1, 2);
|
||||||
|
const yy = num(d.getFullYear(), 4);
|
||||||
|
switch (prefs.dateFormat) {
|
||||||
|
case "dmy-slash":
|
||||||
|
return withYear ? `${dd}/${mm}/${yy}` : `${dd}/${mm}`;
|
||||||
|
case "mdy-slash":
|
||||||
|
return withYear ? `${mm}/${dd}/${yy}` : `${mm}/${dd}`;
|
||||||
|
case "ymd-dash":
|
||||||
|
return withYear ? `${yy}-${mm}-${dd}` : `${mm}-${dd}`;
|
||||||
|
case "dmy-dot":
|
||||||
|
default:
|
||||||
|
return withYear ? `${dd}.${mm}.${yy}` : `${dd}.${mm}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "18:23" / "6:23 PM" */
|
||||||
|
export function formatClock(d: Date): string {
|
||||||
|
return intl(timeOptions()).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hour gutter label in the calendar: "13" / "1 PM". */
|
||||||
|
export function formatHourLabel(hour: number): string {
|
||||||
|
if (uses24Hour()) return num(hour, 2);
|
||||||
|
return intl({ hour: "numeric", hourCycle: "h12" }).format(new Date(2000, 0, 1, hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day and month, no year: "22 Aug" / "22.08." / "08-22". */
|
||||||
|
export function formatDayMonth(d: Date): string {
|
||||||
|
return isAutoDateFormat() ? intl({ month: "short", day: "numeric" }).format(d) : numeric(d, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day, month and year: "22 Aug 2026" / "22.08.2026" / "2026-08-22". */
|
||||||
|
export function formatDate(d: Date): string {
|
||||||
|
return isAutoDateFormat() ? intl({ year: "numeric", month: "short", day: "numeric" }).format(d) : numeric(d, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All-numeric date, even in "auto" mode: "8/22/2026" / "22.08.2026". */
|
||||||
|
export function formatNumericDate(d: Date): string {
|
||||||
|
return isAutoDateFormat() ? intl({ year: "numeric", month: "numeric", day: "numeric" }).format(d) : numeric(d, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spelled-out month, no weekday: "22 August 2026" / "22.08.2026". */
|
||||||
|
export function formatDateLong(d: Date, withYear = true): string {
|
||||||
|
if (isAutoDateFormat()) {
|
||||||
|
return intl({ month: "long", day: "numeric", ...(withYear ? { year: "numeric" as const } : {}) }).format(d);
|
||||||
|
}
|
||||||
|
return numeric(d, withYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long form for headings: "Saturday, 22 August" / "Saturday, 22.08.2026". */
|
||||||
|
export function formatWeekdayDate(d: Date, withYear = false): string {
|
||||||
|
if (isAutoDateFormat()) {
|
||||||
|
return intl({ weekday: "long", month: "long", day: "numeric", ...(withYear ? { year: "numeric" as const } : {}) }).format(d);
|
||||||
|
}
|
||||||
|
return `${formatWeekday(d, "long")}, ${numeric(d, true)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekday(d: Date, style: "short" | "long" | "narrow" = "short"): string {
|
||||||
|
return intl({ weekday: style }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "August 2026" — month names are unambiguous, so this always follows the locale. */
|
||||||
|
export function formatMonthYear(d: Date): string {
|
||||||
|
return intl({ month: "long", year: "numeric" }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date plus time: "22 Aug 2026, 18:23" / "2026-08-22 18:23". */
|
||||||
|
export function formatDateTime(d: Date): string {
|
||||||
|
if (isAutoDateFormat()) {
|
||||||
|
return intl({ year: "numeric", month: "short", day: "numeric", ...timeOptions() }).format(d);
|
||||||
|
}
|
||||||
|
return `${numeric(d, true)} ${formatClock(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day/month plus time, no year: "22 Aug, 18:23" / "22.08. 18:23". */
|
||||||
|
export function formatDayMonthTime(d: Date): string {
|
||||||
|
if (isAutoDateFormat()) {
|
||||||
|
return intl({ month: "short", day: "numeric", ...timeOptions() }).format(d);
|
||||||
|
}
|
||||||
|
return `${numeric(d, false)} ${formatClock(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weekday, full date and time — the message header format. */
|
||||||
|
export function formatFullDateTime(d: Date): string {
|
||||||
|
if (isAutoDateFormat()) {
|
||||||
|
return intl({ weekday: "short", year: "numeric", month: "short", day: "numeric", ...timeOptions() }).format(d);
|
||||||
|
}
|
||||||
|
return `${formatWeekday(d, "short")}, ${numeric(d, true)} ${formatClock(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Relative times */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
let rtfLocale: string | undefined | null = null;
|
||||||
|
let rtfCached: Intl.RelativeTimeFormat | null = null;
|
||||||
|
|
||||||
|
export function relativeFormat(): Intl.RelativeTimeFormat | null {
|
||||||
|
if (typeof Intl === "undefined" || !("RelativeTimeFormat" in Intl)) return null;
|
||||||
|
const loc = resolvedLocale();
|
||||||
|
if (rtfCached && rtfLocale === loc) return rtfCached;
|
||||||
|
try {
|
||||||
|
rtfCached = new Intl.RelativeTimeFormat(loc, { numeric: "auto" });
|
||||||
|
rtfLocale = loc;
|
||||||
|
return rtfCached;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locales offered in settings, on top of "Automatic". */
|
||||||
|
export interface LocaleOption {
|
||||||
|
tag: string;
|
||||||
|
/** The locale's own name for itself, e.g. "Deutsch (Deutschland)". */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let optionsCache: LocaleOption[] | null = null;
|
||||||
|
let optionsExtras = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every locale ICU has data for, named in its own language and sorted by that
|
||||||
|
* name, plus whatever the server reported or the user already chose (so a tag
|
||||||
|
* outside the generated list is still selectable).
|
||||||
|
*/
|
||||||
|
export function localeOptions(): LocaleOption[] {
|
||||||
|
const extras = `${serverLocale ?? ""}|${prefs.locale}`;
|
||||||
|
if (optionsCache && optionsExtras === extras) return optionsCache;
|
||||||
|
const tags = new Set<string>(LOCALE_TAGS);
|
||||||
|
if (serverLocale) tags.add(serverLocale);
|
||||||
|
if (prefs.locale) tags.add(prefs.locale);
|
||||||
|
const list = [...tags].map((tag) => ({ tag, label: localeLabel(tag) }));
|
||||||
|
list.sort((a, b) => a.label.localeCompare(b.label, resolvedLocale()) || a.tag.localeCompare(b.tag));
|
||||||
|
optionsCache = list;
|
||||||
|
optionsExtras = extras;
|
||||||
|
return list;
|
||||||
|
}
|
||||||
+18
-16
@@ -1,4 +1,12 @@
|
|||||||
const rtf = typeof Intl !== "undefined" && "RelativeTimeFormat" in Intl ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) : null;
|
import {
|
||||||
|
formatClock,
|
||||||
|
formatDate,
|
||||||
|
formatDayMonth,
|
||||||
|
formatFullDateTime,
|
||||||
|
formatMonthYear as fmtMonthYear,
|
||||||
|
formatWeekday,
|
||||||
|
relativeFormat,
|
||||||
|
} from "./datetime";
|
||||||
|
|
||||||
export function formatSize(bytes: number | null | undefined): string {
|
export function formatSize(bytes: number | null | undefined): string {
|
||||||
if (bytes == null || !Number.isFinite(bytes)) return "";
|
if (bytes == null || !Number.isFinite(bytes)) return "";
|
||||||
@@ -22,24 +30,17 @@ export function formatListDate(iso: string | null | undefined, now = new Date())
|
|||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
if (isSameDay(d, now)) return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
if (isSameDay(d, now)) return formatClock(d);
|
||||||
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
if (d.getFullYear() === now.getFullYear()) return formatDayMonth(d);
|
||||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
return formatDate(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full date for message headers, e.g. "Sat, Aug 22, 2026, 3:14 PM" */
|
/** 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 {
|
export function formatFullDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return iso;
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
return d.toLocaleString(undefined, {
|
return formatFullDateTime(d);
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
|
export function formatRelative(iso: string | null | undefined, now = new Date()): string {
|
||||||
@@ -47,6 +48,7 @@ export function formatRelative(iso: string | null | undefined, now = new Date())
|
|||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const diff = (d.getTime() - now.getTime()) / 1000;
|
const diff = (d.getTime() - now.getTime()) / 1000;
|
||||||
const abs = Math.abs(diff);
|
const abs = Math.abs(diff);
|
||||||
|
const rtf = relativeFormat();
|
||||||
if (!rtf) return formatListDate(iso, now);
|
if (!rtf) return formatListDate(iso, now);
|
||||||
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
||||||
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
||||||
@@ -56,15 +58,15 @@ export function formatRelative(iso: string | null | undefined, now = new Date())
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateShort(d: Date): string {
|
export function formatDateShort(d: Date): string {
|
||||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
return `${formatWeekday(d)}, ${formatDayMonth(d)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(d: Date): string {
|
export function formatTime(d: Date): string {
|
||||||
return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
return formatClock(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMonthYear(d: Date): string {
|
export function formatMonthYear(d: Date): string {
|
||||||
return d.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
return fmtMonthYear(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function plural(n: number, one: string, many = `${one}s`): string {
|
export function plural(n: number, one: string, many = `${one}s`): string {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Every locale ICU/CLDR has real data for — generated by probing
|
||||||
|
* `Intl.DateTimeFormat(tag).resolvedOptions()` over the language, script and
|
||||||
|
* region subtag space and keeping the tags the resolver does not fold into a
|
||||||
|
* parent. Script variants appear only where they differ from the region's
|
||||||
|
* default script (so `sr-RS` and `sr-Latn-RS` are both listed, but
|
||||||
|
* `sr-Cyrl-RS` — the same thing as `sr-RS` — is not).
|
||||||
|
*/
|
||||||
|
export const LOCALE_TAGS: readonly string[] = [
|
||||||
|
"af-NA", "af-ZA", "agq-CM", "ak-GH", "am-ET", "ar-AE", "ar-BH", "ar-DJ",
|
||||||
|
"ar-DZ", "ar-EG", "ar-EH", "ar-ER", "ar-IL", "ar-IQ", "ar-JO", "ar-KM",
|
||||||
|
"ar-KW", "ar-LB", "ar-LY", "ar-MA", "ar-MR", "ar-OM", "ar-PS", "ar-QA",
|
||||||
|
"ar-SA", "ar-SD", "ar-SO", "ar-SS", "ar-SY", "ar-TD", "ar-TN", "ar-YE",
|
||||||
|
"ars", "as-IN", "asa-TZ", "ast-ES", "az-AZ", "az-Cyrl-AZ", "ba-RU", "bas-CM",
|
||||||
|
"be-BY", "bem-ZM", "bez-TZ", "bg-BG", "bgc-IN", "bho-IN", "blo-BJ", "bm-ML",
|
||||||
|
"bn-BD", "bn-IN", "bo-CN", "bo-IN", "br-FR", "brx-IN", "bs-BA", "bs-Cyrl-BA",
|
||||||
|
"bua-RU", "ca-AD", "ca-ES", "ca-FR", "ca-IT", "ccp-BD", "ccp-IN", "ce-RU",
|
||||||
|
"ceb-PH", "cgg-UG", "chr-US", "ckb-IQ", "ckb-IR", "cs-CZ", "csw-CA", "cv-RU",
|
||||||
|
"cy-GB", "da-DK", "da-GL", "dav-KE", "de-AT", "de-BE", "de-CH", "de-DE",
|
||||||
|
"de-IT", "de-LI", "de-LU", "dje-NE", "doi-IN", "dsb-DE", "dua-CM", "dyo-SN",
|
||||||
|
"dz-BT", "ebu-KE", "ee-GH", "ee-TG", "el-CY", "el-GR", "en-AE", "en-AG",
|
||||||
|
"en-AI", "en-AS", "en-AT", "en-AU", "en-BB", "en-BE", "en-BI", "en-BM",
|
||||||
|
"en-BS", "en-BW", "en-BZ", "en-CA", "en-CC", "en-CH", "en-CK", "en-CM",
|
||||||
|
"en-CX", "en-CY", "en-CZ", "en-DE", "en-DG", "en-DK", "en-DM", "en-EE",
|
||||||
|
"en-ER", "en-ES", "en-FI", "en-FJ", "en-FK", "en-FM", "en-FR", "en-GB",
|
||||||
|
"en-GD", "en-GE", "en-GG", "en-GH", "en-GI", "en-GM", "en-GS", "en-GU",
|
||||||
|
"en-GY", "en-HK", "en-HU", "en-ID", "en-IE", "en-IL", "en-IM", "en-IN",
|
||||||
|
"en-IO", "en-IT", "en-JE", "en-JM", "en-JP", "en-KE", "en-KI", "en-KN",
|
||||||
|
"en-KY", "en-LC", "en-LR", "en-LS", "en-LT", "en-LV", "en-MG", "en-MH",
|
||||||
|
"en-MO", "en-MP", "en-MS", "en-MT", "en-MU", "en-MV", "en-MW", "en-MY",
|
||||||
|
"en-NA", "en-NF", "en-NG", "en-NL", "en-NO", "en-NR", "en-NU", "en-NZ",
|
||||||
|
"en-PG", "en-PH", "en-PK", "en-PL", "en-PN", "en-PR", "en-PT", "en-PW",
|
||||||
|
"en-RO", "en-RW", "en-SB", "en-SC", "en-SD", "en-SE", "en-SG", "en-SH",
|
||||||
|
"en-SI", "en-SK", "en-SL", "en-SS", "en-SX", "en-SZ", "en-TC", "en-TK",
|
||||||
|
"en-TO", "en-TT", "en-TV", "en-TZ", "en-UA", "en-UG", "en-UM", "en-US",
|
||||||
|
"en-VC", "en-VG", "en-VI", "en-VU", "en-WS", "en-ZA", "en-ZM", "en-ZW",
|
||||||
|
"eo", "es-AR", "es-BO", "es-BR", "es-BZ", "es-CL", "es-CO", "es-CR",
|
||||||
|
"es-CU", "es-DO", "es-EA", "es-EC", "es-ES", "es-GQ", "es-GT", "es-HN",
|
||||||
|
"es-IC", "es-MX", "es-NI", "es-PA", "es-PE", "es-PH", "es-PR", "es-PY",
|
||||||
|
"es-SV", "es-US", "es-UY", "es-VE", "et-EE", "eu-ES", "ewo-CM", "fa-AF",
|
||||||
|
"fa-IR", "ff-Adlm-BF", "ff-Adlm-CM", "ff-Adlm-GH", "ff-Adlm-GM", "ff-Adlm-GN", "ff-Adlm-GW", "ff-Adlm-LR",
|
||||||
|
"ff-Adlm-MR", "ff-Adlm-NE", "ff-Adlm-NG", "ff-Adlm-SL", "ff-Adlm-SN", "ff-BF", "ff-CM", "ff-GH",
|
||||||
|
"ff-GM", "ff-GN", "ff-GW", "ff-LR", "ff-MR", "ff-NE", "ff-NG", "ff-SL",
|
||||||
|
"ff-SN", "fi-FI", "fil-PH", "fo-DK", "fo-FO", "fr-BE", "fr-BF", "fr-BI",
|
||||||
|
"fr-BJ", "fr-BL", "fr-CA", "fr-CD", "fr-CF", "fr-CG", "fr-CH", "fr-CI",
|
||||||
|
"fr-CM", "fr-DJ", "fr-DZ", "fr-FR", "fr-GA", "fr-GF", "fr-GN", "fr-GP",
|
||||||
|
"fr-GQ", "fr-HT", "fr-KM", "fr-LU", "fr-MA", "fr-MC", "fr-MF", "fr-MG",
|
||||||
|
"fr-ML", "fr-MQ", "fr-MR", "fr-MU", "fr-NC", "fr-NE", "fr-PF", "fr-PM",
|
||||||
|
"fr-RE", "fr-RW", "fr-SC", "fr-SN", "fr-SY", "fr-TD", "fr-TG", "fr-TN",
|
||||||
|
"fr-VU", "fr-WF", "fr-YT", "fur-IT", "fy-NL", "ga-GB", "ga-IE", "gaa-GH",
|
||||||
|
"gd-GB", "gl-ES", "gsw-CH", "gsw-FR", "gsw-LI", "gu-IN", "guz-KE", "gv-IM",
|
||||||
|
"ha-GH", "ha-NE", "ha-NG", "haw-US", "he-IL", "hi-IN", "hi-Latn-IN", "hr-BA",
|
||||||
|
"hr-HR", "hsb-DE", "hu-HU", "hy-AM", "ia", "id-ID", "ie-EE", "ig-NG",
|
||||||
|
"ii-CN", "is-IS", "it-CH", "it-IT", "it-SM", "it-VA", "ja-JP", "jgo-CM",
|
||||||
|
"jmc-TZ", "jv-ID", "ka-GE", "kab-DZ", "kam-KE", "kde-TZ", "kea-CV", "kgp-BR",
|
||||||
|
"khq-ML", "ki-KE", "kk-CN", "kk-KZ", "kkj-CM", "kl-GL", "kln-KE", "km-KH",
|
||||||
|
"kn-IN", "ko-CN", "ko-KP", "ko-KR", "kok-IN", "kok-Latn-IN", "ks-Deva-IN", "ks-IN",
|
||||||
|
"ksb-TZ", "ksf-CM", "ksh-DE", "ku-IQ", "ku-Latn-IQ", "ku-SY", "ku-TR", "kw-GB",
|
||||||
|
"kxv-Deva-IN", "kxv-IN", "kxv-Orya-IN", "kxv-Telu-IN", "ky-KG", "lag-TZ", "lb-LU", "lg-UG",
|
||||||
|
"lij-IT", "lkt-US", "lmo-IT", "ln-AO", "ln-CD", "ln-CF", "ln-CG", "lo-LA",
|
||||||
|
"lrc-IQ", "lrc-IR", "lt-LT", "lu-CD", "luo-KE", "luy-KE", "lv-LV", "mai-IN",
|
||||||
|
"mas-KE", "mas-TZ", "mer-KE", "mfe-MU", "mg-MG", "mgh-MZ", "mgo-CM", "mi-NZ",
|
||||||
|
"mk-MK", "ml-IN", "mn-MN", "mni-IN", "mr-IN", "ms-BN", "ms-ID", "ms-MY",
|
||||||
|
"ms-SG", "mt-MT", "mua-CM", "my-MM", "mzn-IR", "naq-NA", "nb", "nd-ZW",
|
||||||
|
"nds-DE", "nds-NL", "ne-IN", "ne-NP", "nl-AW", "nl-BE", "nl-BQ", "nl-CW",
|
||||||
|
"nl-NL", "nl-SR", "nl-SX", "nmg-CM", "nn-NO", "nnh-CM", "no-NO", "nqo-GN",
|
||||||
|
"nso-ZA", "nus-SS", "nyn-UG", "oc-ES", "oc-FR", "om-ET", "om-KE", "or-IN",
|
||||||
|
"os-GE", "os-RU", "pa-IN", "pa-PK", "pcm-NG", "pl-PL", "pms-IT", "prg-PL",
|
||||||
|
"ps-AF", "ps-PK", "pt-AO", "pt-BR", "pt-CH", "pt-CV", "pt-GQ", "pt-GW",
|
||||||
|
"pt-LU", "pt-MO", "pt-MZ", "pt-PT", "pt-ST", "pt-TL", "qu-BO", "qu-EC",
|
||||||
|
"qu-PE", "raj-IN", "rm-CH", "rn-BI", "ro-MD", "ro-RO", "rof-TZ", "ru-BY",
|
||||||
|
"ru-KG", "ru-KZ", "ru-MD", "ru-RU", "ru-UA", "rw-RW", "rwk-TZ", "sa-IN",
|
||||||
|
"sah-RU", "saq-KE", "sat-IN", "sbp-TZ", "sc-IT", "scn-IT", "sd-IN", "sd-PK",
|
||||||
|
"se-FI", "se-NO", "se-SE", "seh-MZ", "ses-ML", "sg-CF", "shi-Latn-MA", "shi-MA",
|
||||||
|
"shn-MM", "shn-TH", "si-LK", "sk-SK", "sl-SI", "smn-FI", "sn-ZW", "so-DJ",
|
||||||
|
"so-ET", "so-KE", "so-SO", "sq-AL", "sq-MK", "sq-XK", "sr-BA", "sr-Cyrl-ME",
|
||||||
|
"sr-Latn-BA", "sr-Latn-RS", "sr-Latn-XK", "sr-ME", "sr-RS", "sr-XK", "st-LS", "st-ZA",
|
||||||
|
"su-ID", "sv-AX", "sv-FI", "sv-SE", "sw-CD", "sw-KE", "sw-TZ", "sw-UG",
|
||||||
|
"syr-IQ", "syr-SY", "szl-PL", "ta-IN", "ta-LK", "ta-MY", "ta-SG", "te-IN",
|
||||||
|
"teo-KE", "teo-UG", "tg-TJ", "th-TH", "ti-ER", "ti-ET", "tk-TM", "tn-BW",
|
||||||
|
"tn-ZA", "to-TO", "tok", "tr-CY", "tr-TR", "tt-RU", "twq-NE", "tyv-RU",
|
||||||
|
"tzm-MA", "ug-CN", "uk-UA", "ur-IN", "ur-PK", "uz-AF", "uz-Cyrl-UZ", "uz-UZ",
|
||||||
|
"vai-LR", "vai-Latn-LR", "vec-IT", "vi-VN", "vmw-MZ", "vun-TZ", "wae-CH", "wo-SN",
|
||||||
|
"xh-ZA", "xnr-IN", "xog-UG", "yav-CM", "yi-UA", "yo-BJ", "yo-NG", "yrl-BR",
|
||||||
|
"yrl-CO", "yrl-VE", "yue-CN", "yue-HK", "yue-Hant-CN", "yue-MO", "za-CN", "zgh-MA",
|
||||||
|
"zh-CN", "zh-HK", "zh-Hans-HK", "zh-Hans-MO", "zh-Hant-MY", "zh-MO", "zh-MY", "zh-SG",
|
||||||
|
"zh-TW", "zu-ZA",
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
|
||||||
import type { Id, JmapSession } from "@/jmap/types";
|
import type { Id, JmapSession } from "@/jmap/types";
|
||||||
import { push } from "@/jmap/push";
|
import { push } from "@/jmap/push";
|
||||||
|
import { setServerLocale } from "@/lib/datetime";
|
||||||
|
|
||||||
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
export type AuthStatus = "loading" | "anonymous" | "authenticated";
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
push.stop();
|
push.stop();
|
||||||
|
setServerLocale(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -62,6 +64,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
|
||||||
client.session = s;
|
client.session = s;
|
||||||
|
setServerLocale(s.ihasmail?.userLocale);
|
||||||
set({ session: s });
|
set({ session: s });
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -83,6 +86,7 @@ export const useSession = create<SessionState>((set, get) => ({
|
|||||||
|
|
||||||
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||||
client.session = s;
|
client.session = s;
|
||||||
|
setServerLocale(s.ihasmail?.userLocale);
|
||||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||||
set({ status: "authenticated", session: s, accountId, error: null });
|
set({ status: "authenticated", session: s, accountId, error: null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { loadJson, saveJson } from "@/lib/storage";
|
import { loadJson, saveJson } from "@/lib/storage";
|
||||||
|
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||||
|
|
||||||
export type Theme = "system" | "light" | "dark";
|
export type Theme = "system" | "light" | "dark";
|
||||||
export type Density = "comfortable" | "cozy" | "compact";
|
export type Density = "comfortable" | "cozy" | "compact";
|
||||||
@@ -36,14 +37,16 @@ export interface Settings {
|
|||||||
notificationSound: boolean;
|
notificationSound: boolean;
|
||||||
attachmentReminder: boolean;
|
attachmentReminder: boolean;
|
||||||
weekStart: 0 | 1 | 6;
|
weekStart: 0 | 1 | 6;
|
||||||
timeFormat: "12" | "24" | "auto";
|
/** "" = follow the mail server's locale, then the browser's. */
|
||||||
|
locale: string;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
timeFormat: TimeFormat;
|
||||||
calendarDefaultView: "month" | "week" | "day" | "agenda";
|
calendarDefaultView: "month" | "week" | "day" | "agenda";
|
||||||
workDayStart: number;
|
workDayStart: number;
|
||||||
workDayEnd: number;
|
workDayEnd: number;
|
||||||
defaultEventDuration: number; // minutes
|
defaultEventDuration: number; // minutes
|
||||||
defaultAlertMinutes: number;
|
defaultAlertMinutes: number;
|
||||||
timeZone: string | null; // null = browser
|
timeZone: string | null; // null = browser
|
||||||
language: string;
|
|
||||||
labelsSidebar: boolean;
|
labelsSidebar: boolean;
|
||||||
fontSize: "small" | "medium" | "large";
|
fontSize: "small" | "medium" | "large";
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
@@ -87,6 +90,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
notificationSound: false,
|
notificationSound: false,
|
||||||
attachmentReminder: true,
|
attachmentReminder: true,
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
|
locale: "",
|
||||||
|
dateFormat: "auto",
|
||||||
timeFormat: "auto",
|
timeFormat: "auto",
|
||||||
calendarDefaultView: "week",
|
calendarDefaultView: "week",
|
||||||
workDayStart: 8,
|
workDayStart: 8,
|
||||||
@@ -94,7 +99,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
defaultEventDuration: 60,
|
defaultEventDuration: 60,
|
||||||
defaultAlertMinutes: 10,
|
defaultAlertMinutes: 10,
|
||||||
timeZone: null,
|
timeZone: null,
|
||||||
language: "en",
|
|
||||||
labelsSidebar: true,
|
labelsSidebar: true,
|
||||||
fontSize: "medium",
|
fontSize: "medium",
|
||||||
templates: [],
|
templates: [],
|
||||||
@@ -126,18 +130,23 @@ interface SettingsState {
|
|||||||
importJson(json: string): boolean;
|
importJson(json: string): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
|
||||||
|
applyDateTimePrefs(initialSettings);
|
||||||
|
|
||||||
export const useSettings = create<SettingsState>((set, get) => ({
|
export const useSettings = create<SettingsState>((set, get) => ({
|
||||||
settings: loadJson<Settings>("settings", DEFAULT_SETTINGS),
|
settings: initialSettings,
|
||||||
update(patch) {
|
update(patch) {
|
||||||
const settings = { ...get().settings, ...patch };
|
const settings = { ...get().settings, ...patch };
|
||||||
saveJson("settings", settings);
|
saveJson("settings", settings);
|
||||||
set({ settings });
|
set({ settings });
|
||||||
applyTheme(settings);
|
applyTheme(settings);
|
||||||
|
applyDateTimePrefs(settings);
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
saveJson("settings", DEFAULT_SETTINGS);
|
saveJson("settings", DEFAULT_SETTINGS);
|
||||||
set({ settings: DEFAULT_SETTINGS });
|
set({ settings: DEFAULT_SETTINGS });
|
||||||
applyTheme(DEFAULT_SETTINGS);
|
applyTheme(DEFAULT_SETTINGS);
|
||||||
|
applyDateTimePrefs(DEFAULT_SETTINGS);
|
||||||
},
|
},
|
||||||
exportJson() {
|
exportJson() {
|
||||||
return JSON.stringify(get().settings, null, 2);
|
return JSON.stringify(get().settings, null, 2);
|
||||||
@@ -153,6 +162,10 @@ export const useSettings = create<SettingsState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function applyDateTimePrefs(s: Settings): void {
|
||||||
|
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||||
|
}
|
||||||
|
|
||||||
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||||
@@ -171,3 +184,9 @@ if (typeof window !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const settings = () => useSettings.getState().settings;
|
export const settings = () => useSettings.getState().settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primitive that changes whenever a date/time preference does, so memoised
|
||||||
|
* components that render dates re-render when the format is switched.
|
||||||
|
*/
|
||||||
|
export const dateTimeKey = (s: Settings): string => `${s.locale}|${s.dateFormat}|${s.timeFormat}`;
|
||||||
|
|||||||
@@ -664,7 +664,7 @@ img { max-width: 100%; }
|
|||||||
.month-cell:last-child { border-right: 0; }
|
.month-cell:last-child { border-right: 0; }
|
||||||
.month-cell.other { background: var(--bg-sunken); color: var(--fg-faint); }
|
.month-cell.other { background: var(--bg-sunken); color: var(--fg-faint); }
|
||||||
.month-cell:hover { background: var(--bg-hover); }
|
.month-cell:hover { background: var(--bg-hover); }
|
||||||
.month-cell .day-num { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: .9em; font-weight: 500; align-self: flex-start; }
|
.month-cell .day-num { min-width: 26px; height: 26px; padding: 0 4px; display: flex; align-items: center; justify-content: center; border-radius: 999px; font-size: .9em; font-weight: 500; align-self: flex-start; white-space: nowrap; }
|
||||||
.month-cell.today .day-num { background: var(--accent); color: var(--accent-fg); font-weight: 700; }
|
.month-cell.today .day-num { background: var(--accent); color: var(--accent-fg); font-weight: 700; }
|
||||||
.month-cell .more { font-size: .78em; color: var(--fg-muted); padding-left: 4px; font-weight: 600; }
|
.month-cell .more { font-size: .78em; color: var(--fg-muted); padding-left: 4px; font-weight: 600; }
|
||||||
.ev-chip { display: flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 4px; font-size: .8em; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: #fff; font-weight: 500; flex: 0 0 auto; border: 1px solid transparent; }
|
.ev-chip { display: flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 4px; font-size: .8em; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: #fff; font-weight: 500; flex: 0 0 auto; border: 1px solid transparent; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation } from "wouter";
|
|||||||
import type { CalendarEvent } from "@/jmap/types";
|
import type { CalendarEvent } from "@/jmap/types";
|
||||||
import { useCalendar, type EventInstance } from "@/store/calendar";
|
import { useCalendar, type EventInstance } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
|
import { formatDayMonth } from "@/lib/datetime";
|
||||||
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
import { MenuItem, MenuSep, MenuTitle, Popover, type Anchor } from "@/ui/popover";
|
||||||
import { CALENDAR_COLORS } from "@/ui/misc";
|
import { CALENDAR_COLORS } from "@/ui/misc";
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
@@ -48,7 +49,7 @@ export function CalendarContextMenu({ ctx, onClose, onOpen, onEdit, onCreate }:
|
|||||||
const { start, end, allDay } = ctx;
|
const { start, end, allDay } = ctx;
|
||||||
return (
|
return (
|
||||||
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
<Popover anchor={ctx.anchor} onClose={onClose} width={240}>
|
||||||
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${start.toLocaleDateString(undefined, { month: "short", day: "numeric" })}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
<MenuItem icon={<Plus size={16} />} label={allDay ? `New all-day event on ${formatDayMonth(start)}` : `New event at ${formatTime(start)}`} onClick={() => onCreate(start, end, allDay)} />
|
||||||
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
|
{!allDay && <MenuItem icon={<CalendarDays size={16} />} label="New all-day event" onClick={() => { const d = new Date(start); d.setHours(0, 0, 0, 0); onCreate(d, new Date(d.getTime() + 86400000), true); }} />}
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
<MenuItem icon={<CalIcon size={16} />} label="Go to day" onClick={() => navigate(`/calendar/day/${toLocalDateOnly(start)}`)} />
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useMemo, useState } from "react";
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star } from "lucide-react";
|
||||||
import { useCalendar } from "@/store/calendar";
|
import { useCalendar } from "@/store/calendar";
|
||||||
import { useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
|
||||||
import { formatMonthYear } from "@/lib/format";
|
import { formatMonthYear } from "@/lib/format";
|
||||||
|
import { formatWeekday } from "@/lib/datetime";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog } from "@/ui/dialog";
|
import { confirmDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
@@ -16,6 +17,7 @@ export function CalendarSidebar() {
|
|||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const cal = useCalendar();
|
const cal = useCalendar();
|
||||||
const weekStart = useSettings((s) => s.settings.weekStart);
|
const weekStart = useSettings((s) => s.settings.weekStart);
|
||||||
|
const locale = useSettings((s) => dateTimeKey(s.settings));
|
||||||
const parts = location.split("/");
|
const parts = location.split("/");
|
||||||
const view = parts[2] || "week";
|
const view = parts[2] || "week";
|
||||||
const dateStr = parts[3];
|
const dateStr = parts[3];
|
||||||
@@ -27,10 +29,7 @@ export function CalendarSidebar() {
|
|||||||
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
const [editCal, setEditCal] = useState<Partial<Calendar> | null>(null);
|
||||||
const [share, setShare] = useState<Calendar | null>(null);
|
const [share, setShare] = useState<Calendar | null>(null);
|
||||||
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
|
const instances = cal.instancesIn(grid[0]!, new Date(grid[41]!.getTime() + 86400000));
|
||||||
const dow = useMemo(() => {
|
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
|
||||||
const names = ["S", "M", "T", "W", "T", "F", "S"];
|
|
||||||
return [...Array(7)].map((_, i) => names[(weekStart + i) % 7]);
|
|
||||||
}, [weekStart]);
|
|
||||||
|
|
||||||
if (!cal.available) return null;
|
if (!cal.available) return null;
|
||||||
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCalendar, type EventInstance } from "@/store/calendar";
|
|||||||
import { useSettings } from "@/store/settings";
|
import { useSettings } from "@/store/settings";
|
||||||
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates";
|
||||||
import { formatMonthYear, formatTime } from "@/lib/format";
|
import { formatMonthYear, formatTime } from "@/lib/format";
|
||||||
|
import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime";
|
||||||
import { Empty, useIsMobile } from "@/ui/misc";
|
import { Empty, useIsMobile } from "@/ui/misc";
|
||||||
import { keyboard } from "@/lib/keyboard";
|
import { keyboard } from "@/lib/keyboard";
|
||||||
import { EventPopover } from "./EventPopover";
|
import { EventPopover } from "./EventPopover";
|
||||||
@@ -95,9 +96,9 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
|
|||||||
|
|
||||||
const title =
|
const title =
|
||||||
effectiveView === "month" ? formatMonthYear(anchor)
|
effectiveView === "month" ? formatMonthYear(anchor)
|
||||||
: effectiveView === "week" ? `${range.start.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${addDays(range.end, -1).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`
|
: effectiveView === "week" ? `${formatDayMonth(range.start)} – ${formatDate(addDays(range.end, -1))}`
|
||||||
: effectiveView === "day" ? anchor.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
|
: effectiveView === "day" ? formatWeekdayDate(anchor, true)
|
||||||
: `Agenda from ${anchor.toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
|
: `Agenda from ${formatDayMonth(anchor)}`;
|
||||||
|
|
||||||
const onEvent = (inst: EventInstance, el: Element) => {
|
const onEvent = (inst: EventInstance, el: Element) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
@@ -152,7 +153,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
|
||||||
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
|
||||||
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
|
||||||
const dow = weeks[0]!.map((d) => d.toLocaleDateString(undefined, { weekday: "short" }));
|
const dow = weeks[0]!.map((d) => formatWeekday(d));
|
||||||
const maxPer = 4;
|
const maxPer = 4;
|
||||||
return (
|
return (
|
||||||
<div className="month-grid">
|
<div className="month-grid">
|
||||||
@@ -165,7 +166,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
|
|||||||
const shown = evs.slice(0, maxPer);
|
const shown = evs.slice(0, maxPer);
|
||||||
return (
|
return (
|
||||||
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
|
||||||
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.getDate()}</span>
|
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
|
||||||
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)}
|
||||||
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
|
{evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>+{evs.length - maxPer} more</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +245,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
<div />
|
<div />
|
||||||
{days.map((d) => (
|
{days.map((d) => (
|
||||||
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
|
<div key={d.toISOString()} className={`wh-day ${isToday(d) ? "today" : ""}`} onClick={() => onDayHeader(d)}>
|
||||||
<div className="dow">{d.toLocaleDateString(undefined, { weekday: "short" })}</div>
|
<div className="dow">{formatWeekday(d)}</div>
|
||||||
<div className="dnum">{d.getDate()}</div>
|
<div className="dnum">{d.getDate()}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -260,7 +261,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
|
|||||||
<div className="week-scroll" ref={scrollRef}>
|
<div className="week-scroll" ref={scrollRef}>
|
||||||
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
|
<div className="week-body" style={{ "--hour-h": `${HOUR_H}px` } as React.CSSProperties}>
|
||||||
<div className="time-col">
|
<div className="time-col">
|
||||||
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: "numeric" })}</span>)}
|
{[...Array(24)].map((_, h) => h > 0 && <span key={h} className="hour-label" style={{ top: h * HOUR_H }}>{formatHourLabel(h)}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{days.map((d) => {
|
{days.map((d) => {
|
||||||
const evs = layoutOverlaps(timed(d), d);
|
const evs = layoutOverlaps(timed(d), d);
|
||||||
@@ -400,8 +401,8 @@ function AgendaView({ start, onEvent, onEventContext }: { start: Date; onEvent:
|
|||||||
{byDay.map(({ day, items }) => (
|
{byDay.map(({ day, items }) => (
|
||||||
<div key={day.toISOString()} className="agenda-day">
|
<div key={day.toISOString()} className="agenda-day">
|
||||||
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
|
<div className={`ad-date ${isToday(day) ? "today" : ""}`}>
|
||||||
{day.toLocaleDateString(undefined, { weekday: "long" })}
|
{formatWeekday(day, "long")}
|
||||||
<small>{day.toLocaleDateString(undefined, { month: "long", day: "numeric" })}</small>
|
<small>{formatDateLong(day, false)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{items.map((i) => (
|
{items.map((i) => (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ColorSwatches, Switch } from "@/ui/misc";
|
|||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { RecipientInput } from "../compose/RecipientInput";
|
import { RecipientInput } from "../compose/RecipientInput";
|
||||||
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
import { browserTimeZone, dateToZonedLocal, formatDuration, fromInputDateTime, listTimeZones, parseDuration, toInputDateTime, toLocalDateOnly, zonedToDate, DAY_MS, humanDuration } from "@/lib/dates";
|
||||||
|
import { formatClock, formatNumericDate, formatWeekday } from "@/lib/datetime";
|
||||||
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
import { WEEKDAYS, describeRule, presetFor, ruleFromPreset, type RecurrencePreset } from "@/lib/recurrence";
|
||||||
import { newKey } from "@/lib/contacts";
|
import { newKey } from "@/lib/contacts";
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
<select className="select" style={{ width: "auto", height: 32 }} value={preset} onChange={(e) => { const p = e.target.value as RecurrencePreset; setPreset(p); if (p === "custom") setRule(rule ?? { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ "@type": "NDay", day: WEEKDAYS[(start.getDay() + 6) % 7]!.key }] }); else setRule(ruleFromPreset(p, start)); }}>
|
||||||
<option value="none">Does not repeat</option>
|
<option value="none">Does not repeat</option>
|
||||||
<option value="daily">Daily</option>
|
<option value="daily">Daily</option>
|
||||||
<option value="weekly">Weekly on {start.toLocaleDateString(undefined, { weekday: "long" })}</option>
|
<option value="weekly">Weekly on {formatWeekday(start, "long")}</option>
|
||||||
<option value="weekdays">Every weekday</option>
|
<option value="weekdays">Every weekday</option>
|
||||||
<option value="monthly">Monthly on day {start.getDate()}</option>
|
<option value="monthly">Monthly on day {start.getDate()}</option>
|
||||||
<option value="yearly">Yearly</option>
|
<option value="yearly">Yearly</option>
|
||||||
@@ -282,7 +283,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
|
<Switch checked={sendInvites} onChange={setSendInvites} label="Send invitation emails to guests" />
|
||||||
{Object.keys(fb).length > 0 && (
|
{Object.keys(fb).length > 0 && (
|
||||||
<div className="freebusy">
|
<div className="freebusy">
|
||||||
<div className="hint">Availability on {start.toLocaleDateString()}</div>
|
<div className="hint">Availability on {formatNumericDate(start)}</div>
|
||||||
{attendees.filter((a) => fb[a.email]).map((a) => (
|
{attendees.filter((a) => fb[a.email]).map((a) => (
|
||||||
<div key={a.email} className="fb-row">
|
<div key={a.email} className="fb-row">
|
||||||
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
|
<span className="truncate" style={{ width: 140 }}>{a.name ?? a.email}</span>
|
||||||
@@ -291,7 +292,7 @@ function EventForm({ init, base, editing, onClose, settingsTz, defaultAlert, myE
|
|||||||
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
|
const bs = Math.max(new Date(b.utcStart).getTime(), dayWindow.ds.getTime());
|
||||||
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
|
const be = Math.min(new Date(b.utcEnd).getTime(), dayWindow.de.getTime());
|
||||||
if (be <= bs) return null;
|
if (be <= bs) return null;
|
||||||
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${new Date(b.utcStart).toLocaleTimeString()} – ${new Date(b.utcEnd).toLocaleTimeString()}`} />;
|
return <span key={i} className="fb-busy" style={{ left: `${((bs - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((be - bs) / DAY_MS) * 100}%` }} title={`${b.busyStatus}: ${formatClock(new Date(b.utcStart))} – ${formatClock(new Date(b.utcEnd))}`} />;
|
||||||
})}
|
})}
|
||||||
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
|
{!allDay && <span className="fb-window" style={{ left: `${((start.getTime() - dayWindow.ds.getTime()) / DAY_MS) * 100}%`, width: `${((end.getTime() - start.getTime()) / DAY_MS) * 100}%` }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useContacts } from "@/store/contacts";
|
|||||||
import { useCompose } from "@/store/compose";
|
import { useCompose } from "@/store/compose";
|
||||||
import type { AddressBook, ContactCard } from "@/jmap/types";
|
import type { AddressBook, ContactCard } from "@/jmap/types";
|
||||||
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
import { contactDisplayName, contactEmails, contactPhoto, formatAddressLines, sortKey, toVCard } from "@/lib/contacts";
|
||||||
|
import { formatDate, formatDateLong } from "@/lib/datetime";
|
||||||
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
import { Avatar, Empty, Spinner, useIsNarrow } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
@@ -237,14 +238,14 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
|
{c.keywords && Object.keys(c.keywords).length > 0 && <div className="row wrap gap-4 mt-8">{Object.keys(c.keywords).map((k) => <span key={k} className="chip"><Pin size={12} /> {k}</span>)}</div>}
|
||||||
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {new Date(c.updated).toLocaleDateString()}</p>}
|
{c.updated && <p className="hint mt-16"><CalIcon size={12} /> Updated {formatDate(new Date(c.updated))}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string {
|
function fmtPartial(d: { year?: number; month?: number; day?: number; utc?: string }): string {
|
||||||
if (d.utc) return new Date(d.utc).toLocaleDateString();
|
if (d.utc) return formatDate(new Date(d.utc));
|
||||||
if (d.year && d.month && d.day) return new Date(d.year, d.month - 1, d.day).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
if (d.year && d.month && d.day) return formatDateLong(new Date(d.year, d.month - 1, d.day));
|
||||||
if (d.month && d.day) return new Date(2000, d.month - 1, d.day).toLocaleDateString(undefined, { month: "long", day: "numeric" });
|
if (d.month && d.day) return formatDateLong(new Date(2000, d.month - 1, d.day), false);
|
||||||
return [d.year, d.month, d.day].filter(Boolean).join("-");
|
return [d.year, d.month, d.day].filter(Boolean).join("-");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
|||||||
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
|
import { Archive, ArrowLeft, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck } from "lucide-react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { useMail, type ListState } from "@/store/mail";
|
import { useMail, type ListState } from "@/store/mail";
|
||||||
import { useSettings } from "@/store/settings";
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import type { Email, Id } from "@/jmap/types";
|
import type { Email, Id } from "@/jmap/types";
|
||||||
import { formatListDate } from "@/lib/format";
|
import { formatListDate } from "@/lib/format";
|
||||||
import { displayName, shortName } from "@/lib/address";
|
import { displayName, shortName } from "@/lib/address";
|
||||||
@@ -327,6 +327,8 @@ interface RowProps {
|
|||||||
|
|
||||||
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
|
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead }: RowProps) {
|
||||||
const labels = useSettings((s) => s.settings.labels);
|
const labels = useSettings((s) => s.settings.labels);
|
||||||
|
// Subscribed purely so the row re-renders when the date format changes.
|
||||||
|
useSettings((s) => dateTimeKey(s.settings));
|
||||||
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
|
||||||
const scope = inScope.length ? inScope : [e];
|
const scope = inScope.length ? inScope : [e];
|
||||||
const unread = scope.some((x) => !x.keywords.$seen);
|
const unread = scope.some((x) => !x.keywords.$seen);
|
||||||
|
|||||||
@@ -2,6 +2,28 @@ import { useSettings } from "@/store/settings";
|
|||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||||
import { toast } from "@/ui/toast";
|
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() {
|
export function GeneralSettings() {
|
||||||
const s = useSettings((st) => st.settings);
|
const s = useSettings((st) => st.settings);
|
||||||
@@ -9,6 +31,8 @@ export function GeneralSettings() {
|
|||||||
const reset = useSettings((st) => st.reset);
|
const reset = useSettings((st) => st.reset);
|
||||||
const exportJson = useSettings((st) => st.exportJson);
|
const exportJson = useSettings((st) => st.exportJson);
|
||||||
const importJson = useSettings((st) => st.importJson);
|
const importJson = useSettings((st) => st.importJson);
|
||||||
|
const serverLocale = getServerLocale();
|
||||||
|
const autoLocale = serverLocale ?? browserLocale();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -100,6 +124,35 @@ export function GeneralSettings() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2>Backup</h2>
|
||||||
<div className="row wrap">
|
<div className="row wrap">
|
||||||
@@ -113,3 +166,4 @@ export function GeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useMail } from "@/store/mail";
|
|||||||
import { Switch } from "@/ui/misc";
|
import { Switch } from "@/ui/misc";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
import { toInputDateTime, fromInputDateTime, toUTCDate } from "@/lib/dates";
|
||||||
|
import { formatFullDateTime } from "@/lib/datetime";
|
||||||
|
import { dateTimeKey, useSettings } from "@/store/settings";
|
||||||
import { client, CAP } from "@/jmap/client";
|
import { client, CAP } from "@/jmap/client";
|
||||||
|
|
||||||
export function VacationSettings() {
|
export function VacationSettings() {
|
||||||
@@ -16,6 +18,13 @@ export function VacationSettings() {
|
|||||||
const [to, setTo] = useState("");
|
const [to, setTo] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const available = client.hasCapability(CAP.vacation);
|
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(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -56,8 +65,16 @@ export function VacationSettings() {
|
|||||||
<p className="lead">Automatically reply to people who email you while you're away. Each sender gets at most one reply.</p>
|
<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" />
|
<Switch checked={enabled} onChange={setEnabled} label="Auto-reply enabled" />
|
||||||
<div className="field-row mt-16">
|
<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)} /></div>
|
<div className="field">
|
||||||
<div className="field"><label>Ends (optional)</label><input className="input" type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} /></div>
|
<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>
|
||||||
<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>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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user