Files
webmail/lib/timezone.ts
T
Linus Rath cf2c27157d Merge branch 'main' into feat/stalwart-1.0
# Conflicts:
#	components/contacts/contacts-app.tsx
#	components/email/__tests__/composer-discard.test.tsx
#	components/email/calendar-invitation-banner.tsx
#	components/email/email-composer.tsx
#	components/mail/mail-app.tsx
#	components/pro/pro-compose-tab-body.tsx
#	components/pro/pro-email-tab-body.tsx
#	components/ui/__tests__/avatar.test.tsx
#	components/ui/avatar.tsx
#	hooks/use-mailbox-drop.ts
#	lib/__tests__/calendar-alerts.test.ts
#	lib/__tests__/jmap-calendar-tasks.test.ts
#	lib/__tests__/jmap-send-threading.test.ts
#	lib/demo/demo-client.ts
#	lib/demo/fixtures/calendars.ts
#	lib/jmap/client-interface.ts
#	lib/jmap/client.ts
#	lib/web-push.ts
#	stores/__tests__/contact-store.test.ts
#	stores/__tests__/email-store-thread-key.test.ts
#	stores/calendar-store.ts
#	stores/contact-store.ts
#	stores/email-store.ts
2026-10-26 10:14:12 +01:00

329 lines
13 KiB
TypeScript

