@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.
- package/apps/admin/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- 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/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- 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/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- 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/CalendarListSidebar.js +3 -2
- 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/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- 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 +109 -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 +59 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +185 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +65 -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
|
+
}
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useContext, useId, useRef } from "react";
|
|
7
|
-
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
|
|
8
8
|
import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
|
|
9
9
|
const CHOICES = [
|
|
10
10
|
{ mode: "system", label: "System", hint: "System - follow this device", Icon: HiOutlineComputerDesktop },
|
|
@@ -42,7 +42,7 @@ export default function ThemeSwitch() {
|
|
|
42
42
|
choose(target, true);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [
|
|
45
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border", children: [_jsxs("span", { id: labelId, className: "flex items-center gap-2 text-sm text-text", children: [_jsx(HiOutlinePaintBrush, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Theme"] }), _jsx("div", { role: "radiogroup", "aria-labelledby": labelId, className: "inline-flex rounded-sm border border-border bg-surface-alt p-0.5", children: CHOICES.map(({ mode, label, hint, Icon }, index) => {
|
|
46
46
|
const selected = mode === current;
|
|
47
47
|
return (_jsx("button", { ref: (element) => {
|
|
48
48
|
buttons.current[index] = element;
|
|
@@ -18,8 +18,7 @@ export interface UserMenuProps {
|
|
|
18
18
|
trustedRoles?: readonly string[];
|
|
19
19
|
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
20
20
|
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
21
|
-
* `/settings/
|
|
22
|
-
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
21
|
+
* `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
|
|
23
22
|
showSettingsLink?: boolean;
|
|
24
23
|
/** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
|
|
25
24
|
* while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
8
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
9
|
-
import { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineArrowRightOnRectangle, HiOutlineArrowUturnLeft, HiOutlineBell, HiOutlineBellAlert, HiOutlineClock, HiOutlineCog6Tooth, HiOutlineCommandLine, HiOutlineShieldCheck, HiOutlineUserCircle, } from "react-icons/hi2";
|
|
10
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
11
11
|
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
12
12
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -206,11 +206,11 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
206
206
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
207
207
|
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
|
|
208
208
|
anchor &&
|
|
209
|
-
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (
|
|
209
|
+
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsxs("a", { role: "menuitem", href: accountUrl, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineUserCircle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Account"] })), showSettingsLink && (_jsxs("a", { role: "menuitem", href: SETTINGS_HREF, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCog6Tooth, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Settings"] })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineBell, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Notifications"] }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineBellAlert, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Turn on desktop notifications"] })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
210
210
|
setOpen(false);
|
|
211
211
|
onShowNotifications();
|
|
212
|
-
}, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [
|
|
212
|
+
}, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(HiOutlineClock, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Recent notifications"] }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsxs("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
213
213
|
setOpen(false);
|
|
214
214
|
onShowShortcuts();
|
|
215
|
-
}, className: "
|
|
215
|
+
}, className: "flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineCommandLine, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Keyboard shortcuts"] })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsxs("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowRightOnRectangle, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Sign Out"] })] }), document.body)] }));
|
|
216
216
|
}
|
|
@@ -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) {
|
|
@@ -22,6 +22,7 @@ import { primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
|
22
22
|
import { badgeFor, countOfFolder, inboxUnreadTotal, unreadBadgeTotal, } from "../../../mail/folderCounts.js";
|
|
23
23
|
import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
|
|
24
24
|
import FolderBadgeChip from "./FolderBadgeChip.js";
|
|
25
|
+
import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
|
|
25
26
|
import SidebarSection from "./SidebarSection.js";
|
|
26
27
|
import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
|
|
27
28
|
import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
|
|
@@ -220,13 +221,13 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
220
221
|
aggregateFolderType === type
|
|
221
222
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
222
223
|
: "text-text hover:bg-surface-alt",
|
|
223
|
-
].join(" "), children: [
|
|
224
|
-
}) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, mailbox.ownerUserUid
|
|
224
|
+
].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[type] })] }), badge && _jsx(FolderBadgeChip, { badge: badge })] }, type));
|
|
225
|
+
}) })), mailboxFolders.map(({ mailbox, folders, error: mailboxError }) => (_jsx(SidebarSection, { domId: `${idPrefix}-mail-section-${mailbox.uid}`, label: _jsxs(_Fragment, { children: [mailbox.displayName, !mailbox.ownerUserUid && _jsx(SharedMailboxMark, {})] }), collapsible: collapsible, expanded: sections.isExpanded(mailbox.uid), onToggle: () => sections.toggle(mailbox.uid), unread: unreadBadgeTotal(folders, counts), notice: mailboxError && (_jsx("div", { className: "px-2.5 pb-1", children: _jsx(Alert, { children: mailboxError }) })), children: folderRows(folders, pendingCountFor(mailbox.uid, pendingSends)).map((row) => {
|
|
225
226
|
if (row.kind === "placeholder") {
|
|
226
227
|
// A folder the server has not made (or told us about) yet - the Outbox and Sent Items are made with the first message
|
|
227
228
|
// sent - shown the moment a message is on its way. Nothing to open; the real folder replaces it when it arrives.
|
|
228
229
|
const sending = pendingCountFor(mailbox.uid, pendingSends);
|
|
229
|
-
return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsx("span", { children: FOLDER_LABELS[row.type] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
|
|
230
|
+
return (_jsxs("div", { "data-folder-placeholder": row.type, className: "flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted", children: [_jsxs("span", { className: "flex items-center gap-2.5 min-w-0", children: [_jsx(FolderTypeIcon, { type: row.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[row.type] })] }), row.type === "outbox" && _jsx(OutboxBadge, { total: sending, pendingHere: sending })] }, `placeholder-${row.type}`));
|
|
230
231
|
}
|
|
231
232
|
const folder = row.folder;
|
|
232
233
|
const badge = badgeFor(folder.type, countOfFolder(folder, counts));
|
|
@@ -235,7 +236,7 @@ export default function MailShell({ userUid, authServerUrl, impersonating, imper
|
|
|
235
236
|
folder.uid === folderUid
|
|
236
237
|
? "bg-primary/10 text-primary-dark font-semibold"
|
|
237
238
|
: "text-text hover:bg-surface-alt",
|
|
238
|
-
].join(" "), children: [
|
|
239
|
+
].join(" "), children: [_jsxs("span", { className: ["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" "), children: [_jsx(FolderTypeIcon, { type: folder.type }), _jsx("span", { className: "truncate", children: FOLDER_LABELS[folder.type] ?? folder.name })] }), badge &&
|
|
239
240
|
(folder.type === "outbox" ? (_jsx(OutboxBadge, { total: badge.value, status: outboxStatuses[folder.uid], pendingHere: pendingCountFor(mailbox.uid, pendingSends) })) : (_jsx(FolderBadgeChip, { badge: badge })))] }, folder.uid));
|
|
240
241
|
}) }, mailbox.uid)))] }))] }));
|
|
241
242
|
inner = (_jsxs(_Fragment, { children: [_jsx(MailShortcuts, { mailboxUid: defaultMailboxUid }), _jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsxs("div", { className: "md:hidden flex items-center gap-2 p-3", children: [_jsx("button", { type: "button", className: "shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("div", { ref: setMobileSearchSlot, className: "flex-1 min-w-0" })] }), _jsx(MobileComposeButton, { mailboxUid: defaultMailboxUid }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { FolderType } from "../../../../../lib/mail/mailApi.js";
|
|
3
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
4
|
+
export declare function FolderTypeIcon({ type }: {
|
|
5
|
+
type: FolderType | string;
|
|
6
|
+
}): React.JSX.Element;
|
|
7
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
8
|
+
export declare function SharedMailboxMark(): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HiOutlineArchiveBox, HiOutlineArrowUpTray, HiOutlineBookOpen, HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineFolder, HiOutlineInbox, HiOutlinePaperAirplane, HiOutlinePencilSquare, HiOutlineShieldExclamation, HiOutlineTrash, HiOutlineUsers, HiUserGroup, } from "react-icons/hi2";
|
|
3
|
+
const FOLDER_ICONS = {
|
|
4
|
+
inbox: HiOutlineInbox,
|
|
5
|
+
drafts: HiOutlinePencilSquare,
|
|
6
|
+
sent_items: HiOutlinePaperAirplane,
|
|
7
|
+
outbox: HiOutlineArrowUpTray,
|
|
8
|
+
junk: HiOutlineShieldExclamation,
|
|
9
|
+
archive: HiOutlineArchiveBox,
|
|
10
|
+
deleted_items: HiOutlineTrash,
|
|
11
|
+
calendar: HiOutlineCalendarDays,
|
|
12
|
+
contacts: HiOutlineUsers,
|
|
13
|
+
tasks: HiOutlineClipboardDocumentList,
|
|
14
|
+
notes: HiOutlineBookOpen,
|
|
15
|
+
user: HiOutlineFolder,
|
|
16
|
+
};
|
|
17
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
18
|
+
export function FolderTypeIcon({ type }) {
|
|
19
|
+
const Icon = FOLDER_ICONS[type] ?? HiOutlineFolder;
|
|
20
|
+
return _jsx(Icon, { size: 18, "aria-hidden": "true", className: "shrink-0 text-text-muted" });
|
|
21
|
+
}
|
|
22
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
23
|
+
export function SharedMailboxMark() {
|
|
24
|
+
return (_jsxs(_Fragment, { children: [_jsx(HiUserGroup, { size: 14, "aria-hidden": "true", className: "inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" }), " ", _jsx("span", { className: "sr-only", children: "(shared)" })] }));
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
|
+
import type { IconType } from "react-icons";
|
|
2
3
|
import { Mailbox } from "../../../../../lib/mail/mailApi.js";
|
|
3
4
|
import { AppShellProps } from "../../layout/AppShell.js";
|
|
4
5
|
import { PluginNav } from "../../../plugins/pluginNav.js";
|
|
@@ -6,6 +7,8 @@ export interface SettingsSectionDef {
|
|
|
6
7
|
id: string;
|
|
7
8
|
href: string;
|
|
8
9
|
label: string;
|
|
10
|
+
/** Drawn beside the label in the sidebar. */
|
|
11
|
+
icon: IconType;
|
|
9
12
|
}
|
|
10
13
|
/** Every settings section with its own sidebar entry. */
|
|
11
14
|
export declare const SETTINGS_SECTIONS: SettingsSectionDef[];
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
-
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
7
|
+
import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineTag, HiOutlineUserCircle, HiOutlineUsers, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
9
9
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
10
10
|
import { listMailboxes } from "../../../../../lib/mail/mailApi.js";
|
|
@@ -18,17 +18,17 @@ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.
|
|
|
18
18
|
import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
|
|
19
19
|
/** Every settings section with its own sidebar entry. */
|
|
20
20
|
export const SETTINGS_SECTIONS = [
|
|
21
|
-
{ id: "profile", href: "/settings/profile", label: "Profile" },
|
|
22
|
-
{ id: "appearance", href: "/settings/appearance", label: "Appearance" },
|
|
23
|
-
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
24
|
-
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
25
|
-
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
|
|
26
|
-
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
27
|
-
{ id: "labels", href: "/settings/labels", label: "Labels" },
|
|
28
|
-
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
29
|
-
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
30
|
-
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
31
|
-
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
21
|
+
{ id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
|
|
22
|
+
{ id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
|
|
23
|
+
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
|
|
24
|
+
{ id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
|
|
25
|
+
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
|
|
26
|
+
{ id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
|
|
27
|
+
{ id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
|
|
28
|
+
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
|
|
29
|
+
{ id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
|
|
30
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
|
|
31
|
+
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
|
|
32
32
|
];
|
|
33
33
|
/** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
|
|
34
34
|
* `mergePluginNavItems`. */
|
|
@@ -37,6 +37,7 @@ export function settingsSections(pluginNav) {
|
|
|
37
37
|
id,
|
|
38
38
|
href,
|
|
39
39
|
label,
|
|
40
|
+
icon: HiOutlinePuzzlePiece,
|
|
40
41
|
}));
|
|
41
42
|
}
|
|
42
43
|
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
@@ -111,7 +112,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
111
112
|
// server didn't send its nav item, so that falls back to the current path.
|
|
112
113
|
const href = sections.find((s) => s.id === active)?.href ?? window.location.pathname;
|
|
113
114
|
navigate(`${href}?mailboxUid=${encodeURIComponent(e.target.value)}`);
|
|
114
|
-
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (
|
|
115
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsxs("a", {
|
|
115
116
|
// `inner` (this whole branch) is only ever computed once `status === "ready"`
|
|
116
117
|
// and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
|
|
117
118
|
// `<MailboxProvisioning />` above instead (which is what the `!` below records,
|
|
@@ -120,9 +121,9 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
|
|
|
120
121
|
// structurally can't trigger" removal precedent (e.g.
|
|
121
122
|
// `DomainDetailContent.handleVerify`).
|
|
122
123
|
href: `${section.href}?mailboxUid=${encodeURIComponent(mailboxUid)}`, "aria-current": section.id === active ? "page" : undefined, className: [
|
|
123
|
-
"
|
|
124
|
+
"flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
|
|
124
125
|
section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
|
|
125
|
-
].join(" "), children: section.label }, section.id))) })] }));
|
|
126
|
+
].join(" "), children: [_jsx(section.icon, { size: 18, "aria-hidden": "true", className: "shrink-0" }), section.label] }, section.id))) })] }));
|
|
126
127
|
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
127
128
|
}
|
|
128
129
|
return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
@@ -6,9 +6,9 @@ export declare const APP_HREFS: {
|
|
|
6
6
|
readonly tasks: "/tasks";
|
|
7
7
|
};
|
|
8
8
|
/**
|
|
9
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
10
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
9
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
10
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
11
11
|
*/
|
|
12
|
-
export declare const SETTINGS_HREF = "/settings/
|
|
12
|
+
export declare const SETTINGS_HREF = "/settings/profile";
|
|
13
13
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
14
14
|
export declare function isSettingsPath(pathname: string): boolean;
|
|
@@ -10,10 +10,10 @@ export const APP_HREFS = {
|
|
|
10
10
|
tasks: "/tasks",
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
14
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
13
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
14
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
15
15
|
*/
|
|
16
|
-
export const SETTINGS_HREF = "/settings/
|
|
16
|
+
export const SETTINGS_HREF = "/settings/profile";
|
|
17
17
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
18
18
|
export function isSettingsPath(pathname) {
|
|
19
19
|
return pathname === "/settings" || pathname.startsWith("/settings/");
|
|
@@ -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
|