@rapidmx/web-client 0.14.0 → 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 -862
  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 -455
  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 +3 -3
@@ -0,0 +1,70 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode } from "react";
6
+ import { DescriptionNode, htmlToPlainText, parseEventDescription } from "@rapidmx/react-shared/calendar/eventDescription.js";
7
+
8
+ function renderNodes(nodes: DescriptionNode[]): ReactNode[] {
9
+ return nodes.map((node, index) => {
10
+ if ("text" in node) {
11
+ return node.text;
12
+ }
13
+ switch (node.tag) {
14
+ case "br":
15
+ return <br key={index} />;
16
+ case "a":
17
+ return (
18
+ <a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className="text-primary-dark underline break-words">
19
+ {renderNodes(node.children)}
20
+ </a>
21
+ );
22
+ case "ul":
23
+ return (
24
+ <ul key={index} className="list-disc pl-5 my-0.5">
25
+ {renderNodes(node.children)}
26
+ </ul>
27
+ );
28
+ case "ol":
29
+ return (
30
+ <ol key={index} className="list-decimal pl-5 my-0.5">
31
+ {renderNodes(node.children)}
32
+ </ol>
33
+ );
34
+ case "p":
35
+ return (
36
+ <p key={index} className="mb-1 last:mb-0 min-h-[1lh]">
37
+ {renderNodes(node.children)}
38
+ </p>
39
+ );
40
+ case "li":
41
+ return <li key={index}>{renderNodes(node.children)}</li>;
42
+ case "strong":
43
+ return <strong key={index}>{renderNodes(node.children)}</strong>;
44
+ case "em":
45
+ return <em key={index}>{renderNodes(node.children)}</em>;
46
+ case "u":
47
+ return <u key={index}>{renderNodes(node.children)}</u>;
48
+ }
49
+ });
50
+ }
51
+
52
+ export interface EventDescriptionViewProps {
53
+ /** The description as HTML, from the server or an invitation - untrusted, so it is read into allowed elements only (`parseEventDescription()`) and drawn as
54
+ * React elements, never as markup. */
55
+ html?: string | null;
56
+ /** The plain-text description, shown (line breaks kept) when there is no HTML, or when the HTML holds nothing this can draw. */
57
+ text?: string | null;
58
+ className?: string;
59
+ }
60
+
61
+ /**
62
+ * An event's description, read-only: the rich text with its bold, italics, underline, lists and links (which open in a new tab, `noopener noreferrer`),
63
+ * else the plain text. Nothing is drawn for an event with no description.
64
+ */
65
+ export default function EventDescriptionView({ html, text, className }: EventDescriptionViewProps) {
66
+ if (htmlToPlainText(html) !== "") {
67
+ return <div className={["break-words", className].filter(Boolean).join(" ")}>{renderNodes(parseEventDescription(html))}</div>;
68
+ }
69
+ return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{text}</div> : null;
70
+ }
@@ -0,0 +1,401 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ReactNode, useEffect, useState } from "react";
6
+ import {
7
+ HiOutlineBars3BottomLeft,
8
+ HiOutlineBell,
9
+ HiOutlineBriefcase,
10
+ HiOutlineCheck,
11
+ HiOutlineClock,
12
+ HiOutlineMapPin,
13
+ HiOutlineUser,
14
+ HiOutlineUserGroup,
15
+ HiOutlineVideoCamera,
16
+ HiOutlineXMark,
17
+ } from "react-icons/hi2";
18
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
19
+ import { AttendeeResponseInput, guestPermissionsOf, respondToEvent, visibilityOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
20
+ import { htmlToPlainText } from "@rapidmx/react-shared/calendar/eventDescription.js";
21
+ import { deleteEventOccurrence, deleteEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
22
+ import { CalendarOccurrence, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
23
+ import { getVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
24
+ import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
25
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
+ import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
28
+ import EventDescriptionView from "./EventDescriptionView.js";
29
+ import RequestChangeForm from "./RequestChangeForm.js";
30
+ import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
31
+
32
+ const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" } as const;
33
+
34
+ function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
35
+ return (
36
+ <div className="flex items-start gap-3">
37
+ <span aria-hidden="true" className="shrink-0 text-text-muted mt-0.5">
38
+ {icon}
39
+ </span>
40
+ <div className="flex-1 min-w-0 break-words">{children}</div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export interface EventDetailsProps {
46
+ occurrence: CalendarOccurrence;
47
+ /** Whether this mailbox is invited to the event rather than organizing it: only the organizer can change an event, so there is no
48
+ * Modify, and the mailbox may answer instead. */
49
+ isInvited: boolean;
50
+ /** Set only when this mailbox is an invited attendee (one of the attendees, and not the organizer): the answer it has given so far, which
51
+ * also switches on the Accept / Tentative / Decline block. */
52
+ myResponse?: AttendeeResponseInput | "needsAction";
53
+ /** Whether an address is one of this mailbox's own (its primary address or an alias): tells the reader's own entry from the other guests when the organizer
54
+ * has hidden the guest list. */
55
+ isOwnAddress?: (address: string) => boolean;
56
+ calendarName?: string;
57
+ calendarColor?: string;
58
+ onClose: () => void;
59
+ /** Modify: switch to the edit form. */
60
+ onModify: () => void;
61
+ onSaved: () => void;
62
+ onDeleted: () => void;
63
+ }
64
+
65
+ /**
66
+ * What clicking an existing event shows first: everything the event says, read-only. The reader who organized it (or whose event has no
67
+ * other organizer) can Modify or Delete it; one who was invited can answer (Accept, Tentative, Decline) or remove it from their own
68
+ * calendar, but not change it - the organizer's next update would overwrite that anyway. When the organizer allows guests to ask for changes (or for
69
+ * guests to be added) the invited reader can "Request a change" / "Add guests": the request goes to the organizer (`RequestChangeForm`) and nothing here
70
+ * changes until their update arrives. When the organizer hides the guest list the reader sees only themselves and is told so.
71
+ *
72
+ * An event this reader may only see as a busy block (`occurrence.redacted`: a private or confidential event on a shared calendar) is drawn as just that -
73
+ * its time and that it is busy - with nothing to change, delete or answer.
74
+ */
75
+ export default function EventDetails({
76
+ occurrence,
77
+ isInvited,
78
+ myResponse,
79
+ isOwnAddress,
80
+ calendarName,
81
+ calendarColor,
82
+ onClose,
83
+ onModify,
84
+ onSaved,
85
+ onDeleted,
86
+ }: EventDetailsProps) {
87
+ const [error, setError] = useState<string | null>(null);
88
+ const [busy, setBusy] = useState(false);
89
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
90
+ const [requesting, setRequesting] = useState(false);
91
+ // The organizer's own join link for the event's video meeting: `undefined` while it is fetched, `null` once known to be none.
92
+ const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
93
+ const videoMeetingUid = occurrence.videoMeetingUid;
94
+
95
+ // The link lives on the meeting, not on the event, so it is fetched once here. A failure and a meeting with no link at all are the
96
+ // same thing to this view - there is nothing to open either way - so both settle on `null`.
97
+ useEffect(() => {
98
+ if (!videoMeetingUid) {
99
+ return;
100
+ }
101
+ let cancelled = false;
102
+ void getVideoMeeting(videoMeetingUid)
103
+ .then((meeting) => meeting.organizerJoinUrl ?? null)
104
+ .catch(() => null)
105
+ .then((url) => {
106
+ if (!cancelled) {
107
+ setOrganizerJoinUrl(url);
108
+ }
109
+ });
110
+ return () => {
111
+ cancelled = true;
112
+ };
113
+ }, [videoMeetingUid]);
114
+ // The same scheme allow-list a reminder's own location gets: a hostile or malformed join link never reaches `window.open()`.
115
+ const joinUrl = joinMeetingUrl(organizerJoinUrl);
116
+
117
+ async function handleDelete(scope: "occurrence" | "series") {
118
+ setError(null);
119
+ setBusy(true);
120
+ try {
121
+ if (occurrence.isRecurringOccurrence && scope === "occurrence") {
122
+ await deleteEventOccurrence(occurrence);
123
+ } else {
124
+ await deleteEventSeries(occurrence);
125
+ }
126
+ onDeleted();
127
+ } catch (err) {
128
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
129
+ setBusy(false);
130
+ }
131
+ }
132
+
133
+ // `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own (see `calendarApi.ts`'s `respondToEvent` doc
134
+ // comment) - it always acts on `occurrence.uid` as a single event document.
135
+ async function handleRespond(responseStatus: AttendeeResponseInput) {
136
+ setError(null);
137
+ setBusy(true);
138
+ try {
139
+ await respondToEvent(occurrence.uid, responseStatus);
140
+ // A decline soft-deletes the mailbox's own copy server-side - treat it the same as a
141
+ // delete rather than a save so the calendar view drops it immediately.
142
+ if (responseStatus === "declined") {
143
+ onDeleted();
144
+ } else {
145
+ onSaved();
146
+ }
147
+ } catch (err) {
148
+ setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
149
+ setBusy(false);
150
+ }
151
+ }
152
+
153
+ if (occurrence.redacted) {
154
+ return (
155
+ <div className="flex flex-col">
156
+ <div className="flex items-start gap-3 px-5 pt-4">
157
+ <span aria-hidden="true" className="w-3.5 h-3.5 rounded-full shrink-0 mt-2" style={{ backgroundColor: calendarColor }} />
158
+ <h2 className="flex-1 min-w-0 text-xl font-semibold break-words">Busy</h2>
159
+ <button
160
+ type="button"
161
+ aria-label="Close details"
162
+ onClick={onClose}
163
+ className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
164
+ >
165
+ <HiOutlineXMark size={20} aria-hidden="true" />
166
+ </button>
167
+ </div>
168
+ <div className="flex flex-col gap-3 px-5 pt-3 pb-4 text-sm">
169
+ <DetailRow icon={<HiOutlineClock size={18} />}>
170
+ <div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
171
+ {occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
172
+ </DetailRow>
173
+ <p className="text-xs text-text-muted">This time is busy. The event&rsquo;s details are private, so only its time is shown.</p>
174
+ </div>
175
+ <div className="flex px-5 pb-4">
176
+ <Button type="button" variant="secondary" className="!w-auto ml-auto" onClick={onClose}>
177
+ Close
178
+ </Button>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ const permissions = guestPermissionsOf(occurrence);
185
+ // The organizer hid the guest list: this mailbox's own copy lists only itself (the server sends nothing more), and that is all that is shown.
186
+ const hiddenList = isInvited && !permissions.guestsCanSeeGuestList;
187
+ const allGuests = occurrence.attendees.filter((a) => !a.isOrganizer);
188
+ const guests = hiddenList ? allGuests.filter((a) => !!isOwnAddress?.(a.address)) : allGuests;
189
+ const hasDescription = htmlToPlainText(occurrence.descriptionHtml) !== "" || !!occurrence.description?.trim();
190
+ // An invited reader may ask the organizer for what the organizer allows.
191
+ const askable = isInvited && myResponse !== undefined;
192
+ const canChange = askable && permissions.guestsCanModify;
193
+ const canInvite = askable && permissions.guestsCanInviteOthers;
194
+ const organizerName = occurrence.organizer.displayName;
195
+ const eventZone = occurrence.timezone;
196
+ const answers: { response: AttendeeResponseInput; label: string }[] = [
197
+ { response: "accepted", label: "Accept" },
198
+ { response: "tentative", label: "Tentative" },
199
+ { response: "declined", label: "Decline" },
200
+ ];
201
+
202
+ return (
203
+ <div className="flex flex-col">
204
+ <div className="flex items-start gap-3 px-5 pt-4">
205
+ <span aria-hidden="true" className="w-3.5 h-3.5 rounded-full shrink-0 mt-2" style={{ backgroundColor: calendarColor }} />
206
+ <h2 className="flex-1 min-w-0 text-xl font-semibold break-words">{occurrence.title || "(no title)"}</h2>
207
+ <button
208
+ type="button"
209
+ aria-label="Close details"
210
+ onClick={onClose}
211
+ className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
212
+ >
213
+ <HiOutlineXMark size={20} aria-hidden="true" />
214
+ </button>
215
+ </div>
216
+
217
+ <div className="flex flex-col gap-3 px-5 pt-3 pb-4 text-sm">
218
+ {error && <Alert>{error}</Alert>}
219
+
220
+ <DetailRow icon={<HiOutlineClock size={18} />}>
221
+ <div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
222
+ {occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
223
+ {!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() && (
224
+ <div className="text-text-muted">Event time zone: {eventZone.replace(/_/g, " ")}</div>
225
+ )}
226
+ </DetailRow>
227
+
228
+ {occurrence.location && (
229
+ <DetailRow icon={<HiOutlineMapPin size={18} />}>
230
+ <div>{occurrence.location}</div>
231
+ </DetailRow>
232
+ )}
233
+
234
+ {hasDescription && (
235
+ <DetailRow icon={<HiOutlineBars3BottomLeft size={18} />}>
236
+ <EventDescriptionView html={occurrence.descriptionHtml} text={occurrence.description} />
237
+ </DetailRow>
238
+ )}
239
+
240
+ {videoMeetingUid && (
241
+ <DetailRow icon={<HiOutlineVideoCamera size={18} />}>
242
+ <div className="flex items-center gap-3 flex-wrap">
243
+ <Button
244
+ type="button"
245
+ variant="secondary"
246
+ className="!w-auto"
247
+ disabled={!joinUrl}
248
+ onClick={() => window.open(joinUrl, "_blank", "noopener,noreferrer")}
249
+ >
250
+ Join video call
251
+ </Button>
252
+ {!joinUrl && (
253
+ <span className="text-xs text-text-muted">
254
+ {organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
255
+ </span>
256
+ )}
257
+ </div>
258
+ </DetailRow>
259
+ )}
260
+
261
+ <DetailRow icon={<HiOutlineUser size={18} />}>
262
+ <div>{organizerName || occurrence.organizer.address}</div>
263
+ <div className="text-text-muted">
264
+ {organizerName ? `${occurrence.organizer.address} · ` : ""}Organizer
265
+ </div>
266
+ </DetailRow>
267
+
268
+ {(guests.length > 0 || hiddenList) && (
269
+ <DetailRow icon={<HiOutlineUserGroup size={18} />}>
270
+ <div className="font-medium mb-1">
271
+ {hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests`}
272
+ </div>
273
+ <ul className="flex flex-col gap-1">
274
+ {guests.map((guest, i) => (
275
+ <li key={i} className="flex items-baseline justify-between gap-3">
276
+ <span className="min-w-0 truncate">
277
+ {guest.displayName || guest.address}
278
+ {ROLE_LABEL[guest.role] && <span className="text-text-muted"> ({ROLE_LABEL[guest.role]})</span>}
279
+ </span>
280
+ <span className="shrink-0 text-xs text-text-muted">{RESPONSE_STATUS_LABEL[guest.responseStatus]}</span>
281
+ </li>
282
+ ))}
283
+ </ul>
284
+ {!isInvited && <div className="text-xs text-text-muted mt-1">{describeGuestPermissions(permissions)}</div>}
285
+ </DetailRow>
286
+ )}
287
+
288
+ <DetailRow icon={<HiOutlineBriefcase size={18} />}>
289
+ <div>{BUSY_STATUS_LABEL[occurrence.busyStatus]}</div>
290
+ <div className="text-text-muted">{VISIBILITY_LABEL[visibilityOf(occurrence)]}</div>
291
+ {occurrence.autoReplyEnabled && (
292
+ <div className="text-text-muted">Sends an automatic reply while this event is happening</div>
293
+ )}
294
+ </DetailRow>
295
+
296
+ <DetailRow icon={<HiOutlineBell size={18} />}>
297
+ <div>{describeReminder(occurrence.reminderMinutesBeforeStart)}</div>
298
+ </DetailRow>
299
+
300
+ {calendarName && (
301
+ <div className="flex items-center gap-3 text-text-muted">
302
+ <span aria-hidden="true" className="w-3 h-3 ml-[3px] rounded-full shrink-0" style={{ backgroundColor: calendarColor }} />
303
+ <span className="truncate">{calendarName}</span>
304
+ </div>
305
+ )}
306
+
307
+ {isInvited && (
308
+ <p className="text-xs text-text-muted">
309
+ You were invited to this event. Only the organizer can change its details
310
+ {canChange || canInvite ? ", but you can ask them to." : "."}
311
+ </p>
312
+ )}
313
+ </div>
314
+
315
+ {requesting && (
316
+ <div className="mx-5 mb-3 p-3 rounded-md bg-surface-alt">
317
+ <RequestChangeForm
318
+ subject={{
319
+ uid: occurrence.uid,
320
+ title: occurrence.title,
321
+ location: occurrence.location,
322
+ startDate: occurrence.startDate,
323
+ endDate: occurrence.endDate,
324
+ allDay: occurrence.allDay,
325
+ recurring: occurrence.isRecurringOccurrence,
326
+ description: occurrence.description,
327
+ descriptionHtml: occurrence.descriptionHtml,
328
+ guestAddresses: occurrence.attendees.map((a) => a.address),
329
+ }}
330
+ canChange={canChange}
331
+ canInvite={canInvite}
332
+ onSent={() => setRequesting(false)}
333
+ onCancel={() => setRequesting(false)}
334
+ />
335
+ </div>
336
+ )}
337
+
338
+ {myResponse !== undefined && (
339
+ <div className="flex flex-col gap-2 mx-5 mb-3 p-3 rounded-md bg-surface-alt">
340
+ <div className="flex items-center justify-between gap-3 flex-wrap">
341
+ <span className="text-sm font-medium">Your response</span>
342
+ <div className="flex gap-2">
343
+ {answers.map(({ response, label }) => {
344
+ const current = myResponse === response;
345
+ return (
346
+ <Button
347
+ key={response}
348
+ type="button"
349
+ variant="secondary"
350
+ className={["!w-auto", response === "declined" ? "text-danger" : "", current ? "!bg-primary/10 !border-primary" : ""].join(" ")}
351
+ aria-pressed={current}
352
+ disabled={busy}
353
+ onClick={() => void handleRespond(response)}
354
+ >
355
+ {current && <HiOutlineCheck size={14} aria-hidden="true" />}
356
+ {label}
357
+ </Button>
358
+ );
359
+ })}
360
+ </div>
361
+ </div>
362
+ <p className="text-xs text-text-muted">Other attendees&rsquo; responses may take a few minutes to update.</p>
363
+ </div>
364
+ )}
365
+
366
+ <div className="flex flex-wrap items-center gap-3 px-5 pb-4">
367
+ {!isInvited && (
368
+ <Button type="button" className="!w-auto" disabled={busy} onClick={onModify}>
369
+ Modify
370
+ </Button>
371
+ )}
372
+ {(canChange || canInvite) && !requesting && (
373
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={() => setRequesting(true)}>
374
+ {canChange ? "Request a change" : "Add guests"}
375
+ </Button>
376
+ )}
377
+ {!occurrence.isRecurringOccurrence ? (
378
+ <Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("series")}>
379
+ Delete
380
+ </Button>
381
+ ) : !confirmingDelete ? (
382
+ <Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => setConfirmingDelete(true)}>
383
+ Delete
384
+ </Button>
385
+ ) : (
386
+ <div className="flex gap-2">
387
+ <Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("occurrence")}>
388
+ Delete this event
389
+ </Button>
390
+ <Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("series")}>
391
+ Delete series
392
+ </Button>
393
+ </div>
394
+ )}
395
+ <Button type="button" variant="secondary" className="!w-auto ml-auto" onClick={onClose}>
396
+ Close
397
+ </Button>
398
+ </div>
399
+ </div>
400
+ );
401
+ }