import { isSameDay } from "date-fns";
import { useSettingsStore } from "@/stores/settings-store";
/**
* User-selectable display time zone (#755).
*
* Privacy-hardened browsers (LibreWolf, Tor Browser, resistFingerprinting)
* report UTC to every page, so `Intl.DateTimeFormat().resolvedOptions()`
* cannot be trusted as "the user's zone". The `timeZone` setting lets the
* user pin an IANA zone that overrides browser detection everywhere we turn
* an instant into a wall-clock time: mail timestamps, the calendar grid,
* new-event zones and the `timeZone` argument on JMAP calendar queries.
*
* Two families of helpers live here:
*
* - `getEffectiveTimeZone()` for code that formats a real instant with Intl
* (`toLocaleString(..., { timeZone })`). Nothing else changes.
* - `toDisplayDate()` / `fromDisplayDate()` for the calendar, whose grid math
* runs on the *local* getters of `Date` (`getHours()`, date-fns
* `startOfDay`, ...). Rather than teaching every view about zones, an
* instant is shifted once at the boundary into a "display date" whose
* local fields read as the wall-clock in the effective zone, and shifted
* back when the user's grid interaction has to become a real instant.
* Both are identity functions unless an override differing from the
* browser zone is active, so existing behaviour is untouched by default.
*/
/** Sentinel for "follow the browser" - the default. */
export const AUTO_TIME_ZONE = "auto";
/** The zone the browser reports; `UTC` when detection fails (SSR, old engines). */
// Resolving the zone builds an Intl formatter, which costs tens of
// microseconds; the calendar grids convert every event for every day they
// show, so the answer is kept. The browser zone does not change within a
// page's lifetime in practice.
let browserTimeZone: string | null = null;
export function getBrowserTimeZone(): string {
if (browserTimeZone) return browserTimeZone;
try {
browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch {
browserTimeZone = "UTC";
}
return browserTimeZone;
}
const validityCache = new Map<string, boolean>();
/** True when `Intl` accepts `tz` as a time zone identifier. */
export function isValidTimeZone(tz: unknown): tz is string {
if (typeof tz !== "string" || !tz) return false;
let valid = validityCache.get(tz);
if (valid === undefined) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: tz });
valid = true;
} catch {
valid = false;
}
validityCache.set(tz, valid);
}
return valid;
}
const canonicalCache = new Map<string, string | null>();
/**
* `tz` as `Intl` spells it ("europe/berlin" -> "Europe/Berlin"), or null when
* `Intl` rejects it. Intl takes a zone in any letter case; Stalwart 1.0
* matches the name exactly and refuses one it does not know.
*/
export function canonicalTimeZone(tz: string): string | null {
let canonical = canonicalCache.get(tz);
if (canonical === undefined) {
try {
canonical = new Intl.DateTimeFormat(undefined, { timeZone: tz }).resolvedOptions().timeZone || null;
} catch {
canonical = null;
}
canonicalCache.set(tz, canonical);
}
return canonical;
}
/**
* Resolve a stored `timeZone` setting to a concrete IANA zone, spelled the
* way Intl spells it. `auto`, empty or unknown values (a zone synced from a
* browser with a newer tz database, a hand-edited import) fall back to the
* browser zone instead of throwing later inside Intl. A zone in another
* letter case would be refused by Stalwart 1.0 as an argument, and dropped
* from an event by both 0.16 and 1.0, which stored it as floating.
*/
export function resolveTimeZone(setting: string | null | undefined, browserTimeZone = getBrowserTimeZone()): string {
if (!setting || setting === AUTO_TIME_ZONE) return browserTimeZone;
return canonicalTimeZone(setting) ?? browserTimeZone;
}
/** The zone all instants should be displayed in: the user's override, else the browser's. */
export function getEffectiveTimeZone(): string {
return resolveTimeZone(useSettingsStore.getState().timeZone);
}
export interface WallClock {
year: number;
/** 1-12 */
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
/**
* Wall-clock fields of a JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss", or a bare
* date). Returns null for anything else, including strings with `Z` or an
* offset, which are instants rather than wall clocks.
*/
export function parseLocalDateTime(value: string): WallClock | null {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/);
if (!match) return null;
return {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] || 0),
minute: Number(match[5] || 0),
second: Number(match[6] || 0),
};
}
// Constructing Intl.DateTimeFormat is expensive (ICU data lookup); cache one
// formatter per zone - the calendar calls into this for every event.
const wallClockFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getWallClockFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = wallClockFormatterCache.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
wallClockFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** Wall-clock fields of `date` as seen in `timeZone`. */
export function getWallClock(date: Date, timeZone: string): WallClock {
const parts = getWallClockFormatter(timeZone).formatToParts(date);
const map: Record<string, number> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = Number(part.value);
}
return {
year: map.year,
month: map.month,
day: map.day,
// Some engines still emit "24" at midnight despite hourCycle h23.
hour: map.hour === 24 ? 0 : map.hour,
minute: map.minute,
second: map.second,
};
}
/**
* A JMAP LocalDateTime ("YYYY-MM-DDTHH:mm:ss") for a date-time string, as the
* wall clock in `timeZone` (UTC when unset). Strings that already carry no
* zone are returned as they are; one with `Z` or an offset is converted.
*
* CalendarEvent/query reads `after`/`before` as LocalDateTime in the request's
* timeZone, and Stalwart silently drops a `Z` or offset instead of honouring
* it, which shifted every UTC range by the user's offset.
*/
export function toLocalDateTime(value: string, timeZone?: string): string {
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const w = getWallClock(date, timeZone || "UTC");
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`;
}
/**
* A LocalDateTime ("YYYY-MM-DDTHH:mm:ss") read as the wall clock in
* `fromZone`, as the wall clock in `toZone` at the same instant. Anything else
* (a value with `Z` or an offset, or one that does not parse) is returned as
* it is.
*/
export function moveLocalDateTime(value: string, fromZone: string, toZone: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/.exec(value);
if (!match || fromZone === toZone) return value;
const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
const wall = new Date(0);
wall.setUTCFullYear(year, month - 1, day);
wall.setUTCHours(hour, minute, second, 0);
const asUtc = wall.getTime();
// Two offset lookups, as in fromZonedDisplayDate, for wall clocks next to a
// DST transition.
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), fromZone);
const instant = new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), fromZone));
return toLocalDateTime(instant.toISOString(), toZone);
}
/** UTC offset of `timeZone` at the given instant, in milliseconds (east = positive). */
export function getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const w = getWallClock(date, timeZone);
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
// Intl drops sub-second precision; compare against the whole-second instant.
return asUtc - Math.floor(date.getTime() / 1000) * 1000;
}
/** Build a Date whose *local* fields equal the given wall-clock (years < 100 included). */
function localDateFromWallClock(w: WallClock, milliseconds: number): Date {
const d = new Date(0);
d.setFullYear(w.year, w.month - 1, w.day);
d.setHours(w.hour, w.minute, w.second, milliseconds);
return d;
}
/**
* Shift an instant into a Date whose local getters read as its wall-clock in
* `timeZone` (date-fns-tz's `toZonedTime`). The result is NOT the same
* instant unless the zones agree - only use it for local-field arithmetic
* and rendering.
*/
export function toZonedDisplayDate(date: Date, timeZone: string): Date {
if (isNaN(date.getTime())) return date;
return localDateFromWallClock(getWallClock(date, timeZone), date.getMilliseconds());
}
/**
* Interpret the local fields of `wall` as a wall-clock in `timeZone` and
* return the real instant (date-fns-tz's `fromZonedTime`). Two offset
* lookups handle wall-clocks next to a DST transition.
*/
export function fromZonedDisplayDate(wall: Date, timeZone: string): Date {
if (isNaN(wall.getTime())) return wall;
const asUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(), wall.getMilliseconds(),
);
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
/**
* The instant at which the clock in `timeZone` reads `w`. Unlike
* fromZonedDisplayDate it never goes through a Date's local fields, so a
* time that the browser's own zone skips at a DST change is not shifted.
*/
export function fromWallClock(w: WallClock, timeZone: string): Date {
const d = new Date(0);
d.setUTCFullYear(w.year, w.month - 1, w.day);
d.setUTCHours(w.hour, w.minute, w.second, 0);
const asUtc = d.getTime();
const guess = asUtc - getTimeZoneOffsetMs(new Date(asUtc), timeZone);
return new Date(asUtc - getTimeZoneOffsetMs(new Date(guess), timeZone));
}
/**
* Instant -> display date for the calendar grid. Identity unless the user's
* time zone override differs from the browser zone.
*/
export function toDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getBrowserTimeZone()) return date;
return toZonedDisplayDate(date, timeZone);
}
/** Display date (from the calendar grid) -> real instant. Inverse of `toDisplayDate`. */
export function fromDisplayDate(date: Date): Date {
const timeZone = getEffectiveTimeZone();
if (timeZone === getBrowserTimeZone()) return date;
return fromZonedDisplayDate(date, timeZone);
}
/** "Now" as a display date - what the clock on the wall in the effective zone reads. */
export function displayNow(): Date {
return toDisplayDate(new Date());
}
/** Is the display date `date` on today's calendar day in the effective zone? */
export function isDisplayToday(date: Date): boolean {
return isSameDay(date, displayNow());
}
// A compact fallback for engines without Intl.supportedValuesOf (Safari < 15.4).
// One representative zone per UTC offset band plus the common capitals.
const FALLBACK_TIME_ZONES = [
"UTC",
"Pacific/Honolulu", "America/Anchorage", "America/Los_Angeles", "America/Denver",
"America/Phoenix", "America/Chicago", "America/Mexico_City", "America/New_York",
"America/Toronto", "America/Bogota", "America/Halifax", "America/Sao_Paulo",
"America/Argentina/Buenos_Aires", "Atlantic/Azores", "Europe/London", "Europe/Lisbon",
"Europe/Dublin", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome",
"Europe/Amsterdam", "Europe/Zurich", "Europe/Vienna", "Europe/Prague", "Europe/Warsaw",
"Europe/Stockholm", "Europe/Athens", "Europe/Helsinki", "Europe/Kyiv", "Europe/Istanbul",
"Europe/Moscow", "Asia/Jerusalem", "Asia/Dubai", "Asia/Tehran", "Asia/Karachi",
"Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", "Asia/Jakarta", "Asia/Shanghai",
"Asia/Hong_Kong", "Asia/Singapore", "Asia/Taipei", "Asia/Seoul", "Asia/Tokyo",
"Australia/Perth", "Australia/Adelaide", "Australia/Sydney", "Pacific/Auckland",
"Africa/Cairo", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi",
];
/**
* All IANA zone identifiers the runtime knows (a short fallback list on
* engines without Intl.supportedValuesOf), sorted with plain `UTC` first -
* ICU's list omits it, and it is exactly what a privacy-conscious user may
* want to pin.
*/
export function listTimeZones(): string[] {
let zones: string[] = FALLBACK_TIME_ZONES;
try {
const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
const supported = intl.supportedValuesOf?.("timeZone");
if (supported && supported.length > 0) zones = supported;
} catch {
// keep the static list
}
return ["UTC", ...zones.filter((zone) => zone !== "UTC").sort()];
}