@rapidmx/web-client 0.14.1 → 0.15.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/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -866
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
|
@@ -3,476 +3,74 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import {
|
|
7
|
-
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
-
import { createCalendarEvent, getCalendarEvent, respondToEvent, updateCalendarEvent, } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
9
|
-
import { createVideoMeeting, getVideoMeeting, updateVideoMeeting, } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
|
|
10
|
-
import { deleteEventOccurrence, deleteEventSeries, detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
11
|
-
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
12
|
-
import { fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
6
|
+
import { useRef, useState } from "react";
|
|
13
7
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
14
|
-
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
15
8
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const RESPONSE_STATUS_LABEL = {
|
|
27
|
-
needsAction: "Awaiting response",
|
|
28
|
-
accepted: "Accepted",
|
|
29
|
-
declined: "Declined",
|
|
30
|
-
tentative: "Tentative",
|
|
31
|
-
};
|
|
9
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
10
|
+
import EventDetails from "./EventDetails.js";
|
|
11
|
+
import EventEditor from "./EventEditor.js";
|
|
12
|
+
import EventShell from "./EventShell.js";
|
|
13
|
+
export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
|
|
14
|
+
/** How wide each face of the dialog gets, in px. */
|
|
15
|
+
const QUICK_WIDTH = 450;
|
|
16
|
+
const DETAILS_WIDTH = 480;
|
|
17
|
+
const FORM_WIDTH = 880;
|
|
18
|
+
const TASK_FORM_WIDTH = 560;
|
|
32
19
|
/**
|
|
33
|
-
* The
|
|
34
|
-
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
20
|
+
* The calendar's event dialog, in three faces drawn through one frame (`EventShell`):
|
|
35
21
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
const MAX_MEETING_TITLE_LENGTH = 200;
|
|
45
|
-
const MS_PER_MINUTE = 60000;
|
|
46
|
-
const MS_PER_DAY = 86400000;
|
|
47
|
-
/** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
|
|
48
|
-
* `...T15:00:00.000Z` name the same time without being equal strings. */
|
|
49
|
-
function sameMinute(a, b) {
|
|
50
|
-
return Math.round(new Date(a).getTime() / MS_PER_MINUTE) === Math.round(new Date(b).getTime() / MS_PER_MINUTE);
|
|
51
|
-
}
|
|
52
|
-
/** Milliseconds since midnight of an instant's wall clock in `timezone` (a timed event's own zone). */
|
|
53
|
-
function wallTimeOfDayMs(instantMs, timezone) {
|
|
54
|
-
const wall = toEventWallClock(instantMs, timezone, false);
|
|
55
|
-
return ((wall % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Rewrites an edited occurrence's `startDate`/`endDate` for a save to the entire series. The modal only
|
|
59
|
-
* ever shows (and edits) *this occurrence's* dates, but the series is stored as its master record starting
|
|
60
|
-
* at the first occurrence - sending the occurrence's dates as-is would move the whole series' start to
|
|
61
|
-
* this occurrence. So only the change in time-of-day (and the new duration) is applied to the master's own
|
|
62
|
-
* start; the series keeps its original first date. Unchanged times aren't sent at all.
|
|
22
|
+
* - **A new event** opens as a quick-create popover (`EventQuickForm`, beside what was clicked; a bottom sheet on a phone). "More options"
|
|
23
|
+
* grows it into the full card (`EventExpandedForm`) with everything typed so far still in it.
|
|
24
|
+
* The popover has tabs (`QuickCreateTabs`): Event, Task, and - when the booking plugin is running (`bookingHref`) - Appointment schedule.
|
|
25
|
+
* The Task and Appointment faces are drawn by `QuickCreateFaces`, share the Event tab's title, mailbox and calendar, and only ever
|
|
26
|
+
* create a task or a booking type; the Event tab is the form it always was.
|
|
27
|
+
* - **An existing event** opens read-only (`EventDetails`): what the event says, the invited reader's Accept / Tentative / Decline,
|
|
28
|
+
* and Delete. The reader who organized it also gets Modify, which turns the same card into the full form; closing that form returns
|
|
29
|
+
* to the details, discarding the edits (a save closes everything, as it always did).
|
|
63
30
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* hours later (into the next day), never 22 hours earlier. The master's new start is converted back from
|
|
67
|
-
* that wall clock, so a master on the other side of a DST change keeps the chosen local time.
|
|
31
|
+
* Editing or deleting a recurring event's occurrence offers a choice between "this event" and "the entire series" (see
|
|
32
|
+
* `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
|
|
68
33
|
*/
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const durationMs = new Date(endDate).getTime() - newStart;
|
|
82
|
-
let masterStart;
|
|
83
|
-
if (fields.allDay) {
|
|
84
|
-
// Timed -> all-day keeps the master's own date on its wall clock.
|
|
85
|
-
const masterDateKey = master.allDay
|
|
86
|
-
? allDayDateKey(master.startDate)
|
|
87
|
-
: new Date(toEventWallClock(new Date(master.startDate).getTime(), timezone, false)).toISOString().slice(0, 10);
|
|
88
|
-
masterStart = new Date(allDayInstant(masterDateKey)).getTime();
|
|
89
|
-
}
|
|
90
|
-
else if (master.allDay) {
|
|
91
|
-
// All-day -> timed: the master's own date at the newly chosen wall-clock time.
|
|
92
|
-
const masterDateWall = new Date(allDayInstant(allDayDateKey(master.startDate))).getTime();
|
|
93
|
-
masterStart = fromEventWallClock(masterDateWall + wallTimeOfDayMs(newStart, timezone), timezone, false);
|
|
34
|
+
export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, folderColors, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, initialAllDay, anchor, bookingHref, onSaved, onDeleted, }) {
|
|
35
|
+
const isMobile = useIsMobile();
|
|
36
|
+
// An existing event: Modify was pressed. A new event: More options was.
|
|
37
|
+
const [editing, setEditing] = useState(false);
|
|
38
|
+
const [expanded, setExpanded] = useState(false);
|
|
39
|
+
// A new event: what the popover creates (an event, a task or an appointment schedule). More options only ever grows the tab it was pressed on.
|
|
40
|
+
const [tab, setTab] = useState("event");
|
|
41
|
+
const [syncWarning, setSyncWarning] = useState(false);
|
|
42
|
+
// Whether the form holds anything a click on the backdrop would throw away (kept up to date by the editor).
|
|
43
|
+
const dirtyRef = useRef(false);
|
|
44
|
+
if (!open) {
|
|
45
|
+
return null;
|
|
94
46
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const delta = ((((rawDelta + MS_PER_DAY / 2) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY) - MS_PER_DAY / 2;
|
|
98
|
-
const masterWall = toEventWallClock(new Date(master.startDate).getTime(), timezone, false);
|
|
99
|
-
masterStart = fromEventWallClock(masterWall + delta, timezone, false);
|
|
47
|
+
if (syncWarning) {
|
|
48
|
+
return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
|
|
100
49
|
}
|
|
101
|
-
return {
|
|
102
|
-
...rest,
|
|
103
|
-
startDate: new Date(masterStart).toISOString(),
|
|
104
|
-
endDate: new Date(masterStart + durationMs).toISOString(),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Create/view/edit/delete for a single calendar event. Editing or deleting a recurring event's
|
|
109
|
-
* occurrence offers a choice between "this event" and "the entire series" (see
|
|
110
|
-
* `calendarMutations.ts`) — Outlook's own convention for the same ambiguity.
|
|
111
|
-
*/
|
|
112
|
-
export default function EventModal({ open, onClose, mailboxUid, folderUid, calendars, mailboxOptions, organizerAddress, organizerAliases, occurrence, initialStart, initialEnd, onSaved, onDeleted, }) {
|
|
113
|
-
const [targetFolderUid, setTargetFolderUid] = useState(folderUid);
|
|
114
|
-
const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
|
|
115
|
-
const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === targetMailboxUid);
|
|
116
|
-
// Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
|
|
117
|
-
const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
|
|
118
|
-
const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
|
|
119
50
|
// Every address the viewing mailbox receives at - its primary address and its aliases, case-insensitively.
|
|
120
51
|
const ownAddresses = new Set([organizerAddress, ...(organizerAliases ?? mailboxOptions?.find((option) => option.mailbox.uid === mailboxUid)?.mailbox.aliasAddresses ?? [])].map((address) => address.toLowerCase()));
|
|
121
52
|
const isOwnAddress = (address) => ownAddresses.has(address.toLowerCase());
|
|
122
53
|
// An existing event organized by someone else is the viewing mailbox's copy of an invitation: only the
|
|
123
|
-
// organizer can change it (their next update would overwrite local edits anyway), so it'
|
|
124
|
-
// apart from the RSVP controls.
|
|
54
|
+
// organizer can change it (their next update would overwrite local edits anyway), so it can't be modified.
|
|
125
55
|
const isInvited = !!occurrence && !isOwnAddress(occurrence.organizer.address) && !occurrence.attendees.some((a) => a.isOrganizer && isOwnAddress(a.address));
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * 60000).toISOString());
|
|
147
|
-
});
|
|
148
|
-
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
|
|
149
|
-
const [timezone] = useState(occurrence?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
150
|
-
const [attendees, setAttendees] = useState(occurrence?.attendees ?? []);
|
|
151
|
-
const [recurrenceRule, setRecurrenceRule] = useState(occurrence?.recurrenceRule ?? null);
|
|
152
|
-
const [reminderMinutes, setReminderMinutes] = useState(occurrence?.reminderMinutesBeforeStart?.toString() ?? "");
|
|
153
|
-
const [busyStatus, setBusyStatus] = useState(occurrence?.busyStatus ?? "busy");
|
|
154
|
-
const [autoReplyEnabled, setAutoReplyEnabled] = useState(occurrence?.autoReplyEnabled ?? false);
|
|
155
|
-
const [autoReplyMessage, setAutoReplyMessage] = useState(occurrence?.autoReplyMessage ?? "");
|
|
156
|
-
const [editScope, setEditScope] = useState("occurrence");
|
|
157
|
-
// Video conferencing: the toggle, the meeting this event is linked to (if any), the organizer's own join
|
|
158
|
-
// link for it, and whatever went wrong with the last attempt to mint/cancel one.
|
|
159
|
-
const [videoEnabled, setVideoEnabled] = useState(!!occurrence?.videoMeetingUid);
|
|
160
|
-
const [videoMeetingUid, setVideoMeetingUid] = useState(occurrence?.videoMeetingUid);
|
|
161
|
-
// `undefined` while the link is still being fetched, `null` once it is known there is none to open.
|
|
162
|
-
const [organizerJoinUrl, setOrganizerJoinUrl] = useState(undefined);
|
|
163
|
-
const [videoError, setVideoError] = useState(null);
|
|
164
|
-
// The event as this modal last persisted it. Set only when a save got the event stored but could not
|
|
165
|
-
// finish the video-conferencing step after it: the modal then stays open on that error, and the retry
|
|
166
|
-
// must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
|
|
167
|
-
const [savedEvent, setSavedEvent] = useState(null);
|
|
168
|
-
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
169
|
-
const [error, setError] = useState(null);
|
|
170
|
-
const [saving, setSaving] = useState(false);
|
|
171
|
-
// A series save that moved the series but couldn't move every separately edited occurrence with it.
|
|
172
|
-
const [syncWarning, setSyncWarning] = useState(false);
|
|
173
|
-
const [responding, setResponding] = useState(false);
|
|
174
|
-
const [resourcePickerOpen, setResourcePickerOpen] = useState(false);
|
|
175
|
-
const addResourceButtonRef = useRef(null);
|
|
176
|
-
// An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
|
|
177
|
-
// meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
|
|
178
|
-
// meeting minted in this session already has its link from the create call (see
|
|
179
|
-
// `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
|
|
180
|
-
// not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
|
|
181
|
-
// thing to this UI - there is nothing to open either way - so both settle on `null`.
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
const uid = occurrence?.videoMeetingUid;
|
|
184
|
-
if (!uid) {
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
let cancelled = false;
|
|
188
|
-
void getVideoMeeting(uid)
|
|
189
|
-
.then((meeting) => meeting.organizerJoinUrl ?? null)
|
|
190
|
-
.catch(() => null)
|
|
191
|
-
.then((url) => {
|
|
192
|
-
if (!cancelled) {
|
|
193
|
-
setOrganizerJoinUrl(url);
|
|
194
|
-
}
|
|
195
|
-
});
|
|
196
|
-
return () => {
|
|
197
|
-
cancelled = true;
|
|
198
|
-
};
|
|
199
|
-
}, [occurrence?.videoMeetingUid]);
|
|
200
|
-
// The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize —
|
|
201
|
-
// `organizerAddress` (plus the aliases) are this mailbox's own addresses (see `EventModalProps`), so
|
|
202
|
-
// finding one among `attendees` (and not as that attendee's own organizer flag) identifies exactly
|
|
203
|
-
// that case. `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own
|
|
204
|
-
// (see `calendarApi.ts`'s `respondToEvent` doc comment) — it always acts on `occurrence.uid` as a
|
|
205
|
-
// single event document, so this deliberately does not consult `editScope`.
|
|
206
|
-
const myAttendeeIndex = occurrence ? attendees.findIndex((a) => isOwnAddress(a.address)) : -1;
|
|
207
|
-
const canRespond = myAttendeeIndex !== -1 && !attendees[myAttendeeIndex].isOrganizer;
|
|
208
|
-
// "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
|
|
209
|
-
const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
|
|
210
|
-
function updateAttendee(index, patch) {
|
|
211
|
-
setAttendees((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
|
|
212
|
-
}
|
|
213
|
-
function removeAttendee(index) {
|
|
214
|
-
setAttendees((prev) => prev.filter((_, i) => i !== index));
|
|
215
|
-
}
|
|
216
|
-
function addResource(mailbox) {
|
|
217
|
-
setAttendees((prev) => [
|
|
218
|
-
...prev,
|
|
219
|
-
{ address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
|
|
220
|
-
]);
|
|
221
|
-
setResourcePickerOpen(false);
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
|
|
225
|
-
* attendee list a new meeting's invitees are built from is the one that was just stored).
|
|
226
|
-
*
|
|
227
|
-
* Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
|
|
228
|
-
* organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
|
|
229
|
-
* then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
|
|
230
|
-
* No join link is written onto the event: each attendee's own link is substituted into their own copy of
|
|
231
|
-
* the invitation server-side.
|
|
232
|
-
*
|
|
233
|
-
* Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
|
|
234
|
-
* still holds exactly the placeholder this modal wrote - anything the user has typed there since is
|
|
235
|
-
* theirs and is preserved.
|
|
236
|
-
*
|
|
237
|
-
* **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
|
|
238
|
-
* when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
|
|
239
|
-
* is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
|
|
240
|
-
* meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
|
|
241
|
-
* list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
|
|
242
|
-
* a working one. The helper text under the checkbox says so, and turning the toggle off and on again
|
|
243
|
-
* reissues links for the current attendee list.
|
|
244
|
-
*
|
|
245
|
-
* Never throws: the event is already stored by the time this runs, so a failure here is reported inline
|
|
246
|
-
* next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
|
|
247
|
-
*/
|
|
248
|
-
async function applyVideoConferencing(saved) {
|
|
249
|
-
// Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
|
|
250
|
-
if (videoEnabled === !!videoMeetingUid) {
|
|
251
|
-
return { event: saved, failed: false };
|
|
252
|
-
}
|
|
253
|
-
// Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
|
|
254
|
-
// deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
|
|
255
|
-
// shared meeting belongs to the series, not to the one occurrence being split off, and every other
|
|
256
|
-
// occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
|
|
257
|
-
// from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
|
|
258
|
-
// while detaching would otherwise still look like "off, but state says linked" above and fall into
|
|
259
|
-
// the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
|
|
260
|
-
// not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
|
|
261
|
-
// toggle is off and that record already has no meeting reference (true for every detach, checked or
|
|
262
|
-
// not - see this function's own doc comment on the still-checked case being a separate, documented
|
|
263
|
-
// gap), there is nothing of *this* record's to cancel.
|
|
264
|
-
if (!videoEnabled && !saved.videoMeetingUid) {
|
|
265
|
-
return { event: saved, failed: false };
|
|
266
|
-
}
|
|
267
|
-
try {
|
|
268
|
-
if (videoEnabled) {
|
|
269
|
-
const organizerAddressLower = saved.organizer.address.toLowerCase();
|
|
270
|
-
const invitees = attendees
|
|
271
|
-
.filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
|
|
272
|
-
.map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
|
|
273
|
-
if (invitees.length === 0) {
|
|
274
|
-
setVideoError("Add at least one attendee other than yourself to add video conferencing.");
|
|
275
|
-
return { event: saved, failed: true };
|
|
276
|
-
}
|
|
277
|
-
const result = await createVideoMeeting({
|
|
278
|
-
mailboxUid: saved.mailboxUid,
|
|
279
|
-
title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
|
|
280
|
-
visibility: "private",
|
|
281
|
-
calendarEventUid: saved.uid,
|
|
282
|
-
startTime: saved.startDate,
|
|
283
|
-
endTime: saved.endDate,
|
|
284
|
-
invitees,
|
|
285
|
-
});
|
|
286
|
-
const patched = await updateCalendarEvent({
|
|
287
|
-
uid: saved.uid,
|
|
288
|
-
version: saved.version,
|
|
289
|
-
location: VIDEO_LOCATION_PLACEHOLDER,
|
|
290
|
-
videoMeetingUid: result.meeting.uid,
|
|
291
|
-
});
|
|
292
|
-
setVideoMeetingUid(result.meeting.uid);
|
|
293
|
-
setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
|
|
294
|
-
setLocation(VIDEO_LOCATION_PLACEHOLDER);
|
|
295
|
-
return { event: patched, failed: false };
|
|
296
|
-
}
|
|
297
|
-
await updateVideoMeeting(videoMeetingUid, { status: "cancelled" });
|
|
298
|
-
const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
|
|
299
|
-
// A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
|
|
300
|
-
// leave the placeholder (and the link to the now-cancelled meeting) in place.
|
|
301
|
-
const patch = { location: restoredLocation || null, videoMeetingUid: null };
|
|
302
|
-
const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...patch });
|
|
303
|
-
setVideoMeetingUid(undefined);
|
|
304
|
-
setOrganizerJoinUrl(undefined);
|
|
305
|
-
setLocation(restoredLocation);
|
|
306
|
-
return { event: patched, failed: false };
|
|
307
|
-
}
|
|
308
|
-
catch (err) {
|
|
309
|
-
// `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
|
|
310
|
-
// simply aren't mounted and every call here fails with a plain 404 - a caller offering video
|
|
311
|
-
// conferencing optionally must treat that as "not available here," not alarm the reader with
|
|
312
|
-
// the raw (often server-generated, unhelpful) 404 body text.
|
|
313
|
-
setVideoError(err instanceof ApiRequestError
|
|
314
|
-
? err.status === 404
|
|
315
|
-
? "Video conferencing is not available on this server."
|
|
316
|
-
: err.message
|
|
317
|
-
: "Could not update this event's video conferencing.");
|
|
318
|
-
return { event: saved, failed: true };
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
async function handleSubmit(e) {
|
|
322
|
-
e.preventDefault();
|
|
323
|
-
setError(null);
|
|
324
|
-
setVideoError(null);
|
|
325
|
-
if (!title.trim()) {
|
|
326
|
-
setError("A title is required.");
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
let startDate;
|
|
330
|
-
let endDate;
|
|
331
|
-
if (allDay) {
|
|
332
|
-
const startKey = start.slice(0, 10);
|
|
333
|
-
const lastDayKey = end.slice(0, 10);
|
|
334
|
-
if (lastDayKey < startKey) {
|
|
335
|
-
setError("The end date can't be before the start date.");
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
startDate = allDayInstant(startKey);
|
|
339
|
-
endDate = allDayInstant(addDaysToKey(lastDayKey, 1));
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
if (new Date(end) <= new Date(start)) {
|
|
343
|
-
setError("The end time must be after the start time.");
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
startDate = new Date(start).toISOString();
|
|
347
|
-
endDate = new Date(end).toISOString();
|
|
348
|
-
}
|
|
349
|
-
// Only a stored event is updated in place, where a cleared field must be sent as `null`; creating
|
|
350
|
-
// (a new event, or the detached copy of one occurrence) just omits it.
|
|
351
|
-
const updatingInPlace = !!occurrence && !editingSingleOccurrence;
|
|
352
|
-
const cleared = updatingInPlace ? null : undefined;
|
|
353
|
-
const fields = {
|
|
354
|
-
title: title.trim(),
|
|
355
|
-
location: location.trim() || cleared,
|
|
356
|
-
startDate,
|
|
357
|
-
endDate,
|
|
358
|
-
allDay,
|
|
359
|
-
timezone,
|
|
360
|
-
attendees,
|
|
361
|
-
recurrenceRule: editingSingleOccurrence ? undefined : (recurrenceRule ?? cleared),
|
|
362
|
-
reminderMinutesBeforeStart: reminderMinutes.trim() ? Number(reminderMinutes) : cleared,
|
|
363
|
-
busyStatus,
|
|
364
|
-
autoReplyEnabled,
|
|
365
|
-
autoReplyMessage: autoReplyEnabled ? autoReplyMessage : cleared,
|
|
366
|
-
};
|
|
367
|
-
setSaving(true);
|
|
368
|
-
try {
|
|
369
|
-
let saved;
|
|
370
|
-
let detachedSyncFailed = false;
|
|
371
|
-
if (savedEvent) {
|
|
372
|
-
// A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
|
|
373
|
-
// update exactly the record the first attempt left behind, whichever branch below created it.
|
|
374
|
-
// A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
|
|
375
|
-
// nothing left to shift - the dates being sent again are the ones it moved everything to.
|
|
376
|
-
saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...fields });
|
|
377
|
-
}
|
|
378
|
-
else if (!occurrence) {
|
|
379
|
-
// The organizer is only ever set on create - an edit keeps the event's own organizer.
|
|
380
|
-
saved = await createCalendarEvent({
|
|
381
|
-
mailboxUid: targetMailboxUid,
|
|
382
|
-
folderUid: targetFolderUid,
|
|
383
|
-
...fields,
|
|
384
|
-
organizer: { address: effectiveOrganizerAddress, type: "to" },
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
else if (editingSingleOccurrence) {
|
|
388
|
-
saved = await detachOccurrence(occurrence, fields);
|
|
389
|
-
}
|
|
390
|
-
else if (occurrence.isRecurringOccurrence) {
|
|
391
|
-
const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)));
|
|
392
|
-
saved = series;
|
|
393
|
-
detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields });
|
|
397
|
-
}
|
|
398
|
-
const video = await applyVideoConferencing(saved);
|
|
399
|
-
if (video.failed) {
|
|
400
|
-
// The event itself is saved - keep the modal open on the inline error so the video meeting
|
|
401
|
-
// can be retried (or the toggle put back) rather than closing over it.
|
|
402
|
-
setSavedEvent(video.event);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (detachedSyncFailed) {
|
|
406
|
-
// The series itself was saved - say so, and leave closing to the user once they've read it.
|
|
407
|
-
setSyncWarning(true);
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
onSaved();
|
|
411
|
-
}
|
|
412
|
-
catch (err) {
|
|
413
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
|
|
414
|
-
}
|
|
415
|
-
finally {
|
|
416
|
-
setSaving(false);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
// Only ever invoked from the Delete button(s) rendered inside the `{occurrence && (...)}` block
|
|
420
|
-
// below, so `occurrence` is always non-null here — no defensive null check needed.
|
|
421
|
-
async function handleDelete(scope) {
|
|
422
|
-
setError(null);
|
|
423
|
-
setSaving(true);
|
|
424
|
-
try {
|
|
425
|
-
if (occurrence.isRecurringOccurrence && scope === "occurrence") {
|
|
426
|
-
await deleteEventOccurrence(occurrence);
|
|
427
|
-
}
|
|
428
|
-
else {
|
|
429
|
-
await deleteEventSeries(occurrence);
|
|
430
|
-
}
|
|
431
|
-
onDeleted();
|
|
432
|
-
}
|
|
433
|
-
catch (err) {
|
|
434
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
|
|
435
|
-
setSaving(false);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
// Only rendered inside the `{canRespond && (...)}` block below, which already requires `occurrence`
|
|
439
|
-
// to be non-null (see `canRespond`'s own definition above) — same established pattern as
|
|
440
|
-
// `handleDelete`'s non-null assertion.
|
|
441
|
-
async function handleRespond(responseStatus) {
|
|
442
|
-
setError(null);
|
|
443
|
-
setResponding(true);
|
|
444
|
-
try {
|
|
445
|
-
await respondToEvent(occurrence.uid, responseStatus);
|
|
446
|
-
// A decline soft-deletes the mailbox's own copy server-side — treat it the same as a
|
|
447
|
-
// delete rather than a save so the calendar view drops it immediately.
|
|
448
|
-
if (responseStatus === "declined") {
|
|
449
|
-
onDeleted();
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
onSaved();
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch (err) {
|
|
456
|
-
setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
|
|
457
|
-
setResponding(false);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
// The recurrence end date is stored in the frame the series expands in (UTC for all-day, local otherwise),
|
|
461
|
-
// so toggling All day re-stores the same chosen date in the new frame.
|
|
462
|
-
function handleAllDayChange(nextAllDay) {
|
|
463
|
-
setAllDay(nextAllDay);
|
|
464
|
-
setRecurrenceRule((rule) => rule?.until ? { ...rule, until: recurrenceUntilInstant(recurrenceUntilDateKey(rule.until, allDay), nextAllDay) } : rule);
|
|
465
|
-
}
|
|
466
|
-
if (syncWarning) {
|
|
467
|
-
return (_jsxs(Modal, { open: open, onClose: onSaved, title: "Series saved", children: [_jsx("p", { role: "alert", className: "text-sm mb-4", children: "The series was saved, but some occurrences you had changed individually couldn\u2019t be moved with it. They may now appear twice - check the calendar and delete any duplicate." }), _jsx(Button, { type: "button", className: "!w-auto", onClick: onSaved, children: "OK" })] }));
|
|
468
|
-
}
|
|
469
|
-
// Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
|
|
470
|
-
// `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
|
|
471
|
-
// manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
|
|
472
|
-
// `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
|
|
473
|
-
// came back with nothing to join) both fail validation the same way as a bad scheme would - the button
|
|
474
|
-
// stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
|
|
475
|
-
const validatedJoinUrl = joinMeetingUrl(organizerJoinUrl);
|
|
476
|
-
return (_jsx(Modal, { open: open, onClose: onClose, title: occurrence ? "Edit event" : "New event", children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-1", children: [error && _jsx(Alert, { children: error }), canRespond && (_jsxs("div", { className: "flex flex-col gap-2 mb-3 p-3 rounded-sm bg-surface-alt", children: [_jsxs("div", { className: "flex items-center justify-between gap-3 flex-wrap", children: [_jsx("span", { className: "text-sm font-medium", children: "Your response" }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("accepted"), children: "Accept" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: responding, onClick: () => handleRespond("tentative"), children: "Tentative" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: responding, onClick: () => handleRespond("declined"), children: "Decline" })] })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Other attendees\u2019 responses may take a few minutes to update." })] })), isInvited && (_jsx("p", { className: "text-xs text-text-muted mb-3", children: "You were invited to this event. Only the organizer can change its details." })), videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !validatedJoinUrl, onClick: () => window.open(validatedJoinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !validatedJoinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), _jsxs("fieldset", { disabled: isInvited, className: "flex flex-col gap-1 min-w-0", children: [!occurrence && mailboxOptions && mailboxOptions.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "event-mailbox", children: _jsx("select", { id: "event-mailbox", className: INPUT_CLASS, value: targetMailboxUid, onChange: (e) => handleMailboxChange(e.target.value), children: mailboxOptions.map(({ mailbox }) => (_jsxs("option", { value: mailbox.uid, children: [mailbox.displayName, mailbox.ownerUserUid ? "" : " (shared)"] }, mailbox.uid))) }) })), !occurrence && calendarChoices && calendarChoices.length > 1 && (_jsx(FormField, { label: "Calendar", htmlFor: "event-calendar", children: _jsx("select", { id: "event-calendar", className: INPUT_CLASS, value: targetFolderUid, onChange: (e) => setTargetFolderUid(e.target.value), children: calendarChoices.map((cal) => (_jsx("option", { value: cal.uid, children: cal.name }, cal.uid))) }) })), _jsx(FormField, { label: "Title", htmlFor: "event-title", children: _jsx("input", { id: "event-title", type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) }) }), _jsx(FormField, { label: "Location", htmlFor: "event-location", children: _jsx("input", { id: "event-location", type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) }) }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: videoEnabled, onChange: (e) => setVideoEnabled(e.target.checked) }), "Add video conferencing"] }), videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mt-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mt-1", children: videoError }))] }), _jsxs("label", { className: "flex items-center gap-2 text-sm font-medium mb-3", children: [_jsx("input", { type: "checkbox", checked: allDay, onChange: (e) => handleAllDayChange(e.target.checked) }), "All day"] }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Start", htmlFor: "event-start", children: _jsx("input", { id: "event-start", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? start.slice(0, 10) : start, onChange: (e) => setStart(allDay ? `${e.target.value}T00:00` : e.target.value) }) }), _jsx(FormField, { label: "End", htmlFor: "event-end", children: _jsx("input", { id: "event-end", type: allDay ? "date" : "datetime-local", className: INPUT_CLASS, value: allDay ? end.slice(0, 10) : end, onChange: (e) => setEnd(allDay ? `${e.target.value}T00:00` : e.target.value) }) })] }), _jsx(FormField, { label: "Attendees", htmlFor: "event-attendees", children: _jsxs("div", { id: "event-attendees", className: "flex flex-col gap-2", children: [attendees.map((attendee, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: attendee.address, onChange: (e) => updateAttendee(i, { address: e.target.value }), "aria-label": `Attendee email ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt self-center", children: RESPONSE_STATUS_LABEL[attendee.responseStatus] }), _jsx("button", { type: "button", onClick: () => removeAttendee(i), className: "text-sm text-danger px-2", "aria-label": `Remove attendee ${i + 1}`, children: "\u00D7" })] }, i))), _jsxs("div", { className: "flex gap-3 items-center", children: [_jsx("button", { type: "button", onClick: () => setAttendees((prev) => [...prev, { address: "", role: "required", responseStatus: "needsAction", isOrganizer: false }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add attendee" }), _jsx("button", { ref: addResourceButtonRef, type: "button", onClick: () => setResourcePickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" })] }), resourcePickerOpen && (_jsx(ResourcePicker, { anchorRef: addResourceButtonRef, onClose: () => setResourcePickerOpen(false), onSelect: addResource, excludeAddresses: attendees.map((a) => a.address.toLowerCase()) }))] }) }), _jsxs("div", { className: "grid grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Busy status", htmlFor: "event-busyStatus", children: _jsx("select", { id: "event-busyStatus", className: INPUT_CLASS, value: busyStatus, onChange: (e) => setBusyStatus(e.target.value), children: BUSY_STATUSES.map((status) => (_jsx("option", { value: status, children: BUSY_STATUS_LABEL[status] }, status))) }) }), _jsx(FormField, { label: "Reminder (minutes before)", htmlFor: "event-reminder", children: _jsx("input", { id: "event-reminder", type: "number", min: 0, className: INPUT_CLASS, value: reminderMinutes, onChange: (e) => setReminderMinutes(e.target.value), placeholder: "None" }) })] }), _jsxs("div", { className: "mb-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx("input", { type: "checkbox", checked: autoReplyEnabled, onChange: (e) => setAutoReplyEnabled(e.target.checked) }), "Send an automatic reply while this event is happening"] }), _jsx("p", { className: "text-xs text-text-muted mt-1", children: "Applies in addition to your mailbox\u2019s own Automatic Replies setting (see Settings) \u2014 this event\u2019s message takes over for its own start/end window." }), autoReplyEnabled && (_jsx("textarea", { "aria-label": "Automatic reply message", className: `${INPUT_CLASS} mt-2`, rows: 3, value: autoReplyMessage, onChange: (e) => setAutoReplyMessage(e.target.value), placeholder: "I'm out of office and back on..." }))] }), !editingSingleOccurrence && (_jsx(FormField, { label: "Recurrence", htmlFor: "event-recurrence", children: _jsx(RecurrenceEditor, { value: recurrenceRule, onChange: setRecurrenceRule, allDay: allDay, startWeekday: startWeekdayCode(start, allDay, timezone) }) })), occurrence?.isRecurringOccurrence && !isInvited && (_jsxs("fieldset", { className: "flex flex-col gap-1.5 text-sm mb-3", children: [_jsx("legend", { className: "text-xs font-bold uppercase tracking-wide text-text-muted mb-1", children: "Apply changes to" }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "occurrence", onChange: () => setEditScope("occurrence") }), "This event only"] }), _jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "radio", name: "event-edit-scope", checked: editScope === "series", onChange: () => setEditScope("series") }), "The entire series"] })] }))] }), _jsxs("div", { className: "flex gap-3 mt-2", children: [!isInvited && (_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), occurrence &&
|
|
477
|
-
(!occurrence.isRecurringOccurrence ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete" })) : !confirmingDelete ? (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto ml-auto text-danger", disabled: saving, onClick: () => setConfirmingDelete(true), children: "Delete" })) : (_jsxs("div", { className: "flex gap-2 ml-auto", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("occurrence"), children: "Delete this event" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", disabled: saving, onClick: () => handleDelete("series"), children: "Delete series" })] })))] })] }) }));
|
|
56
|
+
// The viewing mailbox can respond to an *existing* event it's invited to but doesn't organize - finding one of its own
|
|
57
|
+
// addresses among the attendees (and not as that attendee's own organizer flag) identifies exactly that case.
|
|
58
|
+
const myAttendee = occurrence?.attendees.find((a) => isOwnAddress(a.address));
|
|
59
|
+
const canRespond = !!myAttendee && !myAttendee.isOrganizer;
|
|
60
|
+
const isForm = !occurrence || editing;
|
|
61
|
+
const layout = occurrence || expanded ? "expanded" : "quick";
|
|
62
|
+
const variant = !isForm || layout === "expanded" ? "card" : isMobile ? "sheet" : "popover";
|
|
63
|
+
const width = !isForm ? DETAILS_WIDTH : layout === "expanded" ? (tab === "event" || occurrence ? FORM_WIDTH : TASK_FORM_WIDTH) : QUICK_WIDTH;
|
|
64
|
+
// Leaving the form: an existing event goes back to its details, a new one is done.
|
|
65
|
+
const leaveForm = occurrence ? () => setEditing(false) : onClose;
|
|
66
|
+
return (_jsx(EventShell, { variant: variant, label: !occurrence ? "New event" : editing ? "Edit event" : "Event details", focusKey: isForm ? layout : "details", anchor: anchor, width: width, onClose: isForm ? leaveForm : onClose, onBackdropPress: isForm ? () => !dirtyRef.current && leaveForm() : undefined, children: isForm ? (_jsx(EventEditor, { layout: layout, onExpand: () => setExpanded(true), onCancel: leaveForm, onSaved: onSaved, onSyncWarning: () => setSyncWarning(true), dirtyRef: dirtyRef, mailboxUid: mailboxUid, folderUid: folderUid, calendars: calendars, mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: organizerAddress, occurrence: occurrence, initialStart: initialStart, initialEnd: initialEnd, initialAllDay: initialAllDay, quickCreate: occurrence
|
|
67
|
+
? undefined
|
|
68
|
+
: {
|
|
69
|
+
tab,
|
|
70
|
+
onTabChange: (next) => {
|
|
71
|
+
setTab(next);
|
|
72
|
+
setExpanded(false);
|
|
73
|
+
},
|
|
74
|
+
bookingHref,
|
|
75
|
+
} })) : (_jsx(EventDetails, { occurrence: occurrence, isInvited: isInvited, myResponse: canRespond ? myAttendee.responseStatus : undefined, isOwnAddress: isOwnAddress, calendarName: calendars?.find((cal) => cal.uid === occurrence.folderUid)?.name, calendarColor: folderColors?.[occurrence.folderUid], onClose: onClose, onModify: () => setEditing(true), onSaved: onSaved, onDeleted: onDeleted })) }));
|
|
478
76
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { EventFormController } from "./eventForm.js";
|
|
3
|
+
export interface EventQuickFormProps {
|
|
4
|
+
c: EventFormController;
|
|
5
|
+
/** "More options": the same form, larger. */
|
|
6
|
+
onExpand: () => void;
|
|
7
|
+
/** A new event's tab strip (Event, Task, Appointment schedule), drawn under the header, and the attributes that make the rows below it
|
|
8
|
+
* the selected tab's panel. */
|
|
9
|
+
tabs?: ReactNode;
|
|
10
|
+
tabPanelProps?: HTMLAttributes<HTMLDivElement>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The quick-create popover: a title, when (a click opens the date, time, zone and repeat controls in place), guests, video conferencing,
|
|
14
|
+
* 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
|
|
15
|
+
* written under it - then "More options" and Save. Its
|
|
16
|
+
* values are `EventEditor`'s, shared with the full form.
|
|
17
|
+
*/
|
|
18
|
+
export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }: EventQuickFormProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useContext, useState } from "react";
|
|
7
|
+
import { HiOutlineBars2, HiOutlineCalendarDays, HiOutlineClock, HiOutlineXMark } from "react-icons/hi2";
|
|
8
|
+
import { describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import { EventShellContext } from "./EventShell.js";
|
|
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";
|
|
14
|
+
/**
|
|
15
|
+
* The quick-create popover: a title, when (a click opens the date, time, zone and repeat controls in place), guests, video conferencing,
|
|
16
|
+
* 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
|
|
17
|
+
* written under it - then "More options" and Save. Its
|
|
18
|
+
* values are `EventEditor`'s, shared with the full form.
|
|
19
|
+
*/
|
|
20
|
+
export default function EventQuickForm({ c, onExpand, tabs, tabPanelProps }) {
|
|
21
|
+
const { values } = c;
|
|
22
|
+
const { dragHandleProps } = useContext(EventShellContext);
|
|
23
|
+
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
|
+
}
|