@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.
Files changed (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -866
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +2 -2
@@ -0,0 +1,81 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { KeyboardEvent } from "react";
6
+
7
+ /** What the New event popover can create. */
8
+ export type QuickTab = "event" | "task" | "appointment";
9
+
10
+ const TAB_LABELS: Record<QuickTab, string> = { event: "Event", task: "Task", appointment: "Appointment schedule" };
11
+
12
+ /** The DOM ids that tie each tab to its panel (`aria-controls` / `aria-labelledby`). */
13
+ export function quickTabId(tab: QuickTab): string {
14
+ return `quick-create-tab-${tab}`;
15
+ }
16
+ export function quickPanelId(tab: QuickTab): string {
17
+ return `quick-create-panel-${tab}`;
18
+ }
19
+
20
+ /** The attributes of the element holding the selected tab's content. */
21
+ export function quickPanelProps(tab: QuickTab) {
22
+ return { role: "tabpanel" as const, id: quickPanelId(tab), "aria-labelledby": quickTabId(tab) };
23
+ }
24
+
25
+ export interface QuickCreateTabsProps {
26
+ tab: QuickTab;
27
+ onChange: (tab: QuickTab) => void;
28
+ /** Whether the booking plugin is running: without it there is no Appointment schedule tab. */
29
+ showAppointment: boolean;
30
+ }
31
+
32
+ /**
33
+ * The segmented control across the top of the New event popover: Event, Task and (when the booking plugin is running) Appointment schedule.
34
+ * A WAI-ARIA tab list with automatic activation: only the selected tab is in the Tab order, and the arrow keys (wrapping), Home and End
35
+ * move to and select another.
36
+ */
37
+ export default function QuickCreateTabs({ tab, onChange, showAppointment }: QuickCreateTabsProps) {
38
+ const tabs: QuickTab[] = showAppointment ? ["event", "task", "appointment"] : ["event", "task"];
39
+
40
+ function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
41
+ const index = tabs.indexOf(tab);
42
+ let next: QuickTab;
43
+ if (event.key === "ArrowRight") {
44
+ next = tabs[(index + 1) % tabs.length];
45
+ } else if (event.key === "ArrowLeft") {
46
+ next = tabs[(index + tabs.length - 1) % tabs.length];
47
+ } else if (event.key === "Home") {
48
+ next = tabs[0];
49
+ } else if (event.key === "End") {
50
+ next = tabs[tabs.length - 1];
51
+ } else {
52
+ return;
53
+ }
54
+ event.preventDefault();
55
+ onChange(next);
56
+ }
57
+
58
+ return (
59
+ <div role="tablist" aria-label="What to create" className="mx-5 mb-3 flex gap-1 rounded-lg bg-surface-alt p-1">
60
+ {tabs.map((id) => (
61
+ <button
62
+ key={id}
63
+ type="button"
64
+ role="tab"
65
+ id={quickTabId(id)}
66
+ aria-selected={id === tab}
67
+ aria-controls={quickPanelId(id)}
68
+ tabIndex={id === tab ? 0 : -1}
69
+ onClick={() => onChange(id)}
70
+ onKeyDown={handleKeyDown}
71
+ className={[
72
+ "flex-1 min-w-0 rounded-md px-2 py-1.5 text-sm font-medium truncate focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
73
+ id === tab ? "bg-surface text-text shadow-sm" : "text-text-muted hover:text-text",
74
+ ].join(" ")}
75
+ >
76
+ {TAB_LABELS[id]}
77
+ </button>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,81 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, ReactNode, useContext } from "react";
6
+ import { HiOutlineBars2, HiOutlineXMark } from "react-icons/hi2";
7
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
+ import { EventShellContext } from "./EventShell.js";
9
+ import { EventFormController } from "./eventForm.js";
10
+ import { QuickTab, quickPanelProps } from "./QuickCreateTabs.js";
11
+
12
+ export interface QuickFaceFrameProps {
13
+ c: EventFormController;
14
+ /** The `QuickCreateTabs` strip. */
15
+ tabs: ReactNode;
16
+ /** The tab this face is the panel of. */
17
+ tab: QuickTab;
18
+ error: string | null;
19
+ /** Leave the title out (the Appointment schedule tab once it has saved). */
20
+ hideTitle?: boolean;
21
+ /** Enter in a field, or the Save button. */
22
+ onSubmit: (event: FormEvent) => void;
23
+ /** What sits in the tab's panel under the title. */
24
+ children: ReactNode;
25
+ /** The buttons along the bottom (More options, Save). */
26
+ footer: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * The frame the Task and Appointment schedule faces share, drawn the way `EventQuickForm` draws the Event face: a grip to drag the popover
31
+ * and a close button, the tab strip, then the panel - an error, the title (the one the Event tab holds, so switching tabs keeps it) and the
32
+ * face's own rows - and the buttons.
33
+ */
34
+ export default function QuickFaceFrame({ c, tabs, tab, error, hideTitle, onSubmit, children, footer }: QuickFaceFrameProps) {
35
+ const { dragHandleProps } = useContext(EventShellContext);
36
+ return (
37
+ <form onSubmit={onSubmit} className="flex flex-col">
38
+ <div className="flex items-center justify-between px-2 pt-2">
39
+ {dragHandleProps ? (
40
+ <div {...dragHandleProps} aria-hidden="true" className="flex-1 h-7 flex items-center cursor-move text-text-muted select-none touch-none">
41
+ <HiOutlineBars2 size={20} />
42
+ </div>
43
+ ) : (
44
+ <div className="flex-1" />
45
+ )}
46
+ <button
47
+ type="button"
48
+ aria-label="Close"
49
+ onClick={c.onCancel}
50
+ className="w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
51
+ >
52
+ <HiOutlineXMark size={20} aria-hidden="true" />
53
+ </button>
54
+ </div>
55
+
56
+ {tabs}
57
+
58
+ <div {...quickPanelProps(tab)} className="flex flex-col gap-3 px-5 pb-3">
59
+ {error && <Alert>{error}</Alert>}
60
+
61
+ {!hideTitle && (
62
+ <div className="pl-8">
63
+ <input
64
+ type="text"
65
+ aria-label="Title"
66
+ placeholder="Add title"
67
+ data-autofocus
68
+ 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"
69
+ value={c.values.title}
70
+ onChange={(e) => c.update({ title: e.target.value })}
71
+ />
72
+ </div>
73
+ )}
74
+
75
+ {children}
76
+ </div>
77
+
78
+ <div className="flex items-center justify-end gap-2 px-4 py-3">{footer}</div>
79
+ </form>
80
+ );
81
+ }
@@ -38,6 +38,9 @@ export interface RecurrenceEditorProps {
38
38
  /** The weekday the event starts on (in the frame its series expands in - see `allDay.ts`'s
39
39
  * `startWeekdayCode()`): the day a new weekly rule, or one switched to Weekly, repeats on. Defaults to Monday. */
40
40
  startWeekday?: WeekdayCode;
41
+ /** Leaves out the "Repeats" checkbox, for a caller that already turns repeating on and off itself (the event form's
42
+ * "Does not repeat" menu): what remains is the rule's details, shown only while there is a rule. */
43
+ hideToggle?: boolean;
41
44
  }
42
45
 
43
46
  /**
@@ -46,7 +49,7 @@ export interface RecurrenceEditorProps {
46
49
  * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
47
50
  * `.toText()`), so it never drifts out of sync with what will actually be submitted.
48
51
  */
49
- export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO" }: RecurrenceEditorProps) {
52
+ export default function RecurrenceEditor({ value, onChange, allDay = false, startWeekday = "MO", hideToggle = false }: RecurrenceEditorProps) {
50
53
  function handleEnable(enabled: boolean) {
51
54
  onChange(enabled ? { freq: "weekly", interval: 1, byDay: [startWeekday], exceptions: [] } : null);
52
55
  }
@@ -109,10 +112,12 @@ export default function RecurrenceEditor({ value, onChange, allDay = false, star
109
112
 
110
113
  return (
111
114
  <div className="flex flex-col gap-3">
112
- <label className="flex items-center gap-2 text-sm font-medium">
113
- <input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
114
- Repeats
115
- </label>
115
+ {!hideToggle && (
116
+ <label className="flex items-center gap-2 text-sm font-medium">
117
+ <input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
118
+ Repeats
119
+ </label>
120
+ )}
116
121
 
117
122
  {value && (
118
123
  <div className="flex flex-col gap-3 pl-6 border-l-2 border-border">
@@ -0,0 +1,271 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { HiOutlineXMark } from "react-icons/hi2";
7
+ import { EventChangeRequest, requestEventChange } from "@rapidmx/react-shared/calendar/calendarApi.js";
8
+ import { htmlToPlainText, sanitizeEventDescriptionHtml } from "@rapidmx/react-shared/calendar/eventDescription.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 { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
12
+ import { notifyApiError } from "../../notifications/apiErrors.js";
13
+ import { notify } from "../../notifications/store.js";
14
+ import { INPUT_CLASS } from "./EventFormParts.js";
15
+ import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
16
+ import { descriptionProblem, initialDescriptionHtml } from "./eventDialogFields.js";
17
+ import { mergeGuests } from "./eventFormat.js";
18
+
19
+ /** The event a guest asks about: what it says now (the form starts from it and sends only what differs) and which row of it the request is for. */
20
+ export interface ChangeSubject {
21
+ /** The event's uid on this mailbox (`CalendarEvent.uid`, or `MessageInvite.calendarEventUid`): the row the organizer's answer will come back to. */
22
+ uid: string;
23
+ title: string;
24
+ location?: string | null;
25
+ /** ISO 8601 instants; absent for an event whose time is not known. */
26
+ startDate?: string;
27
+ endDate?: string;
28
+ allDay: boolean;
29
+ /** Part of a series: a request changes the whole series (its own row), so its time - which is one occurrence's - is not offered. */
30
+ recurring: boolean;
31
+ description?: string | null;
32
+ descriptionHtml?: string | null;
33
+ /** Everyone already invited, whatever the case, so a guest is not added twice. */
34
+ guestAddresses: string[];
35
+ }
36
+
37
+ export interface RequestChangeFormProps {
38
+ subject: ChangeSubject;
39
+ /** The organizer lets guests change the title, location, description and time (`guestsCanModify`). */
40
+ canChange: boolean;
41
+ /** The organizer lets guests add guests (`guestsCanInviteOthers`). */
42
+ canInvite: boolean;
43
+ /** The request was sent. */
44
+ onSent: () => void;
45
+ onCancel: () => void;
46
+ }
47
+
48
+ const MINUTE_MS = 60_000;
49
+
50
+ /** Whether two instants (ISO, or a form's local time) name the same minute. */
51
+ function sameMinute(a: string, b: string): boolean {
52
+ return Math.round(new Date(a).getTime() / MINUTE_MS) === Math.round(new Date(b).getTime() / MINUTE_MS);
53
+ }
54
+
55
+ /**
56
+ * A guest's request to the organizer, for an event whose organizer lets guests change it and/or invite others (`POST /calendar-events/:id/request-change`).
57
+ * Starts from the event as it is and sends only what the guest changed: the title, location, description and time (with `canChange`) and guests to add (with
58
+ * `canInvite`). Nothing changes on the guest's own calendar - the organizer's copy is updated (automatically, when the flags allow it) and the change
59
+ * comes back as the organizer's next update - so the form says so, and afterwards a "Your change was sent to the organizer" notification confirms it.
60
+ * A title or location can be changed, not cleared (the server refuses that), and the time of a series or an all-day event is not offered.
61
+ */
62
+ export default function RequestChangeForm({ subject, canChange, canInvite, onSent, onCancel }: RequestChangeFormProps) {
63
+ const canChangeTime = canChange && !subject.allDay && !subject.recurring && !!subject.startDate && !!subject.endDate;
64
+ const [title, setTitle] = useState(subject.title);
65
+ const [location, setLocation] = useState(subject.location ?? "");
66
+ const [start, setStart] = useState(subject.startDate ? toDatetimeLocal(subject.startDate) : "");
67
+ const [end, setEnd] = useState(subject.endDate ? toDatetimeLocal(subject.endDate) : "");
68
+ const [descriptionHtml, setDescriptionHtml] = useState(() => initialDescriptionHtml(subject));
69
+ const [added, setAdded] = useState<{ address: string }[]>([]);
70
+ const [guestDraft, setGuestDraft] = useState("");
71
+ const [error, setError] = useState<string | null>(null);
72
+ const [sending, setSending] = useState(false);
73
+ const heading = canChange ? "Request a change" : "Add guests";
74
+
75
+ /** Adds what was typed into the guests box, returning the guests as they now stand; a mistyped address stays in the box and is said. */
76
+ function addTypedGuests(): { address: string }[] | undefined {
77
+ const invited = [...subject.guestAddresses, ...added.map((guest) => guest.address)].map((address) => ({ address }));
78
+ const merged = mergeGuests(
79
+ invited.map((guest) => ({ ...guest, role: "required" as const, responseStatus: "needsAction" as const, isOrganizer: false })),
80
+ guestDraft,
81
+ );
82
+ if (merged.invalid.length > 0) {
83
+ setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
84
+ setGuestDraft(merged.invalid.join(" "));
85
+ return undefined;
86
+ }
87
+ setGuestDraft("");
88
+ return merged.attendees.slice(invited.length).map((guest) => ({ address: guest.address }));
89
+ }
90
+
91
+ function handleGuestKey(event: React.KeyboardEvent<HTMLInputElement>) {
92
+ if (event.key === "Enter" || event.key === "," || event.key === ";") {
93
+ event.preventDefault();
94
+ setError(null);
95
+ const more = addTypedGuests();
96
+ if (more) {
97
+ setAdded((current) => [...current, ...more]);
98
+ }
99
+ }
100
+ }
101
+
102
+ async function handleSubmit(event: FormEvent) {
103
+ event.preventDefault();
104
+ setError(null);
105
+ const typed = canInvite ? addTypedGuests() : [];
106
+ if (!typed) {
107
+ return;
108
+ }
109
+ const guests = [...added, ...typed];
110
+ const request: EventChangeRequest = {};
111
+
112
+ if (canChange) {
113
+ if (title.trim() !== subject.title.trim()) {
114
+ if (!title.trim()) {
115
+ setError("Enter a title, or leave it as it is.");
116
+ return;
117
+ }
118
+ request.title = title.trim();
119
+ }
120
+ if (location.trim() !== (subject.location ?? "").trim()) {
121
+ if (!location.trim()) {
122
+ setError("Enter a location, or leave it as it is.");
123
+ return;
124
+ }
125
+ request.location = location.trim();
126
+ }
127
+ if (canChangeTime && (!sameMinute(start, subject.startDate as string) || !sameMinute(end, subject.endDate as string))) {
128
+ const startMs = new Date(start).getTime();
129
+ const endMs = new Date(end).getTime();
130
+ if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) {
131
+ setError("The end time must be after the start time.");
132
+ return;
133
+ }
134
+ request.startDate = new Date(startMs).toISOString();
135
+ request.endDate = new Date(endMs).toISOString();
136
+ }
137
+ const html = sanitizeEventDescriptionHtml(descriptionHtml);
138
+ if (html !== sanitizeEventDescriptionHtml(initialDescriptionHtml(subject)) && descriptionHtml !== initialDescriptionHtml(subject)) {
139
+ const plain = htmlToPlainText(html);
140
+ if (!plain) {
141
+ setError("Enter a description, or leave it as it is.");
142
+ return;
143
+ }
144
+ const tooLong = descriptionProblem(html);
145
+ if (tooLong) {
146
+ setError(tooLong);
147
+ return;
148
+ }
149
+ request.descriptionHtml = html;
150
+ request.description = plain;
151
+ }
152
+ }
153
+ if (guests.length > 0) {
154
+ request.addAttendees = guests;
155
+ }
156
+ if (Object.keys(request).length === 0) {
157
+ setError("Change something first.");
158
+ return;
159
+ }
160
+
161
+ setSending(true);
162
+ try {
163
+ await requestEventChange(subject.uid, request);
164
+ notify({
165
+ kind: "success",
166
+ title: "Your change was sent to the organizer",
167
+ message: "The event on your calendar updates when the organizer's update comes back.",
168
+ });
169
+ onSent();
170
+ } catch (err) {
171
+ notifyApiError(err, "Couldn't send your change");
172
+ setSending(false);
173
+ }
174
+ }
175
+
176
+ return (
177
+ <form onSubmit={(event) => void handleSubmit(event)} aria-label={heading} className="flex flex-col gap-3">
178
+ <div className="flex items-center justify-between gap-3">
179
+ <h3 className="text-sm font-semibold">{heading}</h3>
180
+ <button
181
+ type="button"
182
+ aria-label="Cancel request"
183
+ onClick={onCancel}
184
+ className="w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
185
+ >
186
+ <HiOutlineXMark size={16} aria-hidden="true" />
187
+ </button>
188
+ </div>
189
+ <p className="text-xs text-text-muted">
190
+ Your request goes to the organizer. Nothing on your calendar changes until their update arrives; when the organizer allows it, it is applied for you.
191
+ </p>
192
+ {error && <Alert>{error}</Alert>}
193
+
194
+ {canChange && (
195
+ <>
196
+ <label className="flex flex-col gap-1 text-xs font-medium">
197
+ Title
198
+ <input type="text" className={INPUT_CLASS} value={title} onChange={(e) => setTitle(e.target.value)} />
199
+ </label>
200
+ <label className="flex flex-col gap-1 text-xs font-medium">
201
+ Location
202
+ <input type="text" className={INPUT_CLASS} value={location} onChange={(e) => setLocation(e.target.value)} />
203
+ </label>
204
+ {canChangeTime && (
205
+ <div className="flex flex-wrap gap-3">
206
+ <label className="flex flex-col gap-1 text-xs font-medium">
207
+ Starts
208
+ <input type="datetime-local" className={INPUT_CLASS} value={start} onChange={(e) => setStart(e.target.value)} />
209
+ </label>
210
+ <label className="flex flex-col gap-1 text-xs font-medium">
211
+ Ends
212
+ <input type="datetime-local" className={INPUT_CLASS} value={end} onChange={(e) => setEnd(e.target.value)} />
213
+ </label>
214
+ </div>
215
+ )}
216
+ {!canChangeTime && (
217
+ <p className="text-xs text-text-muted">
218
+ {subject.recurring ? "A change here applies to the whole series; its times can only be changed by the organizer." : "The time of this event can only be changed by the organizer."}
219
+ </p>
220
+ )}
221
+ <div className="flex flex-col gap-1">
222
+ <span className="text-xs font-medium">Description</span>
223
+ <LazyDescriptionEditor value={descriptionHtml} onChange={setDescriptionHtml} />
224
+ </div>
225
+ </>
226
+ )}
227
+
228
+ {canInvite && (
229
+ <div className="flex flex-col gap-2">
230
+ <label className="flex flex-col gap-1 text-xs font-medium">
231
+ Guests to add
232
+ <input
233
+ type="text"
234
+ className={INPUT_CLASS}
235
+ placeholder="name@example.com"
236
+ value={guestDraft}
237
+ onChange={(e) => setGuestDraft(e.target.value)}
238
+ onKeyDown={handleGuestKey}
239
+ />
240
+ </label>
241
+ {added.length > 0 && (
242
+ <ul className="flex flex-wrap gap-1.5">
243
+ {added.map((guest, index) => (
244
+ <li key={guest.address} className="flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5">
245
+ <span className="truncate max-w-[16rem]">{guest.address}</span>
246
+ <button
247
+ type="button"
248
+ aria-label={`Remove ${guest.address}`}
249
+ onClick={() => setAdded((current) => current.filter((_, i) => i !== index))}
250
+ className="w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text"
251
+ >
252
+ <HiOutlineXMark size={12} aria-hidden="true" />
253
+ </button>
254
+ </li>
255
+ ))}
256
+ </ul>
257
+ )}
258
+ </div>
259
+ )}
260
+
261
+ <div className="flex items-center gap-2">
262
+ <Button type="submit" loading={sending} disabled={sending} className="!w-auto">
263
+ Send request
264
+ </Button>
265
+ <Button type="button" variant="text" className="!w-auto" disabled={sending} onClick={onCancel}>
266
+ Cancel
267
+ </Button>
268
+ </div>
269
+ </form>
270
+ );
271
+ }
@@ -6,6 +6,7 @@ import React from "react";
6
6
  import { addDays, addMinutes, format, startOfDay } from "date-fns";
7
7
  import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
8
8
  import { occursOnDay } from "./allDay.js";
9
+ import { EventAnchor, anchorOf } from "./EventShell.js";
9
10
 
10
11
  const HOUR_HEIGHT_PX = 48;
11
12
  const SLOT_MINUTES = 30;
@@ -25,8 +26,8 @@ export interface SplitDayViewProps {
25
26
  * calendar; this component splits them into columns itself by `occurrence.folderUid`. */
26
27
  occurrences: CalendarOccurrence[];
27
28
  onSelectEvent: (occurrence: CalendarOccurrence) => void;
28
- /** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid`. */
29
- onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
29
+ /** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid`; `anchor` is the slot. */
30
+ onSelectSlot: (start: Date, end: Date, folderUid: string, anchor: EventAnchor) => void;
30
31
  }
31
32
 
32
33
  /**
@@ -44,7 +45,7 @@ export default function SplitDayView({ day, columns, occurrences, onSelectEvent,
44
45
  const dayStart = startOfDay(day);
45
46
 
46
47
  return (
47
- <div role="region" aria-label="Split view" className="flex-1 flex flex-col min-h-0 overflow-y-auto">
48
+ <div role="region" aria-label="Split view" data-calendar-scroller className="flex-1 flex flex-col min-h-0 overflow-y-auto">
48
49
  <div className="flex border-b border-border shrink-0">
49
50
  <div className="w-14 shrink-0" />
50
51
  {columns.map((col) => (
@@ -88,7 +89,7 @@ function SplitColumn({
88
89
  dayStart: Date;
89
90
  occurrences: CalendarOccurrence[];
90
91
  onSelectEvent: (occurrence: CalendarOccurrence) => void;
91
- onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
92
+ onSelectSlot: (start: Date, end: Date, folderUid: string, anchor: EventAnchor) => void;
92
93
  }) {
93
94
  return (
94
95
  <div className="flex-1 min-w-0 relative border-l border-border">
@@ -98,7 +99,7 @@ function SplitColumn({
98
99
  <button
99
100
  key={i}
100
101
  type="button"
101
- onClick={() => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid)}
102
+ onClick={(e) => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid, anchorOf(e.currentTarget))}
102
103
  style={{ height: HOUR_HEIGHT_PX / 2 }}
103
104
  className="block w-full border-b border-border/50 text-left"
104
105
  aria-label={`New event at ${format(slotStart, "h:mm a")} in ${column.name}`}