@rapidmx/web-client 0.22.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.
Files changed (79) hide show
  1. package/apps/admin/mailboxes/[uid].tsx +2 -1
  2. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
  3. package/apps/shared/components/admin/setup/SetupWizard.tsx +1 -1
  4. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
  5. package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
  6. package/apps/shared/components/calendar/EventDetails.tsx +15 -6
  7. package/apps/shared/components/calendar/EventEditor.tsx +5 -2
  8. package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
  9. package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
  10. package/apps/shared/components/calendar/FindATime.tsx +3 -2
  11. package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
  12. package/apps/shared/components/calendar/TimeGridView.tsx +3 -1
  13. package/apps/shared/components/calendar/eventFormat.ts +15 -0
  14. package/apps/shared/components/calendar/timePicker.ts +106 -0
  15. package/apps/shared/components/mail/ConversationList.tsx +18 -5
  16. package/apps/shared/components/mail/ConversationThreadPane.tsx +7 -2
  17. package/apps/shared/components/mail/MessageDetailPane.tsx +37 -18
  18. package/apps/shared/components/mail/invite/InviteRowChip.tsx +8 -12
  19. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +2 -2
  20. package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
  21. package/apps/shared/components/mail/reading/conversationPrint.ts +76 -0
  22. package/apps/shared/components/mail/reading/printMessage.ts +44 -7
  23. package/apps/shared/keyboard/keymap.ts +1 -0
  24. package/apps/shared/mail/decryptedMessages.ts +116 -0
  25. package/apps/www/calendar/index.tsx +5 -2
  26. package/apps/www/index.tsx +11 -0
  27. package/apps/www/settings/profile/index.tsx +8 -12
  28. package/dist/apps/admin/mailboxes/[uid].js +2 -1
  29. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
  30. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +4 -4
  31. package/dist/apps/shared/components/admin/setup/SetupWizard.js +1 -1
  32. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
  33. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
  34. package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
  35. package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
  36. package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
  37. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
  38. package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
  39. package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
  40. package/dist/apps/shared/components/calendar/FindATime.js +3 -2
  41. package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
  42. package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
  43. package/dist/apps/shared/components/calendar/TimeGridView.js +1 -1
  44. package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
  45. package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
  46. package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
  47. package/dist/apps/shared/components/calendar/timePicker.js +89 -0
  48. package/dist/apps/shared/components/mail/ConversationList.js +17 -4
  49. package/dist/apps/shared/components/mail/ConversationThreadPane.js +6 -2
  50. package/dist/apps/shared/components/mail/MessageDetailPane.js +31 -18
  51. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +2 -2
  52. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +7 -9
  53. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +1 -1
  54. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +2 -2
  55. package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
  56. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +15 -0
  57. package/dist/apps/shared/components/mail/reading/conversationPrint.js +69 -0
  58. package/dist/apps/shared/components/mail/reading/printMessage.d.ts +7 -1
  59. package/dist/apps/shared/components/mail/reading/printMessage.js +33 -6
  60. package/dist/apps/shared/keyboard/keymap.d.ts +6 -0
  61. package/dist/apps/shared/keyboard/keymap.js +1 -0
  62. package/dist/apps/shared/mail/decryptedMessages.d.ts +27 -0
  63. package/dist/apps/shared/mail/decryptedMessages.js +97 -0
  64. package/dist/apps/www/calendar/index.js +5 -2
  65. package/dist/apps/www/index.js +10 -0
  66. package/dist/apps/www/settings/profile/index.js +6 -5
  67. package/dist/lib/auth/session.d.ts +14 -1
  68. package/dist/lib/auth/session.js +40 -3
  69. package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
  70. package/dist/lib/components/pickers/TimeZonePicker.js +100 -0
  71. package/dist/lib/util/api.d.ts +6 -0
  72. package/dist/lib/util/api.js +22 -1
  73. package/dist/lib/util/timeZone.d.ts +19 -1
  74. package/dist/lib/util/timeZone.js +46 -2
  75. package/lib/auth/session.ts +48 -3
  76. package/lib/components/pickers/TimeZonePicker.tsx +180 -0
  77. package/lib/util/api.ts +24 -1
  78. package/lib/util/timeZone.ts +50 -2
  79. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { useSyncExternalStore } from "react";
