@rapidmx/web-client 0.23.0 → 0.25.1

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.
Files changed (71) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  3. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  4. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  5. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  6. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  7. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  8. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  9. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  10. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  11. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  12. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  13. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  14. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  15. package/apps/shared/components/calendar/timePicker.ts +106 -0
  16. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  17. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  18. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  19. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  20. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  21. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  22. package/apps/shared/navigation/appHrefs.ts +3 -3
  23. package/apps/www/calendar/index.tsx +5 -2
  24. package/apps/www/settings/profile/index.tsx +8 -12
  25. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  26. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  27. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  28. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  29. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  30. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  31. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  32. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  33. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  34. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  35. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  36. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  37. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  38. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  39. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  40. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  41. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  42. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  43. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  44. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  45. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  46. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  47. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  48. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  49. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  50. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  51. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  52. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  54. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  55. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  56. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  57. package/dist/apps/www/calendar/index.js +5 -2
  58. package/dist/apps/www/settings/profile/index.js +6 -5
  59. package/dist/lib/auth/session.d.ts +14 -1
  60. package/dist/lib/auth/session.js +40 -3
  61. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  62. package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
  63. package/dist/lib/util/api.d.ts +6 -0
  64. package/dist/lib/util/api.js +22 -1
  65. package/dist/lib/util/timeZone.d.ts +19 -1
  66. package/dist/lib/util/timeZone.js +59 -2
  67. package/lib/auth/session.ts +48 -3
  68. package/lib/components/pickers/TimeZonePicker.tsx +185 -0
  69. package/lib/util/api.ts +24 -1
  70. package/lib/util/timeZone.ts +65 -2
  71. package/package.json +1 -1
@@ -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: refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
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
- refreshSession(authServerUrl)
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,109 @@
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 { describeTimeZone, sortTimeZones } 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. Worked out only while the list is open - naming several
23
+ // hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
24
+ const atTime = at?.getTime();
25
+ const entries = useMemo(() => {
26
+ if (!open) {
27
+ return [];
28
+ }
29
+ const on = new Date(atTime ?? Date.now());
30
+ return sortTimeZones(zones, on).map((zone) => {
31
+ const text = describeTimeZone(zone, on);
32
+ return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
33
+ });
34
+ }, [open, zones, atTime]);
35
+ const shown = useMemo(() => {
36
+ const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
37
+ return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
38
+ }, [entries, query]);
39
+ useEffect(() => {
40
+ if (!open) {
41
+ return undefined;
42
+ }
43
+ search.current?.focus();
44
+ selected.current?.scrollIntoView?.({ block: "center" });
45
+ const outside = (event) => {
46
+ if (!root.current?.contains(event.target)) {
47
+ close(false);
48
+ }
49
+ };
50
+ document.addEventListener("pointerdown", outside);
51
+ return () => document.removeEventListener("pointerdown", outside);
52
+ }, [open]);
53
+ function close(refocus) {
54
+ setOpen(false);
55
+ setQuery("");
56
+ setActive(-1);
57
+ if (refocus) {
58
+ trigger.current?.focus();
59
+ }
60
+ }
61
+ function choose(zone) {
62
+ if (zone !== value) {
63
+ onChange(zone);
64
+ }
65
+ close(true);
66
+ }
67
+ function move(step) {
68
+ setActive((index) => {
69
+ const from = index < 0 ? shown.findIndex((entry) => entry.zone === value) : index;
70
+ return Math.min(Math.max(from + step, 0), shown.length - 1);
71
+ });
72
+ }
73
+ function handleSearchKeyDown(event) {
74
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
75
+ event.preventDefault();
76
+ move(event.key === "ArrowDown" ? 1 : -1);
77
+ }
78
+ else if (event.key === "Enter") {
79
+ // Never the form's own submit.
80
+ event.preventDefault();
81
+ const entry = shown[active >= 0 ? active : 0];
82
+ if (entry && (active >= 0 || shown.length === 1)) {
83
+ choose(entry.zone);
84
+ }
85
+ }
86
+ else if (event.key === "Escape") {
87
+ event.stopPropagation();
88
+ close(true);
89
+ }
90
+ }
91
+ 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) => {
92
+ if (event.key === "ArrowDown" && !open) {
93
+ event.preventDefault();
94
+ setOpen(true);
95
+ }
96
+ }, 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) => {
97
+ setQuery(event.target.value);
98
+ setActive(-1);
99
+ }, 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,
100
+ // Before the search box's blur or the outside-click check, which would otherwise close the list first.
101
+ onMouseDown: (event) => {
102
+ event.preventDefault();
103
+ choose(entry.zone);
104
+ }, className: [
105
+ "cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-alt",
106
+ entry.zone === value ? "font-semibold text-primary-dark" : "",
107
+ index === active ? "bg-surface-alt" : "",
108
+ ].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." })] })] }))] }));
109
+ }
@@ -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
@@ -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
- const res = await fetch(apiUrl(path), { ...init, headers, credentials });
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
- * Every time zone the browser can name, for a picker: `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
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 sortTimeZones(zones: readonly string[], at?: Date): string[];
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,64 @@ export function deviceTimeZone() {
17
17
  }
18
18
  }
19
19
  /**
20
- * Every time zone the browser can name, for a picker: `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
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) => formatterFor(zone, 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
+ /** Building an `Intl.DateTimeFormat` is by far the costly part of naming a zone's clock, and a list names hundreds: each is made once. */
33
+ const formatters = new Map();
34
+ function formatterFor(zone, style) {
35
+ const key = `${style}|${zone}`;
36
+ let formatter = formatters.get(key);
37
+ if (!formatter) {
38
+ // Throws for a zone the runtime doesn't know, which is then not remembered.
39
+ formatter = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style });
40
+ formatters.set(key, formatter);
41
+ }
42
+ return formatter;
43
+ }
44
+ /** 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. */
45
+ function zoneNames(zone) {
46
+ const parts = zone.split("/");
47
+ const city = parts.pop().replace(/_/g, " ");
48
+ return { city, region: parts.join("/").replace(/_/g, " ") };
49
+ }
50
+ /**
51
+ * 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
52
+ * is read at `at` (now, unless a date matters). A zone the runtime doesn't know is just its name.
53
+ */
54
+ export function describeTimeZone(zone, at = new Date()) {
55
+ const clock = zoneClock(zone, at);
56
+ if (!clock) {
57
+ return zone.replace(/_/g, " ");
58
+ }
59
+ const { city, region } = zoneNames(zone);
60
+ return `${city}${region ? `, ${region}` : ""} (GMT${clock.offset})`;
61
+ }
62
+ /**
63
+ * 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
64
+ * doesn't know comes last.
65
+ */
66
+ export function sortTimeZones(zones, at = new Date()) {
67
+ // Each zone's offset is read once, not once per comparison.
68
+ const keyed = zones.map((zone) => {
69
+ const offset = zoneClock(zone, at)?.offset;
70
+ const minutes = offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
71
+ return { zone, minutes, city: zoneNames(zone).city };
72
+ });
73
+ keyed.sort((a, b) => (a.minutes === b.minutes ? 0 : a.minutes < b.minutes ? -1 : 1) || a.city.localeCompare(b.city) || a.zone.localeCompare(b.zone));
74
+ return keyed.map((entry) => entry.zone);
75
+ }
76
+ /**
77
+ * 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
78
  * which is added), else just `extra` - the zones the caller already holds, such as a mailbox's current one.
22
79
  */
23
80
  export function timeZoneOptions(...extra) {
@@ -28,5 +85,5 @@ export function timeZoneOptions(...extra) {
28
85
  catch {
29
86
  zones = [];
30
87
  }
31
- return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => a.localeCompare(b));
88
+ return sortTimeZones([...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])]);
32
89
  }
@@ -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
 
21
21
  /** An access token's age at which it is refreshed. The token lives an hour; the margin leaves time to retry a failed refresh. */
@@ -76,6 +76,45 @@ export async function refreshSession(authServerUrl: string, force = false): Prom
76
76
  return locks ? locks.request(LOCK_NAME, run) : run();
77
77
  }
78
78
 
79
+ /** 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. */
80
+ export const SESSION_RECOVERY_COOLDOWN_MS = 10 * 1000;
81
+
82
+ let recovery: Promise<boolean> | undefined;
83
+ let lastRecovery: { at: number; renewed: boolean } | undefined;
84
+
85
+ /**
86
+ * Renews the session because a request was refused for lack of one, whatever this browser's record of the last refresh says. Every request
87
+ * 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
88
+ * the last refresh, so the next regular check asks again (and sends the browser to sign-in if the refresh token is truly gone).
89
+ *
90
+ * @returns Whether the session was renewed.
91
+ */
92
+ export function recoverSession(authServerUrl: string): Promise<boolean> {
93
+ if (lastRecovery && Date.now() - lastRecovery.at < SESSION_RECOVERY_COOLDOWN_MS) {
94
+ return Promise.resolve(lastRecovery.renewed);
95
+ }
96
+ recovery ??= refreshSession(authServerUrl, true)
97
+ .then(
98
+ () => true,
99
+ () => {
100
+ writeTimestamp(() => localStorage, REFRESHED_AT_KEY, 0);
101
+ return false;
102
+ },
103
+ )
104
+ .then((renewed) => {
105
+ lastRecovery = { at: Date.now(), renewed };
106
+ recovery = undefined;
107
+ return renewed;
108
+ });
109
+ return recovery;
110
+ }
111
+
112
+ /** Forgets what recoveries have done (for tests). */
113
+ export function resetSessionRecovery(): void {
114
+ recovery = undefined;
115
+ lastRecovery = undefined;
116
+ }
117
+
79
118
  /** Whether `err` says auth-server rejected the refresh token itself, as opposed to a failure worth retrying. */
80
119
  function isAuthRejection(err: unknown): boolean {
81
120
  return err instanceof ApiRequestError && (err.status === 401 || err.status === 403);
@@ -129,7 +168,8 @@ export interface SessionRefreshOptions {
129
168
  * lifetime, or reopened later): attempts one silent refresh and reloads so the server renders the page with the new
130
169
  * cookie. If the refresh is refused (the refresh token expired or was revoked, or there was none), or a reload just
131
170
  * made still found no session, redirects to sign-in as `useRedirectIfUnauthenticated()` does.
132
- * - `userUid` set: refreshes once the token is `SESSION_REFRESH_AFTER_MS` old (and right away when this browser has no
171
+ * - `userUid` set: also renews the session when a request is refused for lack of one (`apiFetch()` asks `recoverSession()` and sends the request
172
+ * 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
133
173
  * record of a recent refresh, since the page's token age is unknown), checked every `SESSION_REFRESH_CHECK_MS` and
134
174
  * whenever the tab becomes visible or the network returns, because a sleeping laptop stops timers. A failure that is
135
175
  * not a rejection is simply retried at the next check. A rejection (`401`/`403`: the refresh token expired or was
@@ -169,7 +209,8 @@ export function useSessionRefresh(userUid: string | undefined, authServerUrl: st
169
209
  window.location.href = signInUrl(authServerUrl);
170
210
  return;
171
211
  }
172
- refreshSession(authServerUrl)
212
+ // Forced: the page has no session, so what this browser remembers of the last refresh says nothing about whether one is due.
213
+ refreshSession(authServerUrl, true)
173
214
  .then(() => {
174
215
  if (!cancelled) {
175
216
  writeTimestamp(() => sessionStorage, RELOADED_AT_KEY, Date.now());
@@ -190,6 +231,9 @@ export function useSessionRefresh(userUid: string | undefined, authServerUrl: st
190
231
  return;
191
232
  }
192
233
 
234
+ // A request the server refuses for lack of a session renews it (see `apiFetch()`), instead of waiting for the next check.
235
+ setApiSessionRecovery(() => recoverSession(authServerUrl));
236
+
193
237
  let leaving = false;
194
238
  let inFlight = false;
195
239
  const leave = async () => {
@@ -230,6 +274,7 @@ export function useSessionRefresh(userUid: string | undefined, authServerUrl: st
230
274
  window.addEventListener("online", check);
231
275
  return () => {
232
276
  cancelled = true;
277
+ setApiSessionRecovery(undefined);
233
278
  window.clearInterval(interval);
234
279
  document.removeEventListener("visibilitychange", onVisible);
235
280
  window.removeEventListener("online", check);
@@ -0,0 +1,185 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react";
6
+ import { describeTimeZone, sortTimeZones } from "../../util/timeZone.js";
7
+
8
+ export interface TimeZonePickerProps {
9
+ id?: string;
10
+ "aria-label"?: string;
11
+ "aria-describedby"?: string;
12
+ /** The chosen zone (an IANA id). */
13
+ value: string;
14
+ /** The zones to choose between; the list orders them itself. */
15
+ zones: string[];
16
+ onChange: (zone: string) => void;
17
+ /** The date each zone's offset is read on (daylight saving); now unless a date matters. */
18
+ at?: Date;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * 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
24
+ * name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
25
+ * through the list, Enter chooses, and Escape (or a click elsewhere) closes it without changing anything.
26
+ */
27
+ export default function TimeZonePicker({ id, "aria-label": label, "aria-describedby": describedBy, value, zones, onChange, at, className }: TimeZonePickerProps) {
28
+ const listId = useId();
29
+ const root = useRef<HTMLDivElement>(null);
30
+ const trigger = useRef<HTMLButtonElement>(null);
31
+ const search = useRef<HTMLInputElement>(null);
32
+ const selected = useRef<HTMLLIElement>(null);
33
+ const [open, setOpen] = useState(false);
34
+ const [query, setQuery] = useState("");
35
+ const [active, setActive] = useState(-1);
36
+
37
+ // Listed by the offsets shown (which move with daylight saving): west to east, then by city. Worked out only while the list is open - naming several
38
+ // hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
39
+ const atTime = at?.getTime();
40
+ const entries = useMemo(() => {
41
+ if (!open) {
42
+ return [];
43
+ }
44
+ const on = new Date(atTime ?? Date.now());
45
+ return sortTimeZones(zones, on).map((zone) => {
46
+ const text = describeTimeZone(zone, on);
47
+ return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
48
+ });
49
+ }, [open, zones, atTime]);
50
+ const shown = useMemo(() => {
51
+ const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
52
+ return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
53
+ }, [entries, query]);
54
+
55
+ useEffect(() => {
56
+ if (!open) {
57
+ return undefined;
58
+ }
59
+ search.current?.focus();
60
+ selected.current?.scrollIntoView?.({ block: "center" });
61
+ const outside = (event: PointerEvent) => {
62
+ if (!root.current?.contains(event.target as Node)) {
63
+ close(false);
64
+ }
65
+ };
66
+ document.addEventListener("pointerdown", outside);
67
+ return () => document.removeEventListener("pointerdown", outside);
68
+ }, [open]);
69
+
70
+ function close(refocus: boolean) {
71
+ setOpen(false);
72
+ setQuery("");
73
+ setActive(-1);
74
+ if (refocus) {
75
+ trigger.current?.focus();
76
+ }
77
+ }
78
+
79
+ function choose(zone: string) {
80
+ if (zone !== value) {
81
+ onChange(zone);
82
+ }
83
+ close(true);
84
+ }
85
+
86
+ function move(step: number) {
87
+ setActive((index) => {
88
+ const from = index < 0 ? shown.findIndex((entry) => entry.zone === value) : index;
89
+ return Math.min(Math.max(from + step, 0), shown.length - 1);
90
+ });
91
+ }
92
+
93
+ function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
94
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
95
+ event.preventDefault();
96
+ move(event.key === "ArrowDown" ? 1 : -1);
97
+ } else if (event.key === "Enter") {
98
+ // Never the form's own submit.
99
+ event.preventDefault();
100
+ const entry = shown[active >= 0 ? active : 0];
101
+ if (entry && (active >= 0 || shown.length === 1)) {
102
+ choose(entry.zone);
103
+ }
104
+ } else if (event.key === "Escape") {
105
+ event.stopPropagation();
106
+ close(true);
107
+ }
108
+ }
109
+
110
+ return (
111
+ <div ref={root} className="relative inline-block max-w-full">
112
+ <button
113
+ ref={trigger}
114
+ id={id}
115
+ type="button"
116
+ role="combobox"
117
+ aria-label={label}
118
+ aria-describedby={describedBy}
119
+ aria-haspopup="listbox"
120
+ aria-expanded={open}
121
+ aria-controls={open ? listId : undefined}
122
+ className={["flex items-center justify-between gap-2 text-left", className].filter(Boolean).join(" ")}
123
+ onClick={() => setOpen((wasOpen) => !wasOpen)}
124
+ onKeyDown={(event) => {
125
+ if (event.key === "ArrowDown" && !open) {
126
+ event.preventDefault();
127
+ setOpen(true);
128
+ }
129
+ }}
130
+ >
131
+ <span className="truncate">{describeTimeZone(value, at)}</span>
132
+ <span aria-hidden="true" className="shrink-0 text-text-muted">
133
+ &#9662;
134
+ </span>
135
+ </button>
136
+ {open && (
137
+ <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">
138
+ <div className="p-2">
139
+ <input
140
+ ref={search}
141
+ type="text"
142
+ role="searchbox"
143
+ aria-label="Search time zones"
144
+ aria-controls={listId}
145
+ aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
146
+ autoComplete="off"
147
+ placeholder="Search"
148
+ 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"
149
+ value={query}
150
+ onChange={(event) => {
151
+ setQuery(event.target.value);
152
+ setActive(-1);
153
+ }}
154
+ onKeyDown={handleSearchKeyDown}
155
+ />
156
+ </div>
157
+ <ul id={listId} role="listbox" aria-label="Time zones" className="max-h-60 overflow-y-auto pb-1">
158
+ {shown.map((entry, index) => (
159
+ <li
160
+ key={entry.zone}
161
+ id={`${listId}-${index}`}
162
+ ref={entry.zone === value ? selected : undefined}
163
+ role="option"
164
+ aria-selected={entry.zone === value}
165
+ // Before the search box's blur or the outside-click check, which would otherwise close the list first.
166
+ onMouseDown={(event) => {
167
+ event.preventDefault();
168
+ choose(entry.zone);
169
+ }}
170
+ className={[
171
+ "cursor-pointer px-3 py-1.5 text-sm hover:bg-surface-alt",
172
+ entry.zone === value ? "font-semibold text-primary-dark" : "",
173
+ index === active ? "bg-surface-alt" : "",
174
+ ].join(" ")}
175
+ >
176
+ {entry.text}
177
+ </li>
178
+ ))}
179
+ {shown.length === 0 && <li className="px-3 py-1.5 text-sm text-text-muted">No time zones match.</li>}
180
+ </ul>
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }