@rapidmx/web-client 0.14.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -163
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -346
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +53 -4
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +64 -10
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- 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 +635 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +591 -0
- package/apps/shared/components/calendar/EventModal.tsx +206 -946
- 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/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -173
- 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 +227 -222
- package/apps/shared/components/calendar/RequestChangeForm.tsx +240 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -143
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -244
- package/apps/shared/components/calendar/allDay.ts +124 -124
- 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 +210 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -372
- package/apps/shared/components/mail/ConversationList.tsx +323 -271
- package/apps/shared/components/mail/ConversationThreadPane.tsx +130 -5
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1537
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1673
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- 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/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +546 -490
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -253
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- 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/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +3 -15
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +2 -2
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +9 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +578 -469
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2332
- package/apps/www/messages/[uid].tsx +135 -108
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -0
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -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/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +28 -4
- 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 +544 -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 +70 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +191 -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/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -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 +127 -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 +80 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +190 -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/layout/CalendarShell.js +3 -2
- 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/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +14 -9
- package/dist/apps/shared/components/layout/UserMenu.js +74 -26
- 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/ConversationThreadPane.js +127 -22
- 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/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- 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 +16 -7
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- 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/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +10 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +76 -46
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +7 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- 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/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -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/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- 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/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +78 -7
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +548 -210
- 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
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, MutableRefObject, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { format } from "date-fns";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import {
|
|
9
|
+
Attendee,
|
|
10
|
+
CalendarEvent,
|
|
11
|
+
CalendarEventInput,
|
|
12
|
+
createCalendarEvent,
|
|
13
|
+
getCalendarEvent,
|
|
14
|
+
guestPermissionsOf,
|
|
15
|
+
updateCalendarEvent,
|
|
16
|
+
visibilityOf,
|
|
17
|
+
} from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
18
|
+
import {
|
|
19
|
+
VideoMeetingInvitee,
|
|
20
|
+
createVideoMeeting,
|
|
21
|
+
getVideoMeeting,
|
|
22
|
+
updateVideoMeeting,
|
|
23
|
+
} from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
|
|
24
|
+
import { detachOccurrence, saveEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
25
|
+
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
26
|
+
import { deviceTimeZone, timeZoneOptions } from "@rapidmx/react-shared/util/timeZone.js";
|
|
27
|
+
import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
28
|
+
import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
29
|
+
import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
|
|
30
|
+
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
31
|
+
import EventExpandedForm from "./EventExpandedForm.js";
|
|
32
|
+
import QuickCreateFaces, { QuickCreateConfig } from "./QuickCreateFaces.js";
|
|
33
|
+
import { EditScope, EventFormController, EventFormValues } from "./eventForm.js";
|
|
34
|
+
import { descriptionProblem, dialogFields, hasDescriptionText, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
35
|
+
import { mergeGuests, msToWallString, wallStringToMs } from "./eventFormat.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Location text this modal writes when it mints a video meeting for an event, and the one value it will
|
|
39
|
+
* clear again when video conferencing is turned back off (see `applyVideoConferencing()`).
|
|
40
|
+
*
|
|
41
|
+
* Deliberately generic and identical for everyone: each attendee's own personal join link is substituted into
|
|
42
|
+
* their own copy of the invitation server-side (`@rapidmx/restapi`'s `MeetingSchedulingJob`), so no link -
|
|
43
|
+
* personal or otherwise - may be stored on the shared event itself, where every attendee would read the same
|
|
44
|
+
* one.
|
|
45
|
+
*/
|
|
46
|
+
export const VIDEO_LOCATION_PLACEHOLDER = "Video call — link in this invitation";
|
|
47
|
+
|
|
48
|
+
/** The plugin's own title bound (`BaseVideoMeetingRoute`'s `MAX_TITLE_LENGTH`) - a longer title is a 400, so
|
|
49
|
+
* an over-long event title is trimmed to fit rather than failing the whole meeting. */
|
|
50
|
+
const MAX_MEETING_TITLE_LENGTH = 200;
|
|
51
|
+
|
|
52
|
+
/** `CalendarEventInput` fields as sent on save. `null` explicitly clears a field on an update - an
|
|
53
|
+
* omitted (`undefined`) field is dropped by `JSON.stringify` and would leave the stored value in place. */
|
|
54
|
+
type EventFields = { [K in keyof CalendarEventInput]?: CalendarEventInput[K] | null };
|
|
55
|
+
|
|
56
|
+
const MS_PER_MINUTE = 60_000;
|
|
57
|
+
const MS_PER_HOUR = 3_600_000;
|
|
58
|
+
const MS_PER_DAY = 86_400_000;
|
|
59
|
+
|
|
60
|
+
/** Whether two ISO instants fall in the same minute - a stored `...T15:00:00Z` and the form's re-serialized
|
|
61
|
+
* `...T15:00:00.000Z` name the same time without being equal strings. */
|
|
62
|
+
function sameMinute(a: string, b: string): boolean {
|
|
63
|
+
return Math.round(new Date(a).getTime() / MS_PER_MINUTE) === Math.round(new Date(b).getTime() / MS_PER_MINUTE);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Milliseconds since midnight of an instant's wall clock in `timezone` (a timed event's own zone). */
|
|
67
|
+
function wallTimeOfDayMs(instantMs: number, timezone: string | undefined): number {
|
|
68
|
+
const wall = toEventWallClock(instantMs, timezone, false);
|
|
69
|
+
return ((wall % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rewrites an edited occurrence's `startDate`/`endDate` for a save to the entire series. The modal only
|
|
74
|
+
* ever shows (and edits) *this occurrence's* dates, but the series is stored as its master record starting
|
|
75
|
+
* at the first occurrence - sending the occurrence's dates as-is would move the whole series' start to
|
|
76
|
+
* this occurrence. So only the change in time-of-day (and the new duration) is applied to the master's own
|
|
77
|
+
* start; the series keeps its original first date. Unchanged times aren't sent at all.
|
|
78
|
+
*
|
|
79
|
+
* Times of day are read on the event's own timezone wall clock (the series expands there), not the
|
|
80
|
+
* browser's, and the change is the smallest signed difference modulo 24h - moving 23:00 to 01:00 is two
|
|
81
|
+
* hours later (into the next day), never 22 hours earlier. The master's new start is converted back from
|
|
82
|
+
* that wall clock, so a master on the other side of a DST change keeps the chosen local time.
|
|
83
|
+
*/
|
|
84
|
+
async function toSeriesFields(occurrence: CalendarOccurrence, fields: EventFields): Promise<EventFields> {
|
|
85
|
+
const { startDate, endDate, ...rest } = fields;
|
|
86
|
+
if (sameMinute(startDate as string, occurrence.startDate) && sameMinute(endDate as string, occurrence.endDate)) {
|
|
87
|
+
// `saveEventSeries()` treats *any* `timezone`/`allDay` in the update as a possible reinterpretation of
|
|
88
|
+
// the master's instant and fetches the master to check - so when neither differs from the occurrence's
|
|
89
|
+
// own, both are dropped too, or every plain series save (e.g. just a new title) would pay for that GET.
|
|
90
|
+
const { timezone, allDay, ...others } = rest;
|
|
91
|
+
return timezone === occurrence.timezone && allDay === occurrence.allDay ? others : rest;
|
|
92
|
+
}
|
|
93
|
+
const master = await getCalendarEvent(occurrence.uid);
|
|
94
|
+
const timezone = fields.timezone as string;
|
|
95
|
+
const newStart = new Date(startDate as string).getTime();
|
|
96
|
+
const durationMs = new Date(endDate as string).getTime() - newStart;
|
|
97
|
+
let masterStart: number;
|
|
98
|
+
if (fields.allDay) {
|
|
99
|
+
// Timed -> all-day keeps the master's own date on its wall clock.
|
|
100
|
+
const masterDateKey = master.allDay
|
|
101
|
+
? allDayDateKey(master.startDate)
|
|
102
|
+
: new Date(toEventWallClock(new Date(master.startDate).getTime(), timezone, false)).toISOString().slice(0, 10);
|
|
103
|
+
masterStart = new Date(allDayInstant(masterDateKey)).getTime();
|
|
104
|
+
} else if (master.allDay) {
|
|
105
|
+
// All-day -> timed: the master's own date at the newly chosen wall-clock time.
|
|
106
|
+
const masterDateWall = new Date(allDayInstant(allDayDateKey(master.startDate))).getTime();
|
|
107
|
+
masterStart = fromEventWallClock(masterDateWall + wallTimeOfDayMs(newStart, timezone), timezone, false);
|
|
108
|
+
} else {
|
|
109
|
+
const rawDelta = wallTimeOfDayMs(newStart, timezone) - wallTimeOfDayMs(new Date(occurrence.startDate).getTime(), timezone);
|
|
110
|
+
const delta = ((((rawDelta + MS_PER_DAY / 2) % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY) - MS_PER_DAY / 2;
|
|
111
|
+
const masterWall = toEventWallClock(new Date(master.startDate).getTime(), timezone, false);
|
|
112
|
+
masterStart = fromEventWallClock(masterWall + delta, timezone, false);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...rest,
|
|
116
|
+
startDate: new Date(masterStart).toISOString(),
|
|
117
|
+
endDate: new Date(masterStart + durationMs).toISOString(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface EventEditorProps {
|
|
122
|
+
/** The quick-create popover's face of the form, or the full form's (only a new event, which has `quickCreate`, has the quick one). Both show the same values. */
|
|
123
|
+
layout: "quick" | "expanded";
|
|
124
|
+
/** "More options": the quick face asks to become the full one. */
|
|
125
|
+
onExpand: () => void;
|
|
126
|
+
/** Leave without saving (nothing was stored). */
|
|
127
|
+
onCancel: () => void;
|
|
128
|
+
/** Something was stored: the dialog closes and the calendar reloads. */
|
|
129
|
+
onSaved: () => void;
|
|
130
|
+
/** A series save could not move every occurrence edited on its own with it (`detachedOccurrenceSyncFailed`). */
|
|
131
|
+
onSyncWarning: () => void;
|
|
132
|
+
/** Kept up to date with whether the form holds anything a stray click outside it would throw away. */
|
|
133
|
+
dirtyRef: MutableRefObject<boolean>;
|
|
134
|
+
mailboxUid: string;
|
|
135
|
+
folderUid: string;
|
|
136
|
+
calendars?: { uid: string; name: string }[];
|
|
137
|
+
mailboxOptions?: { mailbox: Mailbox; calendars: { uid: string; name: string }[] }[];
|
|
138
|
+
/** Each calendar's colour, keyed by folder uid. */
|
|
139
|
+
folderColors?: Record<string, string>;
|
|
140
|
+
organizerAddress: string;
|
|
141
|
+
/** `null` when creating a new event. */
|
|
142
|
+
occurrence: CalendarOccurrence | null;
|
|
143
|
+
initialStart?: Date;
|
|
144
|
+
initialEnd?: Date;
|
|
145
|
+
/** A new event starting as an all-day one (a day cell of the month view was clicked). */
|
|
146
|
+
initialAllDay?: boolean;
|
|
147
|
+
/** A new event's tabs (Event, Task, Appointment schedule): with it the form is drawn by `QuickCreateFaces`; without it, as the full card. */
|
|
148
|
+
quickCreate?: QuickCreateConfig;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The event form and everything it does: create, and edit (one occurrence, or the entire series, of a recurring event - the choice
|
|
153
|
+
* Outlook's own convention offers for the same ambiguity, see `calendarMutations.ts`), plus video conferencing. It owns the form's
|
|
154
|
+
* one set of values and draws them as either the quick-create popover or the full card (`layout`), so growing the one into the other
|
|
155
|
+
* keeps what was typed.
|
|
156
|
+
*/
|
|
157
|
+
export default function EventEditor({
|
|
158
|
+
layout,
|
|
159
|
+
onExpand,
|
|
160
|
+
onCancel,
|
|
161
|
+
onSaved,
|
|
162
|
+
onSyncWarning,
|
|
163
|
+
dirtyRef,
|
|
164
|
+
mailboxUid,
|
|
165
|
+
folderUid,
|
|
166
|
+
calendars,
|
|
167
|
+
mailboxOptions,
|
|
168
|
+
folderColors,
|
|
169
|
+
organizerAddress,
|
|
170
|
+
occurrence,
|
|
171
|
+
initialStart,
|
|
172
|
+
initialEnd,
|
|
173
|
+
initialAllDay,
|
|
174
|
+
quickCreate,
|
|
175
|
+
}: EventEditorProps) {
|
|
176
|
+
const [deviceZone] = useState(() => deviceTimeZone());
|
|
177
|
+
const [values, setValues] = useState<EventFormValues>(() => {
|
|
178
|
+
let start: string;
|
|
179
|
+
let end: string;
|
|
180
|
+
if (occurrence?.allDay) {
|
|
181
|
+
// An all-day event is stored date-only with an exclusive end (see `allDay.ts`); the form shows its
|
|
182
|
+
// inclusive last day instead.
|
|
183
|
+
const startKey = allDayDateKey(occurrence.startDate);
|
|
184
|
+
const lastDayKey = addDaysToKey(allDayDateKey(occurrence.endDate), -1);
|
|
185
|
+
start = `${startKey}T00:00`;
|
|
186
|
+
end = `${lastDayKey < startKey ? startKey : lastDayKey}T00:00`;
|
|
187
|
+
} else if (!occurrence && initialAllDay) {
|
|
188
|
+
start = `${format(initialStart ?? new Date(), "yyyy-MM-dd")}T00:00`;
|
|
189
|
+
end = start;
|
|
190
|
+
} else {
|
|
191
|
+
start = toDatetimeLocal(occurrence?.startDate ?? initialStart?.toISOString() ?? new Date().toISOString());
|
|
192
|
+
end = toDatetimeLocal(occurrence?.endDate ?? initialEnd?.toISOString() ?? new Date(Date.now() + 30 * MS_PER_MINUTE).toISOString());
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
title: occurrence?.title ?? "",
|
|
196
|
+
location: occurrence?.location ?? "",
|
|
197
|
+
start,
|
|
198
|
+
end,
|
|
199
|
+
allDay: occurrence?.allDay ?? (!occurrence && !!initialAllDay),
|
|
200
|
+
formZone: deviceZone,
|
|
201
|
+
timezone: occurrence?.timezone ?? deviceZone,
|
|
202
|
+
attendees: occurrence?.attendees ?? [],
|
|
203
|
+
guestDraft: "",
|
|
204
|
+
guestInvalid: [],
|
|
205
|
+
recurrenceRule: occurrence?.recurrenceRule ?? null,
|
|
206
|
+
reminderMinutes: occurrence?.reminderMinutesBeforeStart?.toString() ?? "",
|
|
207
|
+
busyStatus: occurrence?.busyStatus ?? "busy",
|
|
208
|
+
visibility: occurrence ? visibilityOf(occurrence) : "default",
|
|
209
|
+
descriptionHtml: initialDescriptionHtml(occurrence),
|
|
210
|
+
...guestPermissionsOf(occurrence ?? {}),
|
|
211
|
+
autoReplyEnabled: occurrence?.autoReplyEnabled ?? false,
|
|
212
|
+
autoReplyMessage: occurrence?.autoReplyMessage ?? "",
|
|
213
|
+
videoEnabled: !!occurrence?.videoMeetingUid,
|
|
214
|
+
targetMailboxUid: mailboxUid,
|
|
215
|
+
targetFolderUid: folderUid,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
const { attendees, location, videoEnabled } = values;
|
|
219
|
+
function update(patch: Partial<EventFormValues>) {
|
|
220
|
+
setValues((prev) => ({ ...prev, ...patch }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const targetMailboxOption = mailboxOptions?.find((option) => option.mailbox.uid === values.targetMailboxUid);
|
|
224
|
+
// Create mode follows the chosen mailbox; editing an existing event always keeps the passed-in values.
|
|
225
|
+
const calendarChoices = !occurrence && targetMailboxOption ? targetMailboxOption.calendars : calendars;
|
|
226
|
+
const effectiveOrganizerAddress = !occurrence && targetMailboxOption ? targetMailboxOption.mailbox.primarySmtpAddress : organizerAddress;
|
|
227
|
+
|
|
228
|
+
function handleMailboxChange(nextMailboxUid: string) {
|
|
229
|
+
const firstCalendar = mailboxOptions?.find((option) => option.mailbox.uid === nextMailboxUid)?.calendars[0];
|
|
230
|
+
update(firstCalendar ? { targetMailboxUid: nextMailboxUid, targetFolderUid: firstCalendar.uid } : { targetMailboxUid: nextMailboxUid });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Video conferencing: the meeting this event is linked to (if any), the organizer's own join link for it, and
|
|
234
|
+
// whatever went wrong with the last attempt to mint/cancel one. (Whether it is switched on is `values.videoEnabled`.)
|
|
235
|
+
const [videoMeetingUid, setVideoMeetingUid] = useState<string | undefined>(occurrence?.videoMeetingUid);
|
|
236
|
+
// `undefined` while the link is still being fetched, `null` once it is known there is none to open.
|
|
237
|
+
const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
|
|
238
|
+
const [videoError, setVideoError] = useState<string | null>(null);
|
|
239
|
+
// The event as this modal last persisted it. Set only when a save got the event stored but could not
|
|
240
|
+
// finish the video-conferencing step after it: the modal then stays open on that error, and the retry
|
|
241
|
+
// must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
|
|
242
|
+
const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
|
|
243
|
+
const [editScope, setEditScope] = useState<EditScope>("occurrence");
|
|
244
|
+
const [error, setError] = useState<string | null>(null);
|
|
245
|
+
const [saving, setSaving] = useState(false);
|
|
246
|
+
|
|
247
|
+
// An event that already carried a meeting when it was opened (e.g. after a page reload) knows only that
|
|
248
|
+
// meeting's uid - the organizer's own join link lives on the meeting, so it is fetched once here. A
|
|
249
|
+
// meeting minted in this session already has its link from the create call (see
|
|
250
|
+
// `applyVideoConferencing()`), which is why this deliberately reads the *prop*, not the state: it must
|
|
251
|
+
// not re-fetch what was just handed to us. A failure and a meeting with no link at all are the same
|
|
252
|
+
// thing to this UI - there is nothing to open either way - so both settle on `null`.
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
const uid = occurrence?.videoMeetingUid;
|
|
255
|
+
if (!uid) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
let cancelled = false;
|
|
259
|
+
void getVideoMeeting(uid)
|
|
260
|
+
.then((meeting) => meeting.organizerJoinUrl ?? null)
|
|
261
|
+
.catch(() => null)
|
|
262
|
+
.then((url) => {
|
|
263
|
+
if (!cancelled) {
|
|
264
|
+
setOrganizerJoinUrl(url);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return () => {
|
|
268
|
+
cancelled = true;
|
|
269
|
+
};
|
|
270
|
+
}, [occurrence?.videoMeetingUid]);
|
|
271
|
+
|
|
272
|
+
// "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
|
|
273
|
+
const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
|
|
274
|
+
|
|
275
|
+
dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || values.guestInvalid.length > 0 || hasDescriptionText(values.descriptionHtml));
|
|
276
|
+
|
|
277
|
+
function updateAttendee(index: number, patch: Partial<Attendee>) {
|
|
278
|
+
setValues((prev) => ({ ...prev, attendees: prev.attendees.map((a, i) => (i === index ? { ...a, ...patch } : a)) }));
|
|
279
|
+
}
|
|
280
|
+
function removeAttendee(index: number) {
|
|
281
|
+
setValues((prev) => ({ ...prev, attendees: prev.attendees.filter((_, i) => i !== index) }));
|
|
282
|
+
}
|
|
283
|
+
function addResource(mailbox: Mailbox) {
|
|
284
|
+
setValues((prev) => ({
|
|
285
|
+
...prev,
|
|
286
|
+
attendees: [
|
|
287
|
+
...prev.attendees,
|
|
288
|
+
{ address: mailbox.primarySmtpAddress, displayName: mailbox.displayName, role: "resource", responseStatus: "needsAction", isOrganizer: false },
|
|
289
|
+
],
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Moving the start moves the end with it, so the event keeps its length (a Google Calendar habit, and the way to
|
|
294
|
+
// reschedule without touching two fields).
|
|
295
|
+
function setStart(next: string) {
|
|
296
|
+
setValues((prev) => {
|
|
297
|
+
const shift = prev.allDay
|
|
298
|
+
? Date.parse(`${next.slice(0, 10)}T00:00:00Z`) - Date.parse(`${prev.start.slice(0, 10)}T00:00:00Z`)
|
|
299
|
+
: wallStringToMs(next, prev.formZone, deviceZone) - wallStringToMs(prev.start, prev.formZone, deviceZone);
|
|
300
|
+
const endMs = prev.allDay ? Date.parse(`${prev.end.slice(0, 10)}T00:00:00Z`) : wallStringToMs(prev.end, prev.formZone, deviceZone);
|
|
301
|
+
let nextEnd = prev.end;
|
|
302
|
+
if (!Number.isNaN(shift + endMs)) {
|
|
303
|
+
nextEnd = prev.allDay
|
|
304
|
+
? `${new Date(endMs + shift).toISOString().slice(0, 10)}T00:00`
|
|
305
|
+
: msToWallString(endMs + shift, prev.formZone, deviceZone);
|
|
306
|
+
}
|
|
307
|
+
return { ...prev, start: next, end: nextEnd };
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The recurrence end date is stored in the frame the series expands in (UTC for all-day, local otherwise),
|
|
312
|
+
// so toggling All day re-stores the same chosen date in the new frame.
|
|
313
|
+
function handleAllDayChange(nextAllDay: boolean) {
|
|
314
|
+
setValues((prev) => {
|
|
315
|
+
let end = prev.end;
|
|
316
|
+
if (!nextAllDay) {
|
|
317
|
+
// Back to a timed event: the all-day dates carry no length, so give it an hour rather than an empty span.
|
|
318
|
+
const startMs = wallStringToMs(prev.start, prev.formZone, deviceZone);
|
|
319
|
+
if (!(wallStringToMs(prev.end, prev.formZone, deviceZone) > startMs) && !Number.isNaN(startMs)) {
|
|
320
|
+
end = msToWallString(startMs + MS_PER_HOUR, prev.formZone, deviceZone);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
...prev,
|
|
325
|
+
allDay: nextAllDay,
|
|
326
|
+
end,
|
|
327
|
+
recurrenceRule: prev.recurrenceRule?.until
|
|
328
|
+
? {
|
|
329
|
+
...prev.recurrenceRule,
|
|
330
|
+
until: recurrenceUntilInstant(recurrenceUntilDateKey(prev.recurrenceRule.until, prev.allDay), nextAllDay),
|
|
331
|
+
}
|
|
332
|
+
: prev.recurrenceRule,
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The times the form shows are read in `formZone`: picking another zone keeps the same instants and shows them on that zone's
|
|
338
|
+
// clock, and the event is stored with the zone chosen. An all-day event has no clock to convert.
|
|
339
|
+
function handleTimeZoneChange(zone: string) {
|
|
340
|
+
setValues((prev) => {
|
|
341
|
+
if (prev.allDay) {
|
|
342
|
+
return { ...prev, formZone: zone, timezone: zone };
|
|
343
|
+
}
|
|
344
|
+
const startMs = wallStringToMs(prev.start, prev.formZone, deviceZone);
|
|
345
|
+
const endMs = wallStringToMs(prev.end, prev.formZone, deviceZone);
|
|
346
|
+
return {
|
|
347
|
+
...prev,
|
|
348
|
+
formZone: zone,
|
|
349
|
+
timezone: zone,
|
|
350
|
+
start: Number.isNaN(startMs) ? prev.start : msToWallString(startMs, zone, deviceZone),
|
|
351
|
+
end: Number.isNaN(endMs) ? prev.end : msToWallString(endMs, zone, deviceZone),
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Brings the event's video meeting in line with the toggle, once the event itself is saved (so the
|
|
358
|
+
* attendee list a new meeting's invitees are built from is the one that was just stored).
|
|
359
|
+
*
|
|
360
|
+
* Turning it **on** mints a meeting for the saved event's attendees - every one of them except the
|
|
361
|
+
* organizer, who reaches their own meeting through ownership (`organizerJoinUrl`), not as a guest - and
|
|
362
|
+
* then patches the event with the meeting's uid and the generic `VIDEO_LOCATION_PLACEHOLDER` location.
|
|
363
|
+
* No join link is written onto the event: each attendee's own link is substituted into their own copy of
|
|
364
|
+
* the invitation server-side.
|
|
365
|
+
*
|
|
366
|
+
* Turning it **off** cancels the meeting and clears the link, restoring Location to `""` only when it
|
|
367
|
+
* still holds exactly the placeholder this modal wrote - anything the user has typed there since is
|
|
368
|
+
* theirs and is preserved.
|
|
369
|
+
*
|
|
370
|
+
* **Known, deliberate limitation:** an event that already has a meeting is left completely alone, even
|
|
371
|
+
* when its attendee list has just changed. `@rapidmx/meet-plugin`'s `PUT /mail/video-meetings/:id`
|
|
372
|
+
* is deliberately minimal (title and cancellation only) and cannot add or remove invitees, and a private
|
|
373
|
+
* meeting's personal join links are minted once at creation - so there is no way to reconcile an invitee
|
|
374
|
+
* list, and silently pretending otherwise would leave a new attendee with no link and a removed one with
|
|
375
|
+
* a working one. The helper text under the checkbox says so, and turning the toggle off and on again
|
|
376
|
+
* reissues links for the current attendee list.
|
|
377
|
+
*
|
|
378
|
+
* Never throws: the event is already stored by the time this runs, so a failure here is reported inline
|
|
379
|
+
* next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
|
|
380
|
+
*/
|
|
381
|
+
async function applyVideoConferencing(saved: CalendarEvent, guests: Attendee[]): Promise<{ event: CalendarEvent; failed: boolean }> {
|
|
382
|
+
// Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
|
|
383
|
+
if (videoEnabled === !!videoMeetingUid) {
|
|
384
|
+
return { event: saved, failed: false };
|
|
385
|
+
}
|
|
386
|
+
// Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
|
|
387
|
+
// deliberately never copies the series' `videoMeetingUid` onto the new standalone event - that
|
|
388
|
+
// shared meeting belongs to the series, not to the one occurrence being split off, and every other
|
|
389
|
+
// occurrence still needs it live. But this component's own `videoMeetingUid` *state* was seeded
|
|
390
|
+
// from the series' occurrence at mount and is untouched by the detach, so unchecking the toggle
|
|
391
|
+
// while detaching would otherwise still look like "off, but state says linked" above and fall into
|
|
392
|
+
// the cancel branch below - cancelling the series' shared meeting for every remaining occurrence,
|
|
393
|
+
// not just this one. `saved.videoMeetingUid` is the actual just-persisted record, so when the
|
|
394
|
+
// toggle is off and that record already has no meeting reference (true for every detach, checked or
|
|
395
|
+
// not - see this function's own doc comment on the still-checked case being a separate, documented
|
|
396
|
+
// gap), there is nothing of *this* record's to cancel.
|
|
397
|
+
if (!videoEnabled && !saved.videoMeetingUid) {
|
|
398
|
+
return { event: saved, failed: false };
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
if (videoEnabled) {
|
|
402
|
+
const organizerAddressLower = saved.organizer.address.toLowerCase();
|
|
403
|
+
const invitees: VideoMeetingInvitee[] = guests
|
|
404
|
+
.filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
|
|
405
|
+
.map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
|
|
406
|
+
if (invitees.length === 0) {
|
|
407
|
+
setVideoError("Add at least one attendee other than yourself to add video conferencing.");
|
|
408
|
+
return { event: saved, failed: true };
|
|
409
|
+
}
|
|
410
|
+
const result = await createVideoMeeting({
|
|
411
|
+
mailboxUid: saved.mailboxUid,
|
|
412
|
+
title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
|
|
413
|
+
visibility: "private",
|
|
414
|
+
calendarEventUid: saved.uid,
|
|
415
|
+
startTime: saved.startDate,
|
|
416
|
+
endTime: saved.endDate,
|
|
417
|
+
invitees,
|
|
418
|
+
});
|
|
419
|
+
const patched = await updateCalendarEvent({
|
|
420
|
+
uid: saved.uid,
|
|
421
|
+
version: saved.version,
|
|
422
|
+
location: VIDEO_LOCATION_PLACEHOLDER,
|
|
423
|
+
videoMeetingUid: result.meeting.uid,
|
|
424
|
+
});
|
|
425
|
+
setVideoMeetingUid(result.meeting.uid);
|
|
426
|
+
setOrganizerJoinUrl(result.organizerJoinUrl ?? null);
|
|
427
|
+
update({ location: VIDEO_LOCATION_PLACEHOLDER });
|
|
428
|
+
return { event: patched, failed: false };
|
|
429
|
+
}
|
|
430
|
+
await updateVideoMeeting(videoMeetingUid!, { status: "cancelled" });
|
|
431
|
+
const restoredLocation = location.trim() === VIDEO_LOCATION_PLACEHOLDER ? "" : location.trim();
|
|
432
|
+
// A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
|
|
433
|
+
// leave the placeholder (and the link to the now-cancelled meeting) in place.
|
|
434
|
+
const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
|
|
435
|
+
const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) });
|
|
436
|
+
setVideoMeetingUid(undefined);
|
|
437
|
+
setOrganizerJoinUrl(undefined);
|
|
438
|
+
update({ location: restoredLocation });
|
|
439
|
+
return { event: patched, failed: false };
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// `videoMeetingsApi.ts`'s own doc comment: when the plugin isn't installed at all, its routes
|
|
442
|
+
// simply aren't mounted and every call here fails with a plain 404 - a caller offering video
|
|
443
|
+
// conferencing optionally must treat that as "not available here," not alarm the reader with
|
|
444
|
+
// the raw (often server-generated, unhelpful) 404 body text.
|
|
445
|
+
setVideoError(
|
|
446
|
+
err instanceof ApiRequestError
|
|
447
|
+
? err.status === 404
|
|
448
|
+
? "Video conferencing is not available on this server."
|
|
449
|
+
: err.message
|
|
450
|
+
: "Could not update this event's video conferencing.",
|
|
451
|
+
);
|
|
452
|
+
return { event: saved, failed: true };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function handleSubmit(e: FormEvent) {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
setError(null);
|
|
459
|
+
setVideoError(null);
|
|
460
|
+
|
|
461
|
+
if (!values.title.trim()) {
|
|
462
|
+
setError("A title is required.");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// An address typed into the guests field but not added yet is added now - or, not being one (nor what was already flagged there), stops the save.
|
|
466
|
+
const merged = mergeGuests(values.attendees, [...values.guestInvalid, values.guestDraft].join(", "), [effectiveOrganizerAddress]);
|
|
467
|
+
if (merged.invalid.length > 0) {
|
|
468
|
+
setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const tooLong = descriptionProblem(values.descriptionHtml);
|
|
472
|
+
if (tooLong) {
|
|
473
|
+
setError(tooLong);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (values.guestDraft) {
|
|
477
|
+
update({ attendees: merged.attendees, guestDraft: "", guestInvalid: [] });
|
|
478
|
+
}
|
|
479
|
+
let startDate: string;
|
|
480
|
+
let endDate: string;
|
|
481
|
+
const { start, end, allDay } = values;
|
|
482
|
+
if (allDay) {
|
|
483
|
+
const startKey = start.slice(0, 10);
|
|
484
|
+
const lastDayKey = end.slice(0, 10);
|
|
485
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(startKey) || !/^\d{4}-\d{2}-\d{2}$/.test(lastDayKey)) {
|
|
486
|
+
setError("Pick a start and an end date.");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (lastDayKey < startKey) {
|
|
490
|
+
setError("The end date can't be before the start date.");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
startDate = allDayInstant(startKey);
|
|
494
|
+
endDate = allDayInstant(addDaysToKey(lastDayKey, 1));
|
|
495
|
+
} else {
|
|
496
|
+
const startMs = wallStringToMs(start, values.formZone, deviceZone);
|
|
497
|
+
const endMs = wallStringToMs(end, values.formZone, deviceZone);
|
|
498
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
|
|
499
|
+
setError("Pick a start and an end date and time.");
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (endMs <= startMs) {
|
|
503
|
+
setError("The end time must be after the start time.");
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
startDate = new Date(startMs).toISOString();
|
|
507
|
+
endDate = new Date(endMs).toISOString();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Only a stored event is updated in place, where a cleared field must be sent as `null`; creating
|
|
511
|
+
// (a new event, or the detached copy of one occurrence) just omits it.
|
|
512
|
+
const updatingInPlace = !!occurrence && !editingSingleOccurrence;
|
|
513
|
+
const cleared = updatingInPlace ? null : undefined;
|
|
514
|
+
const fields: EventFields = {
|
|
515
|
+
title: values.title.trim(),
|
|
516
|
+
location: values.location.trim() || cleared,
|
|
517
|
+
startDate,
|
|
518
|
+
endDate,
|
|
519
|
+
allDay,
|
|
520
|
+
timezone: values.timezone,
|
|
521
|
+
attendees: merged.attendees,
|
|
522
|
+
recurrenceRule: editingSingleOccurrence ? undefined : (values.recurrenceRule ?? cleared),
|
|
523
|
+
reminderMinutesBeforeStart: values.reminderMinutes.trim() ? Number(values.reminderMinutes) : cleared,
|
|
524
|
+
busyStatus: values.busyStatus,
|
|
525
|
+
...dialogFields(values, occurrence),
|
|
526
|
+
autoReplyEnabled: values.autoReplyEnabled,
|
|
527
|
+
autoReplyMessage: values.autoReplyEnabled ? values.autoReplyMessage : cleared,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
setSaving(true);
|
|
531
|
+
try {
|
|
532
|
+
let saved: CalendarEvent;
|
|
533
|
+
let detachedSyncFailed = false;
|
|
534
|
+
if (savedEvent) {
|
|
535
|
+
// A retry after the event was stored but its video meeting couldn't be (see `savedEvent`):
|
|
536
|
+
// update exactly the record the first attempt left behind, whichever branch below created it.
|
|
537
|
+
// A series' own exception/detached-occurrence re-pointing already ran on that attempt and has
|
|
538
|
+
// nothing left to shift - the dates being sent again are the ones it moved everything to.
|
|
539
|
+
saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(fields as Partial<CalendarEventInput>) });
|
|
540
|
+
} else if (!occurrence) {
|
|
541
|
+
// The organizer is only ever set on create - an edit keeps the event's own organizer.
|
|
542
|
+
saved = await createCalendarEvent({
|
|
543
|
+
mailboxUid: values.targetMailboxUid,
|
|
544
|
+
folderUid: values.targetFolderUid,
|
|
545
|
+
...fields,
|
|
546
|
+
organizer: { address: effectiveOrganizerAddress, type: "to" },
|
|
547
|
+
} as CalendarEventInput);
|
|
548
|
+
} else if (editingSingleOccurrence) {
|
|
549
|
+
saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>);
|
|
550
|
+
} else if (occurrence.isRecurringOccurrence) {
|
|
551
|
+
const series = await saveEventSeries(occurrence, (await toSeriesFields(occurrence, fields)) as Partial<CalendarEventInput>);
|
|
552
|
+
saved = series;
|
|
553
|
+
detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
|
|
554
|
+
} else {
|
|
555
|
+
saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) });
|
|
556
|
+
}
|
|
557
|
+
const video = await applyVideoConferencing(saved, merged.attendees);
|
|
558
|
+
if (video.failed) {
|
|
559
|
+
// The event itself is saved - keep the modal open on the inline error so the video meeting
|
|
560
|
+
// can be retried (or the toggle put back) rather than closing over it.
|
|
561
|
+
setSavedEvent(video.event);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (detachedSyncFailed) {
|
|
565
|
+
// The series itself was saved - say so, and leave closing to the user once they've read it.
|
|
566
|
+
onSyncWarning();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
onSaved();
|
|
570
|
+
} catch (err) {
|
|
571
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
|
|
572
|
+
} finally {
|
|
573
|
+
setSaving(false);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
|
|
578
|
+
// `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
|
|
579
|
+
// manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
|
|
580
|
+
// `file:`/other non-http(s) URL into `window.open()`. `undefined` (still loading) and `null` (fetch
|
|
581
|
+
// came back with nothing to join) both fail validation the same way as a bad scheme would - the button
|
|
582
|
+
// stays disabled either way, and the two are told apart below purely from `organizerJoinUrl` itself.
|
|
583
|
+
const joinUrl = joinMeetingUrl(organizerJoinUrl);
|
|
584
|
+
|
|
585
|
+
const timeZones = useMemo(() => timeZoneOptions(values.formZone, values.timezone, deviceZone), [values.formZone, values.timezone, deviceZone]);
|
|
586
|
+
const startMs = values.allDay ? Number.NaN : wallStringToMs(values.start, values.formZone, deviceZone);
|
|
587
|
+
const calendarFolderUid = occurrence ? occurrence.folderUid : values.targetFolderUid;
|
|
588
|
+
|
|
589
|
+
const controller: EventFormController = {
|
|
590
|
+
occurrence,
|
|
591
|
+
values,
|
|
592
|
+
update,
|
|
593
|
+
setStart,
|
|
594
|
+
setEnd: (next) => update({ end: next }),
|
|
595
|
+
onAllDayChange: handleAllDayChange,
|
|
596
|
+
onTimeZoneChange: handleTimeZoneChange,
|
|
597
|
+
timeZones,
|
|
598
|
+
organizerAddress: effectiveOrganizerAddress,
|
|
599
|
+
deviceZone,
|
|
600
|
+
startWeekday: startWeekdayCode(
|
|
601
|
+
values.allDay ? values.start : Number.isNaN(startMs) ? "" : new Date(startMs).toISOString(),
|
|
602
|
+
values.allDay,
|
|
603
|
+
values.timezone,
|
|
604
|
+
),
|
|
605
|
+
editingSingleOccurrence,
|
|
606
|
+
updateAttendee,
|
|
607
|
+
removeAttendee,
|
|
608
|
+
addResource,
|
|
609
|
+
guestAddresses: attendees.map((a) => a.address.toLowerCase()),
|
|
610
|
+
mailboxOptions,
|
|
611
|
+
onMailboxChange: handleMailboxChange,
|
|
612
|
+
calendarChoices,
|
|
613
|
+
calendarName: calendarChoices?.find((cal) => cal.uid === calendarFolderUid)?.name,
|
|
614
|
+
calendarColor: folderColors?.[calendarFolderUid],
|
|
615
|
+
videoMeetingUid,
|
|
616
|
+
joinUrl,
|
|
617
|
+
organizerJoinUrl,
|
|
618
|
+
videoError,
|
|
619
|
+
editScope,
|
|
620
|
+
setEditScope,
|
|
621
|
+
showEditScope: !!occurrence?.isRecurringOccurrence,
|
|
622
|
+
error,
|
|
623
|
+
saving,
|
|
624
|
+
onSubmit: (event) => void handleSubmit(event),
|
|
625
|
+
// Once something was stored, leaving is a save as far as the calendar behind is concerned: it has to reload.
|
|
626
|
+
onCancel: () => (savedEvent ? onSaved() : onCancel()),
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// A new event has the tabs, whose Event face is the quick popover or the full card; an existing event is always the full card.
|
|
630
|
+
return quickCreate ? (
|
|
631
|
+
<QuickCreateFaces c={controller} layout={layout} onExpand={onExpand} config={quickCreate} dirtyRef={dirtyRef} organizerAddress={effectiveOrganizerAddress} />
|
|
632
|
+
) : (
|
|
633
|
+
<EventExpandedForm c={controller} />
|
|
634
|
+
);
|
|
635
|
+
}
|