Files
ihasmail/web/src/store/session.ts
T
jcoffey-dev d82ff15921 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
2026-08-23 12:32:11 -07:00

105 lines
3.1 KiB
TypeScript

import { create } from "zustand";
import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types";
import { push } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
interface SessionState {
status: AuthStatus;
session: JmapSession | null;
/** Selected mail account (defaults to primary). */
accountId: Id | null;
error: string | null;
pushConnected: boolean;
bootstrap(): Promise<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
logout(): Promise<void>;
refresh(): Promise<void>;
setAccount(id: Id): void;
/** Returns the accountId for a capability (primary), falling back to the selected mail account. */
accountFor(cap: string): Id | null;
}
export const useSession = create<SessionState>((set, get) => ({
status: "loading",
session: null,
accountId: null,
error: null,
pushConnected: false,
async bootstrap() {
try {
const s = await apiFetch<JmapSession>("/api/auth/session");
applySession(s, set);
} catch (err) {
if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null });
else set({ status: "anonymous", error: (err as Error).message });
}
},
async login(username, password, totp, remember) {
set({ error: null });
const s = await apiFetch<JmapSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password, totp: totp || undefined, remember }),
});
applySession(s, set);
},
async logout() {
push.stop();
setServerLocale(null);
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
client.session = null;
set({ status: "anonymous", session: null, accountId: null });
},
async refresh() {
try {
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
set({ session: s });
} catch {
/* ignore */
}
},
setAccount(id) {
set({ accountId: id });
},
accountFor(cap) {
const s = get().session;
if (!s) return null;
const selected = get().accountId;
if (selected && s.accounts[selected] && cap in (s.accounts[selected]?.accountCapabilities ?? {})) return selected;
return s.primaryAccounts[cap] ?? selected ?? null;
},
}));
function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
set({ status: "authenticated", session: s, accountId, error: null });
}
client.onUnauthenticated(() => {
push.stop();
client.session = null;
useSession.setState({ status: "anonymous", session: null, accountId: null });
});
push.onConnection((connected) => useSession.setState({ pushConnected: connected }));
export function hasCap(cap: string): boolean {
return client.hasCapability(cap);
}