mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
302 lines
12 KiB
TypeScript
302 lines
12 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);
|
|
}
|
|
|
|
/** True when the user's override is active and differs from the browser zone. */
|
|
export function isTimeZoneOverridden(): boolean {
|
|
return getEffectiveTimeZone() !== getBrowserTimeZone();
|
|
}
|
|
|
|
export interface WallClock {
|
|
year: number;
|
|
/** 1-12 */
|
|
month: number;
|
|
day: number;
|
|
hour: number;
|
|
minute: number;
|
|
second: number;
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
|
|
/**
|
|
* 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()];
|
|
}
|