6
+ import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
7
+ import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
8
+
9
+ /**
10
+ * What this device has recovered of encrypted messages by decrypting them: the real subject (the server only ever holds RFC 9788's `[...]`
11
+ * placeholder) and a short preview. Shared by every view that draws a message - the message list, the conversation list and the reading pane -
12
+ * so a message decrypted in one shows its subject in all of them. Held in memory only, keyed by message uid, and dropped for a mailbox the moment
13
+ * its keys are locked, exactly like the decrypted content it came from.
14
+ */
15
+ export interface DecryptedMessage {
16
+ mailboxUid: string;
17
+ subject?: string;
18
+ preview?: string;
19
+ }
20
+
21
+ const PREVIEW_MAX_LENGTH = 160;
22
+
23
+ let entries: Record<string, DecryptedMessage> = {};
24
+ /** Bumped whenever a mailbox's keys are unlocked, so a view can retry decrypting what it couldn't before. */
25
+ let unlockEpoch = 0;
26
+ const listeners = new Set<() => void>();
27
+ const inFlight = new Set<string>();
28
+
29
+ function emit(): void {
30
+ for (const listener of [...listeners]) {
31
+ listener();
32
+ }
33
+ }
34
+
35
+ subscribeKeySession(({ mailboxUid, state }) => {
36
+ if (state === "locked") {
37
+ const kept = Object.fromEntries(Object.entries(entries).filter(([, entry]) => entry.mailboxUid !== mailboxUid));
38
+ if (Object.keys(kept).length !== Object.keys(entries).length) {
39
+ entries = kept;
40
+ }
41
+ } else {
42
+ unlockEpoch++;
43
+ }
44
+ emit();
45
+ });
46
+
47
+ function subscribe(listener: () => void): () => void {
48
+ listeners.add(listener);
49
+ return () => {
50
+ listeners.delete(listener);
51
+ };
52
+ }
53
+
54
+ /** Plain text of decrypted HTML for a one-line preview. Only ever feeds text display, never markup. */
55
+ export function previewOf(html: string): string {
56
+ return html
57
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
58
+ .replace(/<[^>]+>/g, " ")
59
+ .replace(/&nbsp;/gi, " ")
60
+ .replace(/&amp;/gi, "&")
61
+ .replace(/&lt;/gi, "<")
62
+ .replace(/&gt;/gi, ">")
63
+ .replace(/&quot;/gi, '"')
64
+ .replace(/&#0*39;/gi, "'")
65
+ .replace(/\s+/g, " ")
66
+ .trim()
67
+ .slice(0, PREVIEW_MAX_LENGTH);
68
+ }
69
+
70
+ /** Remembers what was recovered of one message. Ignored when there is nothing new to show. */
71
+ export function rememberDecrypted(uid: string, mailboxUid: string, found: { subject?: string; preview?: string }): void {
72
+ if (!found.subject && !found.preview) {
73
+ return;
74
+ }
75
+ const current = entries[uid];
76
+ if (current && current.subject === found.subject && current.preview === found.preview) {
77
+ return;
78
+ }
79
+ entries = { ...entries, [uid]: { mailboxUid, subject: found.subject, preview: found.preview } };
80
+ emit();
81
+ }
82
+
83
+ /** Every message decrypted so far, by uid; a new object whenever any changes. */
84
+ export function useDecryptedMessages(): Record<string, DecryptedMessage> {
85
+ return useSyncExternalStore(subscribe, () => entries);
86
+ }
87
+
88
+ /** A number that changes whenever a mailbox is unlocked - a dependency for effects that decrypt what they can. */
89
+ export function useUnlockEpoch(): number {
90
+ return useSyncExternalStore(subscribe, () => unlockEpoch);
91
+ }
92
+
93
+ /**
94
+ * Decrypts one message to recover its subject and preview, when its mailbox's keys are unlocked and it isn't already known. Best effort: a
95
+ * message that can't be fetched or opened is simply left as it was. Never prompts.
96
+ */
97
+ export async function decryptForDisplay(uid: string, mailboxUid: string): Promise<void> {
98
+ const unlocked = getUnlockedKeys(mailboxUid);
99
+ if (!unlocked || entries[uid] || inFlight.has(uid)) {
100
+ return;
101
+ }
102
+ inFlight.add(uid);
103
+ try {
104
+ const rawMime = await getMessageRawContent(uid);
105
+ const { evaluateMessageSecurity } = await import("../../../lib/crypto/messageSecurity.js");
106
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
107
+ // A lock while this ran: the result must not go back on screen.
108
+ if (getUnlockedKeys(mailboxUid) === unlocked) {
109
+ rememberDecrypted(uid, mailboxUid, { subject: security.subject, preview: security.html ? previewOf(security.html) : security.text?.slice(0, PREVIEW_MAX_LENGTH) });
110
+ }
111
+ } catch {
112
+ // Left as the placeholder.
113
+ } finally {
114
+ inFlight.delete(uid);
115
+ }
116
+ }
@@ -43,6 +43,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
43
43
  import Alert from "../../../lib/components/feedback/Alert.js";
44
44
  import Button from "../../../lib/components/buttons/Button.js";
45
45
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
46
+ import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
46
47
  import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
47
48
  import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
48
49
  import { useWheelPaging, type WheelEdge } from "../../shared/gestures/useWheelPaging.js";
@@ -357,10 +358,12 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
357
358
  if (!mailboxUid || !folderUid) {
358
359
  return;
359
360
  }
361
+ // With nothing clicked, the next half hour, for an hour.
362
+ const from = start ?? nextHalfHour(new Date());
360
363
  setModal({
361
364
  occurrence: null,
362
- initialStart: start ?? new Date(),
363
- initialEnd: end ?? new Date(Date.now() + 30 * 60_000),
365
+ initialStart: from,
366
+ initialEnd: end ?? new Date(from.getTime() + 60 * 60_000),
364
367
  targetFolderUid,
365
368
  anchor,
366
369
  initialAllDay,
@@ -69,6 +69,7 @@ import { setReadStateMany } from "../shared/mail/messageReadState.js";
69
69
  import { useMarkMessageRead } from "../shared/mail/useMarkMessageRead.js";
70
70
  import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "../shared/components/mail/unreadStyle.js";
71
71
  import { LazyConversationThreadPane, LazyMessageDetailPane, prefetchReadingPane } from "../shared/components/mail/LazyReadingPane.js";
72
+ import { rememberDecrypted } from "../shared/mail/decryptedMessages.js";
72
73
  import ConversationList, { ListedConversation, conversationRowKey } from "../shared/components/mail/ConversationList.js";
73
74
  import type { ConversationThreadHead } from "../shared/components/mail/ConversationThreadPane.js";
74
75
  import SwipeRow from "../shared/components/mail/SwipeRow.js";
@@ -159,6 +160,7 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
159
160
  return null;
160
161
  }
161
162
  const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
163
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: security.subject, preview });
162
164
  return [message.uid, { subject: security.subject, preview }];
163
165
  } catch {
164
166
  return null;
@@ -2497,11 +2499,20 @@ function InboxContent({ userUid }: { userUid?: string }) {
2497
2499
  );
2498
2500
  }
2499
2501
 
2502
+ /** Shift+Delete, from the keyboard: permanently deletes the targets wherever they are, after the same confirmation as Delete in Deleted Items. */
2503
+ async function permanentDeleteFromKeyboard() {
2504
+ const targets = await keyboardTargets();
2505
+ if (targets.length > 0 && (await purgeChosen(targets)) && threadPane && !selectMode) {
2506
+ closeOpenThread();
2507
+ }
2508
+ }
2509
+
2500
2510
  useShortcut(SHORTCUTS.mail.next, () => selectNeighbour(1), { enabled: canMoveSelection });
2501
2511
  useShortcut(SHORTCUTS.mail.previous, () => selectNeighbour(-1), { enabled: canMoveSelection });
2502
2512
  useShortcut(SHORTCUTS.mail.nextUnread, () => selectNextUnread(1), { enabled: canMoveSelection });
2503
2513
  useShortcut(SHORTCUTS.mail.previousUnread, () => selectNextUnread(-1), { enabled: canMoveSelection });
2504
2514
  useShortcut(SHORTCUTS.mail.delete, () => void deleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2515
+ useShortcut(SHORTCUTS.mail.permanentDelete, () => void permanentDeleteFromKeyboard(), { enabled: keyboardActions && keyboardTargetExists });
2505
2516
  useShortcut(
2506
2517
  SHORTCUTS.mail.markRead,
2507
2518
  () =>
@@ -5,7 +5,8 @@
5
5
  import { pageTitle } from "../../../shared/navigation/pageTitle.js";
6
6
  import React, { FormEvent, useMemo, useState } from "react";
7
7
  import { FreeBusyVisibility, freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
8
- import { DEFAULT_TIME_ZONE, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
8
+ import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
9
+ import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
9
10
  import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
10
11
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
11
12
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -169,22 +170,17 @@ function ProfileContent() {
169
170
 
170
171
  <FormField label="Time zone" htmlFor="profile-time-zone">
171
172
  <div className="flex flex-wrap items-center gap-2">
172
- <select
173
+ <TimeZonePicker
173
174
  id="profile-time-zone"
174
175
  className={SELECT_CLASS}
175
176
  value={timezone}
176
- onChange={(e) => {
177
- setTimezone(e.target.value);
177
+ zones={zones}
178
+ onChange={(zone) => {
179
+ setTimezone(zone);
178
180
  setSaved(false);
179
181
  }}
180
182
  aria-describedby="profile-time-zone-help"
181
- >
182
- {zones.map((zone) => (
183
- <option key={zone} value={zone}>
184
- {zone}
185
- </option>
186
- ))}
187
- </select>
183
+ />
188
184
  {timezone !== deviceZone && (
189
185
  <Button
190
186
  type="button"
@@ -195,7 +191,7 @@ function ProfileContent() {
195
191
  setSaved(false);
196
192
  }}
197
193
  >
198
- Use this device&rsquo;s time zone ({deviceZone})
194
+ Use this device&rsquo;s time zone ({describeTimeZone(deviceZone)})
199
195
  </Button>
200
196
  )}
201
197
  </div>
@@ -13,6 +13,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
13
13
  import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
14
14
  import Alert from "../../../lib/components/feedback/Alert.js";
15
15
  import Button from "../../../lib/components/buttons/Button.js";
16
+ import { describeTimeZone } from "../../../lib/util/timeZone.js";
16
17
  import Modal from "../../../lib/components/overlays/Modal.js";
17
18
  function formatBytes(bytes) {
18
19
  if (bytes >= 1000000000)
@@ -107,7 +108,7 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
107
108
  const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
108
109
  return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingAccess(true), children: "Impersonate this user" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["The console shows administrative details only - never a mailbox\u2019s mail or settings.", mailbox.ownerUserUid
109
110
  ? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
110
- : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
111
+ : " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: describeTimeZone(mailbox.timezone) }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
111
112
  setEraseData(e.target.checked);
112
113
  setTypedAddress("");
113
114
  } }), _jsx("span", { children: "Also erase all of its data now (permanent)" })] }), eraseData && (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold text-danger mb-2", children: "Everything in this mailbox will be permanently erased, and this cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: mailbox.primarySmtpAddress }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: typedAddress, disabled: deleting, autoComplete: "off", spellCheck: false, onChange: (e) => setTypedAddress(e.target.value) })] })] })), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase()), onClick: handleDelete, children: eraseData ? "Delete and erase all its data" : "Delete" })] })] })] }));
@@ -8,7 +8,7 @@ import { ApiRequestError } from "../../../../../lib/util/api.js";
8
8
  import { getMailboxPolicy } from "../../../../../lib/admin/mailboxPolicyApi.js";
9
9
  import { leftoverConflictOf } from "../../../../../lib/admin/leftoverMailboxApi.js";
10
10
  import { createMailbox, listMailboxDomains, resolveMailboxOwner } from "../../../../../lib/mail/mailApi.js";
11
- import { deviceTimeZone } from "../../../../../lib/util/timeZone.js";
11
+ import { describeTimeZone, deviceTimeZone, zoneClock } from "../../../../../lib/util/timeZone.js";
12
12
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
13
13
  import Alert from "../../../../../lib/components/feedback/Alert.js";
14
14
  import Button from "../../../../../lib/components/buttons/Button.js";
@@ -142,7 +142,7 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
142
142
  } }), "Shared mailbox (no single owner)"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: "ownerMode", checked: ownerMode === "owned", onChange: () => setOwnerMode("owned") }), "Owned by a specific person"] }), ownerMode === "owned" &&
143
143
  (resolvedOwner ? (_jsxs("div", { className: "flex items-center justify-between gap-3 text-sm py-2 px-3 border border-border rounded-sm", children: [_jsxs("span", { children: ["Owner: ", _jsx("strong", { className: "break-all", children: describePerson(resolvedOwner) })] }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: () => setResolvedOwner(null), children: "Change" })] })) : (_jsx(PrincipalResolver, { resolve: (principal) => resolveMailboxOwner(principal, client), onResolved: (person) => {
144
144
  setResolvedOwner(person);
145
- }, initialPrincipal: defaults?.ownerUserUid, placeholder: "Email address, username or user id", ariaLabel: "Mailbox owner", confirmLabel: "Use this owner", confirmErrorMessage: "Could not confirm this person.", describeConfirm: (person) => (_jsxs(_Fragment, { children: ["Make ", _jsx("strong", { className: "break-all", children: describePerson(person) }), " the owner?"] })) })))] })] }), _jsx(FormField, { label: "Timezone", htmlFor: "timezone", children: _jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }) }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => {
145
+ }, initialPrincipal: defaults?.ownerUserUid, placeholder: "Email address, username or user id", ariaLabel: "Mailbox owner", confirmLabel: "Use this owner", confirmErrorMessage: "Could not confirm this person.", describeConfirm: (person) => (_jsxs(_Fragment, { children: ["Make ", _jsx("strong", { className: "break-all", children: describePerson(person) }), " the owner?"] })) })))] })] }), _jsxs(FormField, { label: "Timezone", htmlFor: "timezone", children: [_jsx("input", { id: "timezone", type: "text", className: INPUT_CLASS, value: timezone, onChange: (e) => setTimezone(e.target.value), placeholder: "America/Los_Angeles" }), zoneClock(timezone) && _jsx("p", { className: "text-xs text-text-muted mt-1", children: describeTimeZone(timezone) })] }), _jsx(FormField, { label: "Quota (GB)", htmlFor: "quotaGb", children: _jsx("input", { id: "quotaGb", type: "number", min: 1, step: 1, className: INPUT_CLASS, value: quotaGb, onChange: (e) => {
146
146
  quotaTouched.current = true;
147
147
  setQuotaGb(Number(e.target.value));
148
148
  } }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: isResource, onChange: (e) => setIsResource(e.target.checked) }), "This is a resource mailbox (a bookable room or piece of equipment)"] }), isResource && (_jsxs("div", { className: "border border-border rounded-sm p-4 mb-4 flex flex-col gap-4", children: [_jsx(FormField, { label: "Resource type", htmlFor: "resourceType", children: _jsxs("select", { id: "resourceType", className: SELECT_CLASS, value: resourceType, onChange: (e) => setResourceType(e.target.value), children: [_jsx("option", { value: "room", children: "Room" }), _jsx("option", { value: "equipment", children: "Equipment" })] }) }), _jsx(FormField, { label: "Capacity (optional)", htmlFor: "resourceCapacity", children: _jsx("input", { id: "resourceCapacity", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: resourceCapacity, onChange: (e) => setResourceCapacity(e.target.value), placeholder: "Informational only" }) }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: autoAcceptBookings, onChange: (e) => setAutoAcceptBookings(e.target.checked) }), "Automatically accept booking requests"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: allowConflicts, onChange: (e) => setAllowConflicts(e.target.checked) }), "Allow conflicting bookings (skip conflict checking entirely)"] }), _jsx(FormField, { label: "Booking window, in days (optional)", htmlFor: "bookingWindowDays", children: _jsx("input", { id: "bookingWindowDays", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: bookingWindowDays, onChange: (e) => setBookingWindowDays(e.target.value), placeholder: "No limit" }) }), _jsx(FormField, { label: "Maximum duration, in minutes (optional)", htmlFor: "maxDurationMinutes", children: _jsx("input", { id: "maxDurationMinutes", type: "number", min: 0, step: 1, className: INPUT_CLASS, value: maxDurationMinutes, onChange: (e) => setMaxDurationMinutes(e.target.value), placeholder: "No limit" }) })] })), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: submitLabel }), cancelHref && (_jsx("a", { href: cancelHref, children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", children: "Cancel" }) }))] })] })] }));
@@ -15,14 +15,14 @@ export declare const SETUP_STEPS: readonly [{
15
15
  readonly id: "escrow";
16
16
  readonly title: "Escrow";
17
17
  readonly intro: "Decide whether encrypted mail can be recovered by a trusted group of holders.";
18
- }, {
19
- readonly id: "branding";
20
- readonly title: "Branding";
21
- readonly intro: "Make the sign-in page and apps look like your organization.";
22
18
  }, {
23
19
  readonly id: "mailboxes";
24
20
  readonly title: "Mailboxes";
25
21
  readonly intro: "Create the first mailboxes, starting with your own.";
22
+ }, {
23
+ readonly id: "branding";
24
+ readonly title: "Branding";
25
+ readonly intro: "Make the sign-in page and apps look like your organization.";
26
26
  }];
27
27
  export type SetupStepId = (typeof SETUP_STEPS)[number]["id"];
28
28
  export interface SetupWizardProps {
@@ -36,8 +36,8 @@ export const SETUP_STEPS = [
36
36
  { id: "domain", title: "Domain", intro: "Add the domain this server receives mail for, then publish the DNS records below." },
37
37
  { id: "settings", title: "Server settings", intro: "Set the rules for encryption, how long mail is kept, and new mailboxes." },
38
38
  { id: "escrow", title: "Escrow", intro: "Decide whether encrypted mail can be recovered by a trusted group of holders." },
39
- { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
40
39
  { id: "mailboxes", title: "Mailboxes", intro: "Create the first mailboxes, starting with your own." },
40
+ { id: "branding", title: "Branding", intro: "Make the sign-in page and apps look like your organization." },
41
41
  ];
42
42
  function isStepId(value) {
43
43
  return SETUP_STEPS.some((step) => step.id === value);
@@ -11,6 +11,7 @@ import CopyButton from "../../../../lib/components/buttons/CopyButton.js";
11
11
  import { bookingPublicUrl, createBookingType, newBookingLinkHref, slugFor, } from "../../calendar/bookingPlugin.js";
12
12
  import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
13
13
  import QuickFaceFrame from "./QuickFaceFrame.js";
14
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
14
15
  const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
15
16
  const DURATIONS = ["15", "30", "45", "60"];
16
17
  const BUFFERS = ["0", "5", "10", "15", "30"];
@@ -131,5 +132,5 @@ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, se
131
132
  return (_jsx(QuickFaceFrame, { c: c, tabs: tabs, tab: "appointment", error: error, hideTitle: !!created, onSubmit: (event) => void handleSubmit(event), footer: created ? (_jsx(Button, { type: "submit", className: "!w-auto", children: "Done" })) : (_jsxs(_Fragment, { children: [_jsxs("a", { href: newBookingLinkHref(settingsHref, values.targetMailboxUid), className: "inline-flex items-center gap-1 text-sm font-semibold text-accent-dark hover:underline px-1 py-1", children: ["More options", _jsx(HiOutlineArrowTopRightOnSquare, { size: 14, "aria-hidden": "true" })] }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })] })), children: created ? (_jsxs(IconRow, { icon: _jsx(HiOutlineLink, { size: 20 }), children: [_jsxs("p", { role: "status", className: "text-sm font-medium mb-1", children: ["\u201C", created.name, "\u201D is ready to book."] }), _jsx("p", { className: "text-xs text-text-muted mb-2", children: "Share this link and anyone can pick an open time - no account needed." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", readOnly: true, "aria-label": "Booking link", className: `${INPUT_CLASS} min-w-0`, value: created.url, onFocus: (e) => e.target.select() }), _jsx(CopyButton, { value: created.url, label: "Copy the booking link" })] }), _jsx("a", { href: `${settingsHref}/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(created.mailboxUid)}`, className: "inline-block text-xs text-primary-dark hover:underline mt-2", children: "Change its settings" })] })) : (_jsxs(_Fragment, { children: [_jsx(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsxs("select", { "aria-label": "Duration", className: SELECT_CLASS, value: draft.duration, onChange: (e) => onDraftChange({ duration: e.target.value }), children: [DURATIONS.map((minutes) => (_jsxs("option", { value: minutes, children: [minutes, " minutes"] }, minutes))), _jsx("option", { value: "custom", children: "Custom" })] }), draft.duration === "custom" && (_jsxs(_Fragment, { children: [_jsx("input", { type: "number", min: MIN_DURATION, max: MAX_DURATION, "aria-label": "Custom duration in minutes", className: `${INPUT_CLASS} !w-24`, value: draft.customMinutes, onChange: (e) => onDraftChange({ customMinutes: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "minutes" })] })), _jsx("span", { className: "text-text-muted", children: "with" }), _jsx("select", { "aria-label": "Buffer after each appointment", className: SELECT_CLASS, value: draft.buffer, onChange: (e) => onDraftChange({ buffer: e.target.value }), children: BUFFERS.map((minutes) => (_jsx("option", { value: minutes, children: minutes === "0" ? "no buffer" : `${minutes} min buffer after` }, minutes))) })] }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx("div", { role: "group", "aria-label": "Days people can book", className: "flex gap-1 mb-2", children: DAY_NAMES.map((day, index) => (_jsx("button", { type: "button", "aria-label": day, "aria-pressed": draft.days[index], title: day, onClick: () => onDraftChange({ days: draft.days.map((open, i) => (i === index ? !open : open)) }), className: [
132
133
  "w-8 h-8 rounded-full text-xs font-medium border",
133
134
  draft.days[index] ? "bg-primary text-white border-primary" : "border-border text-text-muted hover:bg-surface-alt",
134
- ].join(" "), children: day[0] }, day))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "time", "aria-label": "Bookable from", className: `${SELECT_CLASS} min-w-0`, value: draft.startTime, onChange: (e) => onDraftChange({ startTime: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "Bookable until", className: `${SELECT_CLASS} min-w-0`, value: draft.endTime, onChange: (e) => onDraftChange({ endTime: e.target.value }) })] }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Times are in ", timeZone.replace(/_/g, " "), "."] })] }), _jsx(IconRow, { icon: _jsx(HiOutlineMapPin, { size: 20 }), children: _jsx("div", { role: "group", "aria-label": "How people can meet", className: "flex flex-wrap gap-x-4 gap-y-1 text-sm py-1", children: LOCATIONS.map((location) => (_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: draft[location.key], onChange: (e) => onDraftChange({ [location.key]: e.target.checked }) }), location.label] }, location.key))) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendar, { size: 20 }), children: [_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Bookings are added to" }), _jsx(CalendarField, { c: c })] })] })) }));
135
+ ].join(" "), children: day[0] }, day))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "time", "aria-label": "Bookable from", className: `${SELECT_CLASS} min-w-0`, value: draft.startTime, onChange: (e) => onDraftChange({ startTime: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "Bookable until", className: `${SELECT_CLASS} min-w-0`, value: draft.endTime, onChange: (e) => onDraftChange({ endTime: e.target.value }) })] }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Times are in ", describeTimeZone(timeZone), "."] })] }), _jsx(IconRow, { icon: _jsx(HiOutlineMapPin, { size: 20 }), children: _jsx("div", { role: "group", "aria-label": "How people can meet", className: "flex flex-wrap gap-x-4 gap-y-1 text-sm py-1", children: LOCATIONS.map((location) => (_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: draft[location.key], onChange: (e) => onDraftChange({ [location.key]: e.target.checked }) }), location.label] }, location.key))) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendar, { size: 20 }), children: [_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Bookings are added to" }), _jsx(CalendarField, { c: c })] })] })) }));
135
136
  }
@@ -1,4 +1,9 @@
1
- import React from "react";
1
+ import React, { ReactNode } from "react";
2
+ /**
3
+ * `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
4
+ * and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
5
+ */
6
+ export declare function linkifyText(text: string): ReactNode[];
2
7
  export interface EventDescriptionViewProps {
3
8
  /** The description as HTML, from the server or an invitation - untrusted, so it is read into allowed elements only (`parseEventDescription()`) and drawn as
4
9
  * React elements, never as markup. */
@@ -1,15 +1,45 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import React from "react";
2
7
  import { htmlToPlainText, parseEventDescription } from "../../../../lib/calendar/eventDescription.js";
3
- function renderNodes(nodes) {
8
+ const URL_PATTERN = /https?:\/\/[^\s<>"']+/gi;
9
+ const LINK_CLASS = "text-primary-dark underline break-words";
10
+ function count(text, character) {
11
+ return text.split(character).length - 1;
12
+ }
13
+ /**
14
+ * `text` with every web address in it made a link that opens in a new tab: a description's own links are its author's, but Google Meet joins, dial-in pages
15
+ * and the like are often only typed into the text. Punctuation that ends a sentence, or closes a bracket the address was not in, stays out of the link.
16
+ */
17
+ export function linkifyText(text) {
18
+ const parts = [];
19
+ let last = 0;
20
+ for (const match of text.matchAll(URL_PATTERN)) {
21
+ let url = match[0];
22
+ while (/[.,;:!?]$/.test(url) || (url.endsWith(")") && count(url, ")") > count(url, "(")) || (url.endsWith("]") && count(url, "]") > count(url, "["))) {
23
+ url = url.slice(0, -1);
24
+ }
25
+ const at = match.index;
26
+ parts.push(text.slice(last, at));
27
+ parts.push(_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: LINK_CLASS, children: url }, at));
28
+ last = at + url.length;
29
+ }
30
+ parts.push(text.slice(last));
31
+ return parts;
32
+ }
33
+ function renderNodes(nodes, insideLink = false) {
4
34
  return nodes.map((node, index) => {
5
35
  if ("text" in node) {
6
- return node.text;
36
+ return insideLink ? node.text : _jsx(React.Fragment, { children: linkifyText(node.text) }, index);
7
37
  }
8
38
  switch (node.tag) {
9
39
  case "br":
10
40
  return _jsx("br", {}, index);
11
41
  case "a":
12
- return (_jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer", className: "text-primary-dark underline break-words", children: renderNodes(node.children) }, index));
42
+ return (_jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer", className: LINK_CLASS, children: renderNodes(node.children, true) }, index));
13
43
  case "ul":
14
44
  return (_jsx("ul", { className: "list-disc pl-5 my-0.5", children: renderNodes(node.children) }, index));
15
45
  case "ol":
@@ -35,5 +65,5 @@ export default function EventDescriptionView({ html, text, className }) {
35
65
  if (htmlToPlainText(html) !== "") {
36
66
  return _jsx("div", { className: ["break-words", className].filter(Boolean).join(" "), children: renderNodes(parseEventDescription(html)) });
37
67
  }
38
- return text?.trim() ? _jsx("div", { className: ["break-words whitespace-pre-wrap", className].filter(Boolean).join(" "), children: text }) : null;
68
+ return text?.trim() ? _jsx("div", { className: ["break-words whitespace-pre-wrap", className].filter(Boolean).join(" "), children: linkifyText(text) }) : null;
39
69
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
@@ -12,13 +12,13 @@ import { htmlToPlainText } from "../../../../lib/calendar/eventDescription.js";
12
12
  import { deleteEventOccurrence, deleteEventSeries } from "../../../../lib/calendar/calendarMutations.js";
13
13
  import { describeRecurrence } from "../../../../lib/calendar/recurrence.js";
14
14
  import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
15
- import { deviceTimeZone } from "../../../../lib/util/timeZone.js";
15
+ import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
16
16
  import Alert from "../../../../lib/components/feedback/Alert.js";
17
17
  import Button from "../../../../lib/components/buttons/Button.js";
18
18
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
19
19
  import EventDescriptionView from "./EventDescriptionView.js";
20
20
  import RequestChangeForm from "./RequestChangeForm.js";
21
- import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
21
+ import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen, formatStoredWhenInZone } from "./eventFormat.js";
22
22
  const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" };
23
23
  function DetailRow({ icon, children }) {
24
24
  return (_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-text-muted mt-0.5", children: icon }), _jsx("div", { className: "flex-1 min-w-0 break-words", children: children })] }));
@@ -121,7 +121,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
121
121
  { response: "tentative", label: "Tentative" },
122
122
  { response: "declined", label: "Decline" },
123
123
  ];
124
- return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) }), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] }), !occurrence.allDay && eventZone && eventZone !== deviceTimeZone() && (_jsxs("div", { className: "text-text-muted", children: ["Event time zone: ", eventZone.replace(/_/g, " ")] }))] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
124
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsx(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: _jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }) })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: organizerName || occurrence.organizer.address }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [guest.displayName || guest.address, ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
125
125
  uid: occurrence.uid,
126
126
  title: occurrence.title,
127
127
  location: occurrence.location,
@@ -19,6 +19,7 @@ import EventExpandedForm from "./EventExpandedForm.js";
19
19
  import QuickCreateFaces from "./QuickCreateFaces.js";
20
20
  import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
21
21
  import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
22
+ import { nextHalfHour } from "./timePicker.js";
22
23
  /**
23
24
  * The Location text this modal writes when it mints a video meeting for an event, and the one value it will
24
25
  * clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
@@ -120,8 +121,10 @@ export default function EventEditor({ layout, onExpand, onCancel, onSaved, onSyn
120
121
  end = start;
121
122
  }
122
123
  else {
123
- start = toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString());
124
- end = toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * MS_PER_MINUTE).toISOString());
124
+ // With nothing clicked, a new event starts at the next half hour and lasts an hour.
125
+ const newStart = initialStart ?? nextHalfHour(new Date());
126
+ start = toDatetimeLocal(occurrence?.startDate ?? newStart.toISOString());
127
+ end = toDatetimeLocal(occurrence?.endDate ?? (initialEnd ?? new Date(newStart.getTime() + MS_PER_HOUR)).toISOString());
125
128
  }
126
129
  return {
127
130
  title: occurrence?.title ?? "",
@@ -44,7 +44,7 @@ export declare function RecurrenceSelect({ value, onChange, allDay, startWeekday
44
44
  allDay: boolean;
45
45
  startWeekday: WeekdayCode | undefined;
46
46
  }): React.JSX.Element;
47
- /** [start date] [start time] to [end time] [end date], then "All day", the "Time zone" link (and its menu) and how the event repeats. */
47
+ /** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
48
48
  export declare function DateTimeControls({ c }: {
49
49
  c: EventFormController;
50
50
  }): React.JSX.Element;
@@ -9,9 +9,12 @@ import Button from "../../../../lib/components/buttons/Button.js";
9
9
  import GuestInput from "./GuestInput.js";
10
10
  import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
11
11
  import RecurrenceEditor from "./RecurrenceEditor.js";
12
+ import TimeComboBox from "./TimeComboBox.js";
13
+ import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
12
14
  import ResourcePicker from "./ResourcePicker.js";
13
15
  import { hasDescriptionText } from "./eventDialogFields.js";
14
- import { REMINDER_UNITS, RESPONSE_STATUS_LABEL, VISIBILITIES, VISIBILITY_HELP, VISIBILITY_LABEL, bestReminderUnit, } from "./eventFormat.js";
16
+ import { dayTimes, endTimeOptions, formatTimeOfDay } from "./timePicker.js";
17
+ import { REMINDER_UNITS, RESPONSE_STATUS_LABEL, VISIBILITIES, VISIBILITY_HELP, VISIBILITY_LABEL, bestReminderUnit, zoneDate, } from "./eventFormat.js";
15
18
  export const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
16
19
  export const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
17
20
  /** A text field that reads as plain text until it is hovered or focused, as a row of the quick-create popover does. */
@@ -139,11 +142,14 @@ export function RecurrenceSelect({ value, onChange, allDay, startWeekday, }) {
139
142
  }
140
143
  return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("select", { id: "event-recurrence", "aria-label": "Recurrence", className: SELECT_CLASS, value: selected, onChange: (e) => choose(e.target.value), children: [_jsx("option", { value: "none", children: "Does not repeat" }), _jsx("option", { value: "daily", children: "Daily" }), _jsx("option", { value: "weekly", children: startWeekday ? `Weekly on ${WEEKDAY_NAME[startWeekday]}` : "Weekly" }), _jsx("option", { value: "monthly", children: "Monthly" }), _jsx("option", { value: "yearly", children: "Annually" }), _jsx("option", { value: "weekdays", children: "Every weekday (Monday to Friday)" }), _jsx("option", { value: "custom", children: "Custom\u2026" })] }), selected === "custom" && _jsx(RecurrenceEditor, { value: value, onChange: onChange, allDay: allDay, startWeekday: startWeekday, hideToggle: true })] }));
141
144
  }
142
- /** [start date] [start time] to [end time] [end date], then "All day", the "Time zone" link (and its menu) and how the event repeats. */
145
+ const START_TIME_OPTIONS = dayTimes().map((time) => ({ value: time, label: formatTimeOfDay(time) }));
146
+ /** [start date] [start time] to [end time] [end date], then the "Time zone" link (and its menu), "All day" and how the event repeats. */
143
147
  export function DateTimeControls({ c }) {
144
148
  const { values } = c;
145
149
  const [zoneOpen, setZoneOpen] = useState(false);
146
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "date", "aria-label": "Event start date", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(0, 10), onChange: (e) => c.setStart(`${e.target.value}${values.start.slice(10)}`) }), !values.allDay && (_jsx("input", { type: "time", "aria-label": "Event start time", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(11, 16), onChange: (e) => c.setStart(`${values.start.slice(0, 11)}${e.target.value}`) })), _jsx("span", { className: "text-text-muted", children: "to" }), !values.allDay && (_jsx("input", { type: "time", "aria-label": "Event end time", className: `${SELECT_CLASS} min-w-0`, value: values.end.slice(11, 16), onChange: (e) => c.setEnd(`${values.end.slice(0, 11)}${e.target.value}`) })), _jsx("input", { type: "date", "aria-label": "Event end date", className: `${SELECT_CLASS} min-w-0`, value: values.end.slice(0, 10), onChange: (e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`) })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2 text-sm", children: [_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: values.allDay, onChange: (e) => c.onAllDayChange(e.target.checked) }), "All day"] }), _jsx("button", { type: "button", "aria-expanded": zoneOpen, className: "text-primary-dark hover:underline", onClick: () => setZoneOpen((open) => !open), children: "Time zone" }), !c.editingSingleOccurrence && (_jsx(RecurrenceSelect, { value: values.recurrenceRule, onChange: (rule) => c.update({ recurrenceRule: rule }), allDay: values.allDay, startWeekday: c.startWeekday }))] }), zoneOpen && (_jsx("select", { "aria-label": "Time zone", className: `${SELECT_CLASS} self-start max-w-full`, value: values.formZone, onChange: (e) => c.onTimeZoneChange(e.target.value), children: c.timeZones.map((zone) => (_jsx("option", { value: zone, children: zone }, zone))) }))] }));
150
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "date", "aria-label": "Event start date", className: `${SELECT_CLASS} min-w-0`, value: values.start.slice(0, 10), onChange: (e) => c.setStart(`${e.target.value}${values.start.slice(10)}`) }), !values.allDay && (_jsx(TimeComboBox, { "aria-label": "Event start time", className: `${SELECT_CLASS} w-28`, display: formatTimeOfDay(values.start.slice(11, 16)), current: values.start.slice(11, 16), options: START_TIME_OPTIONS,
151
+ // Moving the start moves the end with it, so the event keeps its length: whatever the start is set to.
152
+ onSelect: (time) => c.setStart(`${values.start.slice(0, 11)}${time}`), onType: (time) => c.setStart(`${values.start.slice(0, 11)}${time}`) })), _jsx("span", { className: "text-text-muted", children: "to" }), !values.allDay && (_jsx(TimeComboBox, { "aria-label": "Event end time", className: `${SELECT_CLASS} w-28`, display: formatTimeOfDay(values.end.slice(11, 16)), current: values.end, options: endTimeOptions(values.start).map((option) => ({ value: option.value, label: option.label, hint: option.hint })), onSelect: c.setEnd, onType: (time) => c.setEnd(`${values.end.slice(0, 11)}${time}`) })), _jsx("input", { type: "date", "aria-label": "Event end date", className: `${SELECT_CLASS} min-w-0`, value: values.end.slice(0, 10), onChange: (e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`) })] }), _jsx("button", { type: "button", "aria-expanded": zoneOpen, className: "self-start text-sm text-primary-dark hover:underline", onClick: () => setZoneOpen((open) => !open), children: "Time zone" }), zoneOpen && (_jsx(TimeZonePicker, { "aria-label": "Event time zone", className: `${SELECT_CLASS} max-w-full`, value: values.formZone, zones: c.timeZones, at: zoneDate(values.start), onChange: c.onTimeZoneChange })), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: values.allDay, onChange: (e) => c.onAllDayChange(e.target.checked) }), "All day"] }), !c.editingSingleOccurrence && (_jsx(RecurrenceSelect, { value: values.recurrenceRule, onChange: (rule) => c.update({ recurrenceRule: rule }), allDay: values.allDay, startWeekday: c.startWeekday }))] }));
147
153
  }
148
154
  /** The calendar this event is in: its colour and name, a choice between calendars (and mailboxes) when an event is being created. */
149
155
  export function CalendarField({ c }) {
@@ -10,7 +10,8 @@ import Alert from "../../../../lib/components/feedback/Alert.js";
10
10
  import Button from "../../../../lib/components/buttons/Button.js";
11
11
  import { EventShellContext } from "./EventShell.js";
12
12
  import { CalendarField, DateTimeControls, DescriptionRow, GuestsRow, IconRow, LocationRow, VideoConferencingRow } from "./EventFormParts.js";
13
- import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf } from "./eventFormat.js";
13
+ import { describeTimeZone } from "../../../../lib/util/timeZone.js";
14
+ import { BUSY_STATUS_LABEL, VISIBILITY_LABEL, describeReminder, formatFormWhen, reminderOf, zoneDate } from "./eventFormat.js";
14
15
  /**
15
16
  * The quick-create popover: a title, when (a click opens the date, time, zone and repeat controls in place), guests, video conferencing,
16
17
  * a location, an "Add description" row (which opens the rich-text box in place) and which calendar - with the busy status, visibility and reminder it will have
@@ -21,5 +22,5 @@ export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }) {
21
22
  const { values } = c;
22
23
  const { dragHandleProps } = useContext(EventShellContext);
23
24
  const [timeOpen, setTimeOpen] = useState(false);
24
- return (_jsxs("form", { onSubmit: c.onSubmit, className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-2 pt-2", children: [dragHandleProps ? (_jsx("div", { ...dragHandleProps, "aria-hidden": "true", className: "flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none", children: _jsx(HiOutlineBars2, { size: 20 }) })) : (_jsx("div", { className: "flex-1" })), _jsx("button", { type: "button", "aria-label": "Close", onClick: c.onCancel, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), tabs, _jsxs("div", { ...tabPanelProps, className: "flex flex-col gap-3 px-5 pb-3", children: [c.error && _jsx(Alert, { children: c.error }), _jsx("div", { className: "pl-8", children: _jsx("input", { type: "text", "aria-label": "Title", placeholder: "Add title", "data-autofocus": true, className: "w-full text-xl bg-transparent text-text border-0 border-b-2 border-primary/50 focus:border-primary focus:outline-none pb-1 placeholder:text-text-muted", value: values.title, onChange: (e) => c.update({ title: e.target.value }) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: [_jsxs("button", { type: "button", "aria-expanded": timeOpen, onClick: () => setTimeOpen((open) => !open), className: "w-full text-left rounded-md hover:bg-surface-alt -mx-1 px-1 py-0.5", children: [_jsx("span", { className: "block text-sm", children: formatFormWhen(values.start, values.end, values.allDay) }), _jsxs("span", { className: "block text-xs text-text-muted", children: [values.formZone.replace(/_/g, " "), " \u2022", " ", values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"] })] }), timeOpen && (_jsx("div", { className: "mt-2", children: _jsx(DateTimeControls, { c: c }) }))] }), _jsx(GuestsRow, { c: c }), _jsx(VideoConferencingRow, { c: c }), _jsx(LocationRow, { c: c }), _jsx(DescriptionRow, { c: c, collapsible: true }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx(CalendarField, { c: c }), _jsxs("p", { className: "text-xs text-text-muted mt-0.5", children: [BUSY_STATUS_LABEL[values.busyStatus], " \u2022 ", VISIBILITY_LABEL[values.visibility], " \u2022 ", describeReminder(reminderOf(values.reminderMinutes))] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2 px-4 py-3", children: [_jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: onExpand, children: "More options" }), _jsx(Button, { type: "submit", loading: c.saving, disabled: c.saving, className: "!w-auto", children: "Save" })] })] }));
25
+ return (_jsxs("form", { onSubmit: c.onSubmit, className: "flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-2 pt-2", children: [dragHandleProps ? (_jsx("div", { ...dragHandleProps, "aria-hidden": "true", className: "flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none", children: _jsx(HiOutlineBars2, { size: 20 }) })) : (_jsx("div", { className: "flex-1" })), _jsx("button", { type: "button", "aria-label": "Close", onClick: c.onCancel, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), tabs, _jsxs("div", { ...tabPanelProps, className: "flex flex-col gap-3 px-5 pb-3", children: [c.error && _jsx(Alert, { children: c.error }), _jsx("div", { className: "pl-8", children: _jsx("input", { type: "text", "aria-label": "Title", placeholder: "Add title", "data-autofocus": true, className: "w-full text-xl bg-transparent text-text border-0 border-b-2 border-primary/50 focus:border-primary focus:outline-none pb-1 placeholder:text-text-muted", value: values.title, onChange: (e) => c.update({ title: e.target.value }) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: [_jsxs("button", { type: "button", "aria-expanded": timeOpen, onClick: () => setTimeOpen((open) => !open), className: "w-full text-left rounded-md hover:bg-surface-alt -mx-1 px-1 py-0.5", children: [_jsx("span", { className: "block text-sm", children: formatFormWhen(values.start, values.end, values.allDay) }), _jsxs("span", { className: "block text-xs text-text-muted", children: [describeTimeZone(values.formZone, zoneDate(values.start)), " \u2022", " ", values.recurrenceRule ? `Repeats ${describeRecurrence(values.recurrenceRule)}` : "Does not repeat"] })] }), timeOpen && (_jsx("div", { className: "mt-2", children: _jsx(DateTimeControls, { c: c }) }))] }), _jsx(GuestsRow, { c: c }), _jsx(VideoConferencingRow, { c: c }), _jsx(LocationRow, { c: c }), _jsx(DescriptionRow, { c: c, collapsible: true }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx(CalendarField, { c: c }), _jsxs("p", { className: "text-xs text-text-muted mt-0.5", children: [BUSY_STATUS_LABEL[values.busyStatus], " \u2022 ", VISIBILITY_LABEL[values.visibility], " \u2022 ", describeReminder(reminderOf(values.reminderMinutes))] })] })] }), _jsxs("div", { className: "flex items-center justify-end gap-2 px-4 py-3", children: [_jsx(Button, { type: "button", variant: "text", className: "!w-auto", onClick: onExpand, children: "More options" }), _jsx(Button, { type: "submit", loading: c.saving, disabled: c.saving, className: "!w-auto", children: "Save" })] })] }));
25
26
  }