@rapidmx/web-client 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +46 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +180 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +50 -2
- package/package.json +1 -1
|
@@ -169,6 +169,19 @@ export function formatStoredWhen(startDate, endDate, allDay, now = new Date()) {
|
|
|
169
169
|
}
|
|
170
170
|
return formatWhen(new Date(startDate), new Date(endDate), false, now);
|
|
171
171
|
}
|
|
172
|
+
/** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
|
|
173
|
+
export function zoneDate(start) {
|
|
174
|
+
const day = new Date(`${start.slice(0, 10)}T12:00:00Z`);
|
|
175
|
+
return Number.isNaN(day.getTime()) ? new Date() : day;
|
|
176
|
+
}
|
|
177
|
+
/** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
|
|
178
|
+
export function formatStoredWhenInZone(startDate, endDate, zone, now = new Date()) {
|
|
179
|
+
const onZoneClock = (iso) => {
|
|
180
|
+
const wall = new Date(toEventWallClock(new Date(iso).getTime(), zone, false));
|
|
181
|
+
return new Date(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes());
|
|
182
|
+
};
|
|
183
|
+
return formatWhen(onZoneClock(startDate), onZoneClock(endDate), false, now);
|
|
184
|
+
}
|
|
172
185
|
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
173
186
|
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
174
187
|
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The step between the times a time field offers. */
|
|
2
|
+
export declare const TIME_STEP_MINUTES = 15;
|
|
3
|
+
/**
|
|
4
|
+
* The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
|
|
5
|
+
* event lasting an hour from there.
|
|
6
|
+
*/
|
|
7
|
+
export declare function nextHalfHour(now: Date): Date;
|
|
8
|
+
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
9
|
+
export declare function formatTimeOfDay(time: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
|
|
12
|
+
* the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseTimeInput(text: string): string | null;
|
|
15
|
+
/** Every time of a day, a quarter hour apart, as `HH:mm`. */
|
|
16
|
+
export declare function dayTimes(): string[];
|
|
17
|
+
/** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
|
|
18
|
+
export declare function formatDuration(minutes: number): string;
|
|
19
|
+
export interface EndTimeOption {
|
|
20
|
+
/** The end as `YYYY-MM-DDTHH:mm`. */
|
|
21
|
+
value: string;
|
|
22
|
+
/** The time of day, as a 12 hour clock reading. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** How long the event would last, "(1.5 hrs)". */
|
|
25
|
+
hint: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
|
|
29
|
+
* day later, with the length of the event that end would make.
|
|
30
|
+
*/
|
|
31
|
+
export declare function endTimeOptions(start: string): EndTimeOption[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { format } from "date-fns";
|
|
6
|
+
/** The step between the times a time field offers. */
|
|
7
|
+
export const TIME_STEP_MINUTES = 15;
|
|
8
|
+
const MS_PER_MINUTE = 60000;
|
|
9
|
+
/**
|
|
10
|
+
* The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
|
|
11
|
+
* event lasting an hour from there.
|
|
12
|
+
*/
|
|
13
|
+
export function nextHalfHour(now) {
|
|
14
|
+
const slot = 30 * MS_PER_MINUTE;
|
|
15
|
+
return new Date(Math.ceil(now.getTime() / slot) * slot);
|
|
16
|
+
}
|
|
17
|
+
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
18
|
+
export function formatTimeOfDay(time) {
|
|
19
|
+
// A cleared date leaves no time to read: the field is empty, not an error.
|
|
20
|
+
if (!/^\d\d:\d\d$/.test(time)) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
24
|
+
return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
|
|
28
|
+
* the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
|
|
29
|
+
*/
|
|
30
|
+
export function parseTimeInput(text) {
|
|
31
|
+
const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
|
|
32
|
+
if (!match) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
let hours = Number(match[1]);
|
|
36
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2]);
|
|
37
|
+
const meridiem = match[3]?.toLowerCase();
|
|
38
|
+
if (minutes > 59) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (meridiem) {
|
|
42
|
+
if (hours < 1 || hours > 12) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
|
|
46
|
+
}
|
|
47
|
+
else if (hours > 23) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
51
|
+
}
|
|
52
|
+
/** Every time of a day, a quarter hour apart, as `HH:mm`. */
|
|
53
|
+
export function dayTimes() {
|
|
54
|
+
return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
|
|
55
|
+
const minutes = i * TIME_STEP_MINUTES;
|
|
56
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
|
|
60
|
+
export function formatDuration(minutes) {
|
|
61
|
+
if (minutes < 60) {
|
|
62
|
+
return `${minutes} min${minutes === 1 ? "" : "s"}`;
|
|
63
|
+
}
|
|
64
|
+
const hours = Math.round((minutes / 60) * 100) / 100;
|
|
65
|
+
return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
|
|
66
|
+
}
|
|
67
|
+
/** `YYYY-MM-DDTHH:mm` read as a wall clock, in minutes since the epoch (no zone: the daylight saving changes of a zone are not this field's business). */
|
|
68
|
+
function wallMinutes(value) {
|
|
69
|
+
return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
|
|
70
|
+
}
|
|
71
|
+
function wallString(minutes) {
|
|
72
|
+
return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
|
|
76
|
+
* day later, with the length of the event that end would make.
|
|
77
|
+
*/
|
|
78
|
+
export function endTimeOptions(start) {
|
|
79
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const startMinutes = wallMinutes(start);
|
|
83
|
+
const options = [];
|
|
84
|
+
for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
|
|
85
|
+
const value = wallString(at);
|
|
86
|
+
options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
|
|
87
|
+
}
|
|
88
|
+
return options;
|
|
89
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
1
2
|
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
2
3
|
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
3
4
|
* or by name, since mail clients disagree about which one they set (`text/calendar`, `application/ics`, or a bare `.ics` as octet-stream). */
|
|
@@ -43,15 +44,15 @@ export function formatInviteWhen(invite) {
|
|
|
43
44
|
}
|
|
44
45
|
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
45
46
|
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
46
|
-
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
47
|
-
const
|
|
47
|
+
// The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
|
|
48
|
+
const zone = describeTimeZone(deviceTimeZone(), end ?? start);
|
|
48
49
|
const startDay = date.format(start);
|
|
49
50
|
if (!end) {
|
|
50
|
-
return `${startDay}, ${
|
|
51
|
+
return `${startDay}, ${time.format(start)} • ${zone}`;
|
|
51
52
|
}
|
|
52
53
|
return startDay === date.format(end)
|
|
53
|
-
? `${startDay}, ${time.format(start)} - ${
|
|
54
|
-
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${
|
|
54
|
+
? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
|
|
55
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
|
|
55
56
|
}
|
|
56
57
|
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
57
58
|
export function formatRowStart(invite) {
|
|
@@ -31,6 +31,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
|
31
31
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
32
32
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
33
33
|
import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
|
|
34
|
+
import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
|
|
34
35
|
import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
|
|
35
36
|
import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
|
|
36
37
|
import { useWheelPaging } from "../../shared/gestures/useWheelPaging.js";
|
|
@@ -275,10 +276,12 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
275
276
|
if (!mailboxUid || !folderUid) {
|
|
276
277
|
return;
|
|
277
278
|
}
|
|
279
|
+
// With nothing clicked, the next half hour, for an hour.
|
|
280
|
+
const from = start ?? nextHalfHour(new Date());
|
|
278
281
|
setModal({
|
|
279
282
|
occurrence: null,
|
|
280
|
-
initialStart:
|
|
281
|
-
initialEnd: end ?? new Date(
|
|
283
|
+
initialStart: from,
|
|
284
|
+
initialEnd: end ?? new Date(from.getTime() + 60 * 60000),
|
|
282
285
|
targetFolderUid,
|
|
283
286
|
anchor,
|
|
284
287
|
initialAllDay,
|
|
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
7
7
|
import { useMemo, useState } from "react";
|
|
8
8
|
import { freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
9
|
-
import
|
|
9
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
10
|
+
import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
|
|
10
11
|
import SettingsShell, { useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
11
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
12
13
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -112,13 +113,13 @@ function ProfileContent() {
|
|
|
112
113
|
setDisplayName(e.target.value);
|
|
113
114
|
setNameError(null);
|
|
114
115
|
setSaved(false);
|
|
115
|
-
}, "aria-invalid": nameError ? true : undefined, "aria-describedby": nameError ? "profile-display-name-error" : "profile-display-name-help" }), nameError && (_jsx("p", { id: "profile-display-name-error", role: "alert", className: "mt-1 text-xs text-danger", children: nameError })), _jsx("p", { id: "profile-display-name-help", className: "mt-1 text-xs text-text-muted", children: "The sender name on mail you send, shown to recipients beside your address (for example \u201CJane Doe\u201D). It can\u2019t contain an \u201C@\u201D." })] }), _jsxs(FormField, { label: "Time zone", htmlFor: "profile-time-zone", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(
|
|
116
|
-
setTimezone(
|
|
116
|
+
}, "aria-invalid": nameError ? true : undefined, "aria-describedby": nameError ? "profile-display-name-error" : "profile-display-name-help" }), nameError && (_jsx("p", { id: "profile-display-name-error", role: "alert", className: "mt-1 text-xs text-danger", children: nameError })), _jsx("p", { id: "profile-display-name-help", className: "mt-1 text-xs text-text-muted", children: "The sender name on mail you send, shown to recipients beside your address (for example \u201CJane Doe\u201D). It can\u2019t contain an \u201C@\u201D." })] }), _jsxs(FormField, { label: "Time zone", htmlFor: "profile-time-zone", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(TimeZonePicker, { id: "profile-time-zone", className: SELECT_CLASS, value: timezone, zones: zones, onChange: (zone) => {
|
|
117
|
+
setTimezone(zone);
|
|
117
118
|
setSaved(false);
|
|
118
|
-
}, "aria-describedby": "profile-time-zone-help"
|
|
119
|
+
}, "aria-describedby": "profile-time-zone-help" }), timezone !== deviceZone && (_jsxs(Button, { type: "button", variant: "text", className: "!w-auto", onClick: () => {
|
|
119
120
|
setTimezone(deviceZone);
|
|
120
121
|
setSaved(false);
|
|
121
|
-
}, children: ["Use this device\u2019s time zone (", deviceZone, ")"] }))] }), zoneWasPreselected && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Your mailbox has no time zone chosen yet; this device\u2019s is preselected. Save to keep it." })), _jsx("p", { id: "profile-time-zone-help", className: "mt-1 text-xs text-text-muted", children: "How calendar times and reminders are shown and scheduled for this mailbox, such as when an event or a reminder falls." })] }), _jsxs(FormField, { label: "Free/busy visibility", htmlFor: "profile-free-busy", children: [_jsx("select", { id: "profile-free-busy", className: SELECT_CLASS, value: freeBusy, disabled: !canChangeFreeBusy, onChange: (e) => {
|
|
122
|
+
}, children: ["Use this device\u2019s time zone (", describeTimeZone(deviceZone), ")"] }))] }), zoneWasPreselected && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Your mailbox has no time zone chosen yet; this device\u2019s is preselected. Save to keep it." })), _jsx("p", { id: "profile-time-zone-help", className: "mt-1 text-xs text-text-muted", children: "How calendar times and reminders are shown and scheduled for this mailbox, such as when an event or a reminder falls." })] }), _jsxs(FormField, { label: "Free/busy visibility", htmlFor: "profile-free-busy", children: [_jsx("select", { id: "profile-free-busy", className: SELECT_CLASS, value: freeBusy, disabled: !canChangeFreeBusy, onChange: (e) => {
|
|
122
123
|
setFreeBusy(e.target.value);
|
|
123
124
|
setSaved(false);
|
|
124
125
|
}, "aria-describedby": "profile-free-busy-help", children: FREE_BUSY_CHOICES.map((choice) => (_jsx("option", { value: choice.value, children: choice.label }, choice.value))) }), _jsx("p", { id: "profile-free-busy-help", className: "mt-1 text-xs text-text-muted", children: FREE_BUSY_CHOICES.find((choice) => choice.value === freeBusy).help }), _jsx("p", { className: "mt-1 text-xs text-text-muted", children: "Who can see the hours you are busy when they look for a time to meet - never the title, place or guests of an event." }), !canChangeFreeBusy && (_jsx("p", { className: "mt-1 text-xs text-text-muted", children: "This mailbox is shared with you: only its owner can change who sees its free/busy times." }))] }), _jsx("div", { children: _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }) })] })] }) }));
|
|
@@ -17,6 +17,18 @@ export declare const SESSION_RELOAD_GUARD_MS: number;
|
|
|
17
17
|
* @returns `true` when a refresh was made, `false` when one was not needed.
|
|
18
18
|
*/
|
|
19
19
|
export declare function refreshSession(authServerUrl: string, force?: boolean): Promise<boolean>;
|
|
20
|
+
/** How long the outcome of a recovery is reused for: requests that fail together share one refresh, and a session that will not come back is not asked again at once. */
|
|
21
|
+
export declare const SESSION_RECOVERY_COOLDOWN_MS: number;
|
|
22
|
+
/**
|
|
23
|
+
* Renews the session because a request was refused for lack of one, whatever this browser's record of the last refresh says. Every request
|
|
24
|
+
* refused at about the same time shares one refresh, and its outcome is reused for `SESSION_RECOVERY_COOLDOWN_MS`. A refusal also clears the record of
|
|
25
|
+
* the last refresh, so the next regular check asks again (and sends the browser to sign-in if the refresh token is truly gone).
|
|
26
|
+
*
|
|
27
|
+
* @returns Whether the session was renewed.
|
|
28
|
+
*/
|
|
29
|
+
export declare function recoverSession(authServerUrl: string): Promise<boolean>;
|
|
30
|
+
/** Forgets what recoveries have done (for tests). */
|
|
31
|
+
export declare function resetSessionRecovery(): void;
|
|
20
32
|
/**
|
|
21
33
|
* Redirects the browser to auth-server's sign-in page when `userUid` is absent, carrying the current URL as
|
|
22
34
|
* `return_to` so auth-server can send the browser back here afterward. A no-op once `userUid` is present.
|
|
@@ -51,7 +63,8 @@ export interface SessionRefreshOptions {
|
|
|
51
63
|
* lifetime, or reopened later): attempts one silent refresh and reloads so the server renders the page with the new
|
|
52
64
|
* cookie. If the refresh is refused (the refresh token expired or was revoked, or there was none), or a reload just
|
|
53
65
|
* made still found no session, redirects to sign-in as `useRedirectIfUnauthenticated()` does.
|
|
54
|
-
* - `userUid` set:
|
|
66
|
+
* - `userUid` set: also renews the session when a request is refused for lack of one (`apiFetch()` asks `recoverSession()` and sends the request
|
|
67
|
+
* again), so a token that ran out while timers were stopped costs nothing. Refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
|
|
55
68
|
* record of a recent refresh, since the page's token age is unknown), checked every `SESSION_REFRESH_CHECK_MS` and
|
|
56
69
|
* whenever the tab becomes visible or the network returns, because a sleeping laptop stops timers. A failure that is
|
|
57
70
|
* not a rejection is simply retried at the next check. A rejection (`401`/`403`: the refresh token expired or was
|
package/dist/lib/auth/session.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* sign-out already has.
|
|
16
16
|
*/
|
|
17
17
|
import { useEffect, useRef } from "react";
|
|
18
|
-
import { ApiRequestError, authApiFetch } from "../util/api.js";
|
|
18
|
+
import { ApiRequestError, authApiFetch, setApiSessionRecovery } from "../util/api.js";
|
|
19
19
|
import { useApiClient } from "../util/apiClientContext.js";
|
|
20
20
|
/** An access token's age at which it is refreshed. The token lives an hour; the margin leaves time to retry a failed refresh. */
|
|
21
21
|
export const SESSION_REFRESH_AFTER_MS = 45 * 60 * 1000;
|
|
@@ -71,6 +71,38 @@ export async function refreshSession(authServerUrl, force = false) {
|
|
|
71
71
|
const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
|
|
72
72
|
return locks ? locks.request(LOCK_NAME, run) : run();
|
|
73
73
|
}
|
|
74
|
+
/** How long the outcome of a recovery is reused for: requests that fail together share one refresh, and a session that will not come back is not asked again at once. */
|
|
75
|
+
export const SESSION_RECOVERY_COOLDOWN_MS = 10 * 1000;
|
|
76
|
+
let recovery;
|
|
77
|
+
let lastRecovery;
|
|
78
|
+
/**
|
|
79
|
+
* Renews the session because a request was refused for lack of one, whatever this browser's record of the last refresh says. Every request
|
|
80
|
+
* refused at about the same time shares one refresh, and its outcome is reused for `SESSION_RECOVERY_COOLDOWN_MS`. A refusal also clears the record of
|
|
81
|
+
* the last refresh, so the next regular check asks again (and sends the browser to sign-in if the refresh token is truly gone).
|
|
82
|
+
*
|
|
83
|
+
* @returns Whether the session was renewed.
|
|
84
|
+
*/
|
|
85
|
+
export function recoverSession(authServerUrl) {
|
|
86
|
+
if (lastRecovery && Date.now() - lastRecovery.at < SESSION_RECOVERY_COOLDOWN_MS) {
|
|
87
|
+
return Promise.resolve(lastRecovery.renewed);
|
|
88
|
+
}
|
|
89
|
+
recovery ?? (recovery = refreshSession(authServerUrl, true)
|
|
90
|
+
.then(() => true, () => {
|
|
91
|
+
writeTimestamp(() => localStorage, REFRESHED_AT_KEY, 0);
|
|
92
|
+
return false;
|
|
93
|
+
})
|
|
94
|
+
.then((renewed) => {
|
|
95
|
+
lastRecovery = { at: Date.now(), renewed };
|
|
96
|
+
recovery = undefined;
|
|
97
|
+
return renewed;
|
|
98
|
+
}));
|
|
99
|
+
return recovery;
|
|
100
|
+
}
|
|
101
|
+
/** Forgets what recoveries have done (for tests). */
|
|
102
|
+
export function resetSessionRecovery() {
|
|
103
|
+
recovery = undefined;
|
|
104
|
+
lastRecovery = undefined;
|
|
105
|
+
}
|
|
74
106
|
/** Whether `err` says auth-server rejected the refresh token itself, as opposed to a failure worth retrying. */
|
|
75
107
|
function isAuthRejection(err) {
|
|
76
108
|
return err instanceof ApiRequestError && (err.status === 401 || err.status === 403);
|
|
@@ -106,7 +138,8 @@ export function useRedirectIfUnauthenticated(userUid, authServerUrl) {
|
|
|
106
138
|
* lifetime, or reopened later): attempts one silent refresh and reloads so the server renders the page with the new
|
|
107
139
|
* cookie. If the refresh is refused (the refresh token expired or was revoked, or there was none), or a reload just
|
|
108
140
|
* made still found no session, redirects to sign-in as `useRedirectIfUnauthenticated()` does.
|
|
109
|
-
* - `userUid` set:
|
|
141
|
+
* - `userUid` set: also renews the session when a request is refused for lack of one (`apiFetch()` asks `recoverSession()` and sends the request
|
|
142
|
+
* again), so a token that ran out while timers were stopped costs nothing. Refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
|
|
110
143
|
* record of a recent refresh, since the page's token age is unknown), checked every `SESSION_REFRESH_CHECK_MS` and
|
|
111
144
|
* whenever the tab becomes visible or the network returns, because a sleeping laptop stops timers. A failure that is
|
|
112
145
|
* not a rejection is simply retried at the next check. A rejection (`401`/`403`: the refresh token expired or was
|
|
@@ -143,7 +176,8 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
143
176
|
window.location.href = signInUrl(authServerUrl);
|
|
144
177
|
return;
|
|
145
178
|
}
|
|
146
|
-
|
|
179
|
+
// Forced: the page has no session, so what this browser remembers of the last refresh says nothing about whether one is due.
|
|
180
|
+
refreshSession(authServerUrl, true)
|
|
147
181
|
.then(() => {
|
|
148
182
|
if (!cancelled) {
|
|
149
183
|
writeTimestamp(() => sessionStorage, RELOADED_AT_KEY, Date.now());
|
|
@@ -162,6 +196,8 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
162
196
|
if (paused) {
|
|
163
197
|
return;
|
|
164
198
|
}
|
|
199
|
+
// A request the server refuses for lack of a session renews it (see `apiFetch()`), instead of waiting for the next check.
|
|
200
|
+
setApiSessionRecovery(() => recoverSession(authServerUrl));
|
|
165
201
|
let leaving = false;
|
|
166
202
|
let inFlight = false;
|
|
167
203
|
const leave = async () => {
|
|
@@ -202,6 +238,7 @@ export function useSessionRefresh(userUid, authServerUrl, options = {}) {
|
|
|
202
238
|
window.addEventListener("online", check);
|
|
203
239
|
return () => {
|
|
204
240
|
cancelled = true;
|
|
241
|
+
setApiSessionRecovery(undefined);
|
|
205
242
|
window.clearInterval(interval);
|
|
206
243
|
document.removeEventListener("visibilitychange", onVisible);
|
|
207
244
|
window.removeEventListener("online", check);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface TimeZonePickerProps {
|
|
3
|
+
id?: string;
|
|
4
|
+
"aria-label"?: string;
|
|
5
|
+
"aria-describedby"?: string;
|
|
6
|
+
/** The chosen zone (an IANA id). */
|
|
7
|
+
value: string;
|
|
8
|
+
/** The zones to choose between; the list orders them itself. */
|
|
9
|
+
zones: string[];
|
|
10
|
+
onChange: (zone: string) => void;
|
|
11
|
+
/** The date each zone's offset is read on (daylight saving); now unless a date matters. */
|
|
12
|
+
at?: Date;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A time zone chooser that lists zones as "Los Angeles, America (GMT-07:00)", west to east and then by city, and has a search box in the list: typing narrows it to the zones whose
|
|
17
|
+
* name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
|
|
18
|
+
* through the list, Enter chooses, and Escape (or a click elsewhere) closes it without changing anything.
|
|
19
|
+
*/
|
|
20
|
+
export default function TimeZonePicker({ id, "aria-label": label, "aria-describedby": describedBy, value, zones, onChange, at, className }: TimeZonePickerProps): React.JSX.Element;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { compareTimeZones, describeTimeZone } from "../../util/timeZone.js";
|
|
8
|
+
/**
|
|
9
|
+
* A time zone chooser that lists zones as "Los Angeles, America (GMT-07:00)", west to east and then by city, and has a search box in the list: typing narrows it to the zones whose
|
|
10
|
+
* name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
|
|
11
|
+
* through the list, Enter chooses, and Escape (or a click elsewhere) closes it without changing anything.
|
|
12
|
+
*/
|
|
13
|
+
export default function TimeZonePicker({ id, "aria-label": label, "aria-describedby": describedBy, value, zones, onChange, at, className }) {
|
|
14
|
+
const listId = useId();
|
|
15
|
+
const root = useRef(null);
|
|
16
|
+
const trigger = useRef(null);
|
|
17
|
+
const search = useRef(null);
|
|
18
|
+
const selected = useRef(null);
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const [query, setQuery] = useState("");
|
|
21
|
+
const [active, setActive] = useState(-1);
|
|
22
|
+
// Listed by the offsets shown (which move with daylight saving): west to east, then by city.
|
|
23
|
+
const entries = useMemo(() => [...zones]
|
|
24
|
+
.sort((a, b) => compareTimeZones(a, b, at))
|
|
25
|
+
.map((zone) => ({ zone, text: describeTimeZone(zone, at), haystack: `${describeTimeZone(zone, at)} ${zone.replace(/_/g, " ")}`.toLowerCase() })), [zones, at]);
|
|
26
|
+
const shown = useMemo(() => {
|
|
27
|
+
const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
|
|
28
|
+
return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
|
|
29
|
+
}, [entries, query]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
search.current?.focus();
|
|
35
|
+
selected.current?.scrollIntoView?.({ block: "center" });
|
|
36
|
+
const outside = (event) => {
|
|
37
|
+
if (!root.current?.contains(event.target)) {
|
|
38
|
+
close(false);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
document.addEventListener("pointerdown", outside);
|
|
42
|
+
return () => document.removeEventListener("pointerdown", outside);
|
|
43
|
+
}, [open]);
|
|
44
|
+
function close(refocus) {
|
|
45
|
+
setOpen(false);
|
|
46
|
+
setQuery("");
|
|
47
|
+
setActive(-1);
|
|
48
|
+
if (refocus) {
|
|
49
|
+
trigger.current?.focus();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function choose(zone) {
|
|
53
|
+
if (zone !== value) {
|
|
54
|
+
onChange(zone);
|
|
55
|
+
}
|
|
56
|
+
close(true);
|
|
57
|
+
}
|
|
58
|
+
function move(step) {
|
|
59
|
+
setActive((index) => {
|
|
60
|
+
const from = index < 0 ? shown.findIndex((entry) => entry.zone === value) : index;
|
|
61
|
+
return Math.min(Math.max(from + step, 0), shown.length - 1);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function handleSearchKeyDown(event) {
|
|
65
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
move(event.key === "ArrowDown" ? 1 : -1);
|
|
68
|
+
}
|
|
69
|
+
else if (event.key === "Enter") {
|
|
70
|
+
// Never the form's own submit.
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
const entry = shown[active >= 0 ? active : 0];
|
|
73
|
+
if (entry && (active >= 0 || shown.length === 1)) {
|
|
74
|
+
choose(entry.zone);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (event.key === "Escape") {
|
|
78
|
+
event.stopPropagation();
|
|
79
|
+
close(true);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return (_jsxs("div", { ref: root, className: "relative inline-block max-w-full", children: [_jsxs("button", { ref: trigger, id: id, type: "button", role: "combobox", "aria-label": label, "aria-describedby": describedBy, "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, className: ["flex items-center justify-between gap-2 text-left", className].filter(Boolean).join(" "), onClick: () => setOpen((wasOpen) => !wasOpen), onKeyDown: (event) => {
|
|
83
|
+
if (event.key === "ArrowDown" && !open) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
setOpen(true);
|
|
86
|
+
}
|
|
87
|
+
}, children: [_jsx("span", { className: "truncate", children: describeTimeZone(value, at) }), _jsx("span", { "aria-hidden": "true", className: "shrink-0 text-text-muted", children: "\u25BE" })] }), open && (_jsxs("div", { className: "absolute left-0 top-full z-30 mt-1 w-80 max-w-[90vw] rounded-md border border-border bg-surface shadow-lg", children: [_jsx("div", { className: "p-2", children: _jsx("input", { ref: search, type: "text", role: "searchbox", "aria-label": "Search time zones", "aria-controls": listId, "aria-activedescendant": active >= 0 ? `${listId}-${active}` : undefined, autoComplete: "off", placeholder: "Search", className: "w-full rounded-md border border-border bg-surface-alt px-3 py-1.5 text-sm text-text focus:border-primary focus:outline-none", value: query, onChange: (event) => {
|
|
88
|
+
setQuery(event.target.value);
|
|
89
|
+
setActive(-1);
|
|
90
|
+
}, onKeyDown: handleSearchKeyDown }) }), _jsxs("ul", { id: listId, role: "listbox", "aria-label": "Time zones", className: "max-h-60 overflow-y-auto pb-1", children: [shown.map((entry, index) => (_jsx("li", { id: `${listId}-${index}`, ref: entry.zone === value ? selected : undefined, role: "option", "aria-selected": entry.zone === value,
|
|
91
|
+
// Before the search box's blur or the outside-click check, which would otherwise close the list first.
|
|
92
|
+
onMouseDown: (event) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
choose(entry.zone);
|
|
95
|
+
}, className: [
|
|
96
|
+
"cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-alt",
|
|
97
|
+
entry.zone === value ? "font-semibold text-primary-dark" : "",
|
|
98
|
+
index === active ? "bg-surface-alt" : "",
|
|
99
|
+
].join(" "), children: entry.text }, entry.zone))), shown.length === 0 && _jsx("li", { className: "px-3 py-1.5 text-sm text-text-muted", children: "No time zones match." })] })] }))] }));
|
|
100
|
+
}
|
package/dist/lib/util/api.d.ts
CHANGED
|
@@ -68,6 +68,12 @@ export declare function withCsrfHeader(headers: Record<string, string>, method?:
|
|
|
68
68
|
* in one place, rather than repeated at every call site.
|
|
69
69
|
*/
|
|
70
70
|
export declare function apiFetch<T = unknown>(path: string, init?: RequestInit): Promise<T>;
|
|
71
|
+
/**
|
|
72
|
+
* Registers what `apiFetch()` does when the server answers `401`: try to renew the signed-in session and, when that worked, send the request once
|
|
73
|
+
* more. `recovery` resolves whether the session was renewed. The app frame registers it while a session is open (`useSessionRefresh()`); pass
|
|
74
|
+
* `undefined` to remove it. A rejection counts as "not renewed".
|
|
75
|
+
*/
|
|
76
|
+
export declare function setApiSessionRecovery(recovery: (() => Promise<boolean>) | undefined): void;
|
|
71
77
|
/**
|
|
72
78
|
* `fetch()` against auth-server's API — a *different* origin than this app, unlike `apiFetch()` above. Used
|
|
73
79
|
* only for the handful of actions that must be issued by auth-server itself (e.g. admin impersonation, which
|
package/dist/lib/util/api.js
CHANGED
|
@@ -137,9 +137,30 @@ export async function apiFetch(path, init = {}) {
|
|
|
137
137
|
headers.set("Content-Type", "application/json");
|
|
138
138
|
applyCsrfHeader(headers, init.method);
|
|
139
139
|
const credentials = apiBaseUrl ? "include" : init.credentials;
|
|
140
|
-
|
|
140
|
+
let res = await fetch(apiUrl(path), { ...init, headers, credentials });
|
|
141
|
+
// The access token has run out (a sleeping tab or laptop stops the timer that renews it in time): renew the session, and ask again once.
|
|
142
|
+
if (res.status === 401 && sessionRecovery && (await recoverSession())) {
|
|
143
|
+
res = await fetch(apiUrl(path), { ...init, headers, credentials });
|
|
144
|
+
}
|
|
141
145
|
return decodeApiResponse(res, (error) => unauthorizedObserver?.(error));
|
|
142
146
|
}
|
|
147
|
+
let sessionRecovery;
|
|
148
|
+
/**
|
|
149
|
+
* Registers what `apiFetch()` does when the server answers `401`: try to renew the signed-in session and, when that worked, send the request once
|
|
150
|
+
* more. `recovery` resolves whether the session was renewed. The app frame registers it while a session is open (`useSessionRefresh()`); pass
|
|
151
|
+
* `undefined` to remove it. A rejection counts as "not renewed".
|
|
152
|
+
*/
|
|
153
|
+
export function setApiSessionRecovery(recovery) {
|
|
154
|
+
sessionRecovery = recovery;
|
|
155
|
+
}
|
|
156
|
+
async function recoverSession() {
|
|
157
|
+
try {
|
|
158
|
+
return await sessionRecovery();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
143
164
|
/**
|
|
144
165
|
* `fetch()` against auth-server's API — a *different* origin than this app, unlike `apiFetch()` above. Used
|
|
145
166
|
* only for the handful of actions that must be issued by auth-server itself (e.g. admin impersonation, which
|
|
@@ -6,7 +6,25 @@ export declare const DEFAULT_TIME_ZONE = "UTC";
|
|
|
6
6
|
*/
|
|
7
7
|
export declare function deviceTimeZone(): string;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* What `zone`'s clock is called and how far it is from UTC at `at`: `{ abbreviation: "PDT", offset: "-07:00" }`. `null` for a zone the runtime doesn't
|
|
10
|
+
* know. The abbreviation is the runtime's own (`GMT+1` where it has no name for a zone), the offset counts daylight saving on that date.
|
|
11
|
+
*/
|
|
12
|
+
export declare function zoneClock(zone: string, at?: Date): {
|
|
13
|
+
abbreviation: string;
|
|
14
|
+
offset: string;
|
|
15
|
+
} | null;
|
|
16
|
+
/**
|
|
17
|
+
* A zone as a list or a line of text names it, city first, with how far its clock is from UTC: "Los Angeles, America (GMT-07:00)". Daylight saving
|
|
18
|
+
* is read at `at` (now, unless a date matters). A zone the runtime doesn't know is just its name.
|
|
19
|
+
*/
|
|
20
|
+
export declare function describeTimeZone(zone: string, at?: Date): string;
|
|
21
|
+
/**
|
|
22
|
+
* Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
|
|
23
|
+
* doesn't know comes last.
|
|
24
|
+
*/
|
|
25
|
+
export declare function compareTimeZones(a: string, b: string, at?: Date): number;
|
|
26
|
+
/**
|
|
27
|
+
* Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
|
|
10
28
|
* which is added), else just `extra` - the zones the caller already holds, such as a mailbox's current one.
|
|
11
29
|
*/
|
|
12
30
|
export declare function timeZoneOptions(...extra: string[]): string[];
|
|
@@ -17,7 +17,50 @@ export function deviceTimeZone() {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* What `zone`'s clock is called and how far it is from UTC at `at`: `{ abbreviation: "PDT", offset: "-07:00" }`. `null` for a zone the runtime doesn't
|
|
21
|
+
* know. The abbreviation is the runtime's own (`GMT+1` where it has no name for a zone), the offset counts daylight saving on that date.
|
|
22
|
+
*/
|
|
23
|
+
export function zoneClock(zone, at = new Date()) {
|
|
24
|
+
try {
|
|
25
|
+
const part = (style) => new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style }).formatToParts(at).find((p) => p.type === "timeZoneName").value;
|
|
26
|
+
return { abbreviation: part("short"), offset: part("longOffset").replace("GMT", "") || "+00:00" };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A zone id split into its city and the region it is in: "America/Argentina/Buenos_Aires" is "Buenos Aires" in "America/Argentina"; "UTC" is in none. */
|
|
33
|
+
function zoneNames(zone) {
|
|
34
|
+
const parts = zone.split("/");
|
|
35
|
+
const city = parts.pop().replace(/_/g, " ");
|
|
36
|
+
return { city, region: parts.join("/").replace(/_/g, " ") };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A zone as a list or a line of text names it, city first, with how far its clock is from UTC: "Los Angeles, America (GMT-07:00)". Daylight saving
|
|
40
|
+
* is read at `at` (now, unless a date matters). A zone the runtime doesn't know is just its name.
|
|
41
|
+
*/
|
|
42
|
+
export function describeTimeZone(zone, at = new Date()) {
|
|
43
|
+
const clock = zoneClock(zone, at);
|
|
44
|
+
if (!clock) {
|
|
45
|
+
return zone.replace(/_/g, " ");
|
|
46
|
+
}
|
|
47
|
+
const { city, region } = zoneNames(zone);
|
|
48
|
+
return `${city}${region ? `, ${region}` : ""} (GMT${clock.offset})`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
|
|
52
|
+
* doesn't know comes last.
|
|
53
|
+
*/
|
|
54
|
+
export function compareTimeZones(a, b, at = new Date()) {
|
|
55
|
+
const minutes = (zone) => {
|
|
56
|
+
const offset = zoneClock(zone, at)?.offset;
|
|
57
|
+
return offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
|
|
58
|
+
};
|
|
59
|
+
const byOffset = minutes(a) - minutes(b);
|
|
60
|
+
return (Number.isNaN(byOffset) ? 0 : byOffset) || zoneNames(a).city.localeCompare(zoneNames(b).city) || a.localeCompare(b);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
|
|
21
64
|
* which is added), else just `extra` - the zones the caller already holds, such as a mailbox's current one.
|
|
22
65
|
*/
|
|
23
66
|
export function timeZoneOptions(...extra) {
|
|
@@ -28,5 +71,6 @@ export function timeZoneOptions(...extra) {
|
|
|
28
71
|
catch {
|
|
29
72
|
zones = [];
|
|
30
73
|
}
|
|
31
|
-
|
|
74
|
+
const now = new Date();
|
|
75
|
+
return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => compareTimeZones(a, b, now));
|
|
32
76
|
}
|