mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
286 lines
11 KiB
TypeScript
286 lines
11 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;
|
|
}
|
|
|
|
/**
|
|
* Resolve a stored `timeZone` setting to a concrete IANA zone. `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.
|
|
*/
|
|
export function resolveTimeZone(setting: string | null | undefined, browserTimeZone = getBrowserTimeZone()): string {
|
|
if (!setting || setting === AUTO_TIME_ZONE) return browserTimeZone;
|
|
return isValidTimeZone(setting) ? 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)}`;
|
|
}
|
|
|
|
/** 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()];
|
|
}
|