@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
@@ -197,7 +197,8 @@ export default function PluginsManager({ embedded = false } = {}) {
197
197
  watchRollout();
198
198
  void refreshUpdates();
199
199
  }
200
- /** Runs an action on an installed plugin's row, which is busy meanwhile, and shows why it failed. */
200
+ /** Runs an action on a plugin's row (an installed plugin's, or an uninstalled one's), which is busy meanwhile, and
201
+ * shows why it failed. */
201
202
  async function onRow(plugin, action) {
202
203
  setBusy(plugin.uid, true);
203
204
  setError(null);
@@ -278,6 +279,20 @@ export default function PluginsManager({ embedded = false } = {}) {
278
279
  function upgrade(plugin, packageVersion) {
279
280
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
280
281
  }
282
+ /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
283
+ function reinstall(purge) {
284
+ const displayName = purgeName(purge);
285
+ return onRow(purge, async () => {
286
+ let latest;
287
+ try {
288
+ latest = (await planPluginChange(purge.name)).plugin.version;
289
+ }
290
+ catch (err) {
291
+ return errorMessage(err, `Could not install ${displayName}.`);
292
+ }
293
+ return install(purge.name, latest, displayName);
294
+ });
295
+ }
281
296
  /** Runs the steps of a failed data deletion that failed, again. */
282
297
  async function retryPurge(purge) {
283
298
  setBusy(purge.uid, true);
@@ -308,7 +323,7 @@ export default function PluginsManager({ embedded = false } = {}) {
308
323
  .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
309
324
  .map((other) => other.manifest.displayName);
310
325
  return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
311
- }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge) }, purge.uid)))] })] }) })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, onInstall: (result) => install(result.name, result.version, result.name),
326
+ }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] }) })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, onInstall: (result) => install(result.name, result.version, result.name),
312
327
  // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
313
328
  onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
314
329
  const problem = await install(name, packageVersion, displayName);
@@ -441,9 +456,9 @@ function PluginServers({ plugin, status }) {
441
456
  return (_jsxs("div", { className: "text-xs", children: [_jsxs("span", { className: loaded === status.instances.length ? "text-success font-medium" : "text-text-muted", children: ["Loaded on ", loaded, " of ", status.instances.length, " ", status.instances.length === 1 ? "server" : "servers"] }), errors.map((message) => (_jsx("div", { className: "text-danger mt-1 break-words", children: message }, message)))] }));
442
457
  }
443
458
  /** The row of a plugin that was uninstalled but whose data deletion is still listed: what became of its data. */
444
- function UninstalledPluginRow({ purge, busy, onRetry }) {
459
+ function UninstalledPluginRow({ purge, busy, onRetry, onInstall }) {
445
460
  const failedSteps = purge.steps.filter((step) => !step.ok);
446
- return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: [_jsx("div", { className: "font-semibold", children: purgeName(purge) }), _jsx("div", { className: "text-xs text-text-muted", children: purge.name })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} hidden lg:table-cell` }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-3 basis-full lg:basis-auto`, children: _jsxs("div", { className: "flex flex-col items-start gap-1 text-xs max-w-sm", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uninstalled" }), purge.state === "pending" && (_jsxs(_Fragment, { children: [_jsx("span", { children: "Uninstalled - data will be deleted after servers restart" }), !!purge.serversRunning && (_jsxs("span", { className: "text-text-muted", children: [purge.serversRunning, " of ", purge.serversTotal, " ", purge.serversTotal === 1 ? "server" : "servers", " still running it"] }))] })), purge.state === "running" && _jsx("span", { children: "Uninstalled - deleting its data now" }), purge.state === "done" && _jsxs("span", { className: "text-success font-medium", children: ["Data deleted", purge.completedAt ? ` ${formatDate(purge.completedAt)}` : ""] }), purge.state === "failed" && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-danger break-words", children: ["Data deletion failed: ", purgeFailure(purge)] }), failedSteps.length > 0 && (_jsx("ul", { className: "list-disc pl-4 text-text-muted break-words", children: failedSteps.map((step) => (_jsxs("li", { children: [step.step, ": ", step.error ?? "failed"] }, step.step))) }))] }))] }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-1 pb-3`, children: purge.state === "failed" && (_jsx("div", { className: "flex lg:justify-end", children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Retry deleting the data of ${purgeName(purge)}`, disabled: busy, loading: busy, onClick: onRetry, children: "Retry" }) })) })] }));
461
+ return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: [_jsx("div", { className: "font-semibold", children: purgeName(purge) }), _jsx("div", { className: "text-xs text-text-muted", children: purge.name })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} hidden lg:table-cell` }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-3 basis-full lg:basis-auto`, children: _jsxs("div", { className: "flex flex-col items-start gap-1 text-xs max-w-sm", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uninstalled" }), purge.state === "pending" && (_jsxs(_Fragment, { children: [_jsx("span", { children: "Uninstalled - data will be deleted after servers restart" }), !!purge.serversRunning && (_jsxs("span", { className: "text-text-muted", children: [purge.serversRunning, " of ", purge.serversTotal, " ", purge.serversTotal === 1 ? "server" : "servers", " still running it"] }))] })), purge.state === "running" && _jsx("span", { children: "Uninstalled - deleting its data now" }), purge.state === "done" && _jsxs("span", { className: "text-success font-medium", children: ["Data deleted", purge.completedAt ? ` ${formatDate(purge.completedAt)}` : ""] }), purge.state === "failed" && (_jsxs(_Fragment, { children: [_jsxs("span", { className: "text-danger break-words", children: ["Data deletion failed: ", purgeFailure(purge)] }), failedSteps.length > 0 && (_jsx("ul", { className: "list-disc pl-4 text-text-muted break-words", children: failedSteps.map((step) => (_jsxs("li", { children: [step.step, ": ", step.error ?? "failed"] }, step.step))) }))] }))] }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-1 pb-3`, children: _jsxs("div", { className: "flex gap-2 lg:justify-end", children: [purge.state === "failed" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Retry deleting the data of ${purgeName(purge)}`, disabled: busy, loading: busy, onClick: onRetry, children: "Retry" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Install ${purgeName(purge)}`, disabled: busy || purge.state === "running", onClick: onInstall, children: "Install" })] }) })] }));
447
462
  }
448
463
  function AddPluginModal({ open, onClose, onAdd, }) {
449
464
  const [name, setName] = useState("");
@@ -587,12 +602,30 @@ function needsSelection(definition, saved) {
587
602
  definition.default === undefined &&
588
603
  (definition.options ?? []).length > 0);
589
604
  }
605
+ /** What a plugin's setting default writes for the host this console was reached at, like `https://<host>/meet`. */
606
+ const HOST_PLACEHOLDER = "<host>";
607
+ /**
608
+ * The value offered for a text setting whose default names the host (`https://<host>/meet`) and that has no real value
609
+ * saved yet - nothing, an empty value, or the placeholder itself, which an older server stores when it installs the
610
+ * plugin. It's the default with this console's host filled in, and is only stored once the form is saved.
611
+ */
612
+ function suggestedHost(definition, saved) {
613
+ if (typeof definition.default !== "string" || !definition.default.includes(HOST_PLACEHOLDER) || typeof window === "undefined") {
614
+ return undefined;
615
+ }
616
+ const unset = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
617
+ return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
618
+ }
590
619
  /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
591
620
  * first option. Kept as a string for text inputs. */
592
621
  function initialValue(definition, saved) {
593
622
  if (needsSelection(definition, saved)) {
594
623
  return definition.options[0].value;
595
624
  }
625
+ const suggestion = suggestedHost(definition, saved);
626
+ if (suggestion !== undefined) {
627
+ return suggestion;
628
+ }
596
629
  const value = saved ?? definition.default;
597
630
  if (definition.type === "boolean") {
598
631
  return value === true;
@@ -610,13 +643,14 @@ function SettingsModal({ plugin, onClose, onSaved }) {
610
643
  e.preventDefault();
611
644
  // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
612
645
  // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
613
- // A required select with nothing to fall back on is sent as the form picked it.
646
+ // A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
647
+ // form shows them.
614
648
  const settings = {};
615
649
  let changed = false;
616
650
  for (const definition of definitions) {
617
651
  const key = definition.key;
618
652
  const value = values[key];
619
- if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key])) {
653
+ if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key]) && suggestedHost(definition, plugin.settings[key]) === undefined) {
620
654
  settings[key] = plugin.settings[key] ?? null;
621
655
  continue;
622
656
  }
@@ -0,0 +1,47 @@
1
+ import React, { ReactNode } from "react";
2
+ import { EventFormController } from "./eventForm.js";
3
+ /** What the Appointment schedule tab holds besides the title (which is the Event tab's): kept by `QuickCreateFaces`, so it survives a trip to another tab. */
4
+ export interface AppointmentDraft {
5
+ /** One of `DURATIONS`, or `"custom"` to use `customMinutes`. */
6
+ duration: string;
7
+ customMinutes: string;
8
+ /** Whether each weekday (0 = Sunday) is bookable. */
9
+ days: boolean[];
10
+ /** `HH:mm`, the hours every bookable day is open. */
11
+ startTime: string;
12
+ endTime: string;
13
+ /** Minutes kept free after each appointment. */
14
+ buffer: string;
15
+ video: boolean;
16
+ phone: boolean;
17
+ other: boolean;
18
+ /** Set once the schedule is saved: the face then shows its link. */
19
+ created: {
20
+ name: string;
21
+ url: string;
22
+ uid: string;
23
+ mailboxUid: string;
24
+ } | null;
25
+ }
26
+ /** A new schedule: 30 minutes, Monday to Friday 9:00 to 17:00, a video call, no buffer. */
27
+ export declare function initialAppointmentDraft(): AppointmentDraft;
28
+ export interface AppointmentQuickFormProps {
29
+ c: EventFormController;
30
+ draft: AppointmentDraft;
31
+ onDraftChange: (patch: Partial<AppointmentDraft>) => void;
32
+ tabs: ReactNode;
33
+ /** The booking plugin's Settings path (`/settings/booking-types`), where More options goes. */
34
+ settingsHref: string;
35
+ /** The name booking pages show for the host. */
36
+ hostName: string;
37
+ /** The IANA zone the open hours are in. */
38
+ timeZone: string;
39
+ }
40
+ /**
41
+ * The Appointment schedule tab of the New event popover: publishes a bookable schedule (a booking type of `@rapidmx/booking-plugin`) with
42
+ * `POST /api/mail/booking-types` - a name (the title), a duration, the weekdays and hours people can book, the buffer after each
43
+ * appointment, how they may meet (video, phone, other) and which calendar takes the bookings - and then shows the public booking link with a
44
+ * Copy button. Everything else about a booking link (approval, notice, a window, per-day hours, date overrides, several meeting types, a
45
+ * description, the page's avatar and banner) is on the plugin's own page, which More options opens.
46
+ */
47
+ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, settingsHref, hostName, timeZone }: AppointmentQuickFormProps): React.JSX.Element;
@@ -0,0 +1,135 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { HiOutlineArrowTopRightOnSquare, HiOutlineCalendar, HiOutlineCalendarDays, HiOutlineClock, HiOutlineLink, HiOutlineMapPin } from "react-icons/hi2";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
10
+ import CopyButton from "@rapidmx/react-shared/components/buttons/CopyButton.js";
11
+ import { bookingPublicUrl, createBookingType, newBookingLinkHref, slugFor, } from "../../calendar/bookingPlugin.js";
12
+ import { CalendarField, INPUT_CLASS, IconRow, SELECT_CLASS } from "./EventFormParts.js";
13
+ import QuickFaceFrame from "./QuickFaceFrame.js";
14
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
15
+ const DURATIONS = ["15", "30", "45", "60"];
16
+ const BUFFERS = ["0", "5", "10", "15", "30"];
17
+ /** How often the plugin lets a slot repeat and how long one may be (`MIN_SLOT_MINUTES` to a day), in minutes. */
18
+ const MIN_DURATION = 5;
19
+ const MAX_DURATION = 1440;
20
+ /** How many times a taken link name is retried with a number after it (`intro`, `intro-2`, ...). */
21
+ const MAX_SLUG_ATTEMPTS = 5;
22
+ const LOCATIONS = [
23
+ { key: "video", type: "video", label: "Video call" },
24
+ { key: "phone", type: "phone", label: "Phone call" },
25
+ { key: "other", type: "other", label: "Other" },
26
+ ];
27
+ /** A new schedule: 30 minutes, Monday to Friday 9:00 to 17:00, a video call, no buffer. */
28
+ export function initialAppointmentDraft() {
29
+ return {
30
+ duration: "30",
31
+ customMinutes: "",
32
+ days: [false, true, true, true, true, true, false],
33
+ startTime: "09:00",
34
+ endTime: "17:00",
35
+ buffer: "0",
36
+ video: true,
37
+ phone: false,
38
+ other: false,
39
+ created: null,
40
+ };
41
+ }
42
+ /** Creates the booking type, trying the next number after `base` (`intro`, `intro-2`, ...) while its link name is taken (a 409). */
43
+ async function createWithFreeSlug(base, input) {
44
+ for (let attempt = 1;; attempt++) {
45
+ try {
46
+ return await createBookingType({ ...input, slug: attempt === 1 ? base : `${base}-${attempt}` });
47
+ }
48
+ catch (err) {
49
+ if (!(err instanceof ApiRequestError && err.status === 409 && attempt < MAX_SLUG_ATTEMPTS)) {
50
+ throw err;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ function minutesOf(time) {
56
+ const [hours, minutes] = time.split(":").map(Number);
57
+ return hours * 60 + minutes;
58
+ }
59
+ /**
60
+ * The Appointment schedule tab of the New event popover: publishes a bookable schedule (a booking type of `@rapidmx/booking-plugin`) with
61
+ * `POST /api/mail/booking-types` - a name (the title), a duration, the weekdays and hours people can book, the buffer after each
62
+ * appointment, how they may meet (video, phone, other) and which calendar takes the bookings - and then shows the public booking link with a
63
+ * Copy button. Everything else about a booking link (approval, notice, a window, per-day hours, date overrides, several meeting types, a
64
+ * description, the page's avatar and banner) is on the plugin's own page, which More options opens.
65
+ */
66
+ export default function AppointmentQuickForm({ c, draft, onDraftChange, tabs, settingsHref, hostName, timeZone }) {
67
+ const { values } = c;
68
+ const [error, setError] = useState(null);
69
+ const [saving, setSaving] = useState(false);
70
+ async function handleSubmit(event) {
71
+ event.preventDefault();
72
+ if (draft.created) {
73
+ c.onCancel();
74
+ return;
75
+ }
76
+ setError(null);
77
+ const name = values.title.trim();
78
+ const durationMinutes = Number(draft.duration === "custom" ? draft.customMinutes : draft.duration);
79
+ const startMinute = minutesOf(draft.startTime);
80
+ const endMinute = minutesOf(draft.endTime);
81
+ const locationOptions = LOCATIONS.filter((location) => draft[location.key]).map((location) => ({ type: location.type }));
82
+ let problem;
83
+ if (!name) {
84
+ problem = "A title is required.";
85
+ }
86
+ else if (!Number.isInteger(durationMinutes) || durationMinutes < MIN_DURATION || durationMinutes > MAX_DURATION) {
87
+ problem = `A duration is a whole number of minutes from ${MIN_DURATION} to ${MAX_DURATION}.`;
88
+ }
89
+ else if (!draft.days.some(Boolean)) {
90
+ problem = "Choose at least one day people can book.";
91
+ }
92
+ else if (!(startMinute < endMinute)) {
93
+ problem = "The first bookable time must be before the last.";
94
+ }
95
+ else if (endMinute - startMinute < durationMinutes) {
96
+ problem = "The hours people can book are shorter than one appointment.";
97
+ }
98
+ else if (locationOptions.length === 0) {
99
+ problem = "Choose at least one way to meet.";
100
+ }
101
+ if (problem) {
102
+ setError(problem);
103
+ return;
104
+ }
105
+ setSaving(true);
106
+ try {
107
+ const base = slugFor(name);
108
+ const availability = draft.days.flatMap((open, dayOfWeek) => (open ? [{ dayOfWeek, startMinute, endMinute }] : []));
109
+ const created = await createWithFreeSlug(base, {
110
+ mailboxUid: values.targetMailboxUid,
111
+ calendarFolderUid: values.targetFolderUid,
112
+ name,
113
+ hostDisplayName: hostName,
114
+ meetingTypes: [{ name, durationMinutes, locationOptions }],
115
+ timezone: timeZone,
116
+ availability,
117
+ bufferAfterMinutes: Number(draft.buffer),
118
+ });
119
+ onDraftChange({
120
+ created: { name: created.name, url: bookingPublicUrl(created.mailboxUid, created.slug), uid: created.uid, mailboxUid: created.mailboxUid },
121
+ });
122
+ }
123
+ catch (err) {
124
+ setError(err instanceof ApiRequestError ? err.message : "Could not create this appointment schedule.");
125
+ }
126
+ finally {
127
+ setSaving(false);
128
+ }
129
+ }
130
+ const { created } = draft;
131
+ return (_jsx(QuickFaceFrame, { c: c, tabs: tabs, tab: "appointment", error: error, hideTitle: !!created, onSubmit: (event) => void handleSubmit(event), footer: created ? (_jsx(Button, { type: "submit", className: "!w-auto", children: "Done" })) : (_jsxs(_Fragment, { children: [_jsxs("a", { href: newBookingLinkHref(settingsHref, values.targetMailboxUid), className: "inline-flex items-center gap-1 text-sm font-semibold text-accent-dark hover:underline px-1 py-1", children: ["More options", _jsx(HiOutlineArrowTopRightOnSquare, { size: 14, "aria-hidden": "true" })] }), _jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" })] })), children: created ? (_jsxs(IconRow, { icon: _jsx(HiOutlineLink, { size: 20 }), children: [_jsxs("p", { role: "status", className: "text-sm font-medium mb-1", children: ["\u201C", created.name, "\u201D is ready to book."] }), _jsx("p", { className: "text-xs text-text-muted mb-2", children: "Share this link and anyone can pick an open time - no account needed." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", readOnly: true, "aria-label": "Booking link", className: `${INPUT_CLASS} min-w-0`, value: created.url, onFocus: (e) => e.target.select() }), _jsx(CopyButton, { value: created.url, label: "Copy the booking link" })] }), _jsx("a", { href: `${settingsHref}/${encodeURIComponent(created.uid)}?mailboxUid=${encodeURIComponent(created.mailboxUid)}`, className: "inline-block text-xs text-primary-dark hover:underline mt-2", children: "Change its settings" })] })) : (_jsxs(_Fragment, { children: [_jsx(IconRow, { icon: _jsx(HiOutlineClock, { size: 20 }), children: _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsxs("select", { "aria-label": "Duration", className: SELECT_CLASS, value: draft.duration, onChange: (e) => onDraftChange({ duration: e.target.value }), children: [DURATIONS.map((minutes) => (_jsxs("option", { value: minutes, children: [minutes, " minutes"] }, minutes))), _jsx("option", { value: "custom", children: "Custom" })] }), draft.duration === "custom" && (_jsxs(_Fragment, { children: [_jsx("input", { type: "number", min: MIN_DURATION, max: MAX_DURATION, "aria-label": "Custom duration in minutes", className: `${INPUT_CLASS} !w-24`, value: draft.customMinutes, onChange: (e) => onDraftChange({ customMinutes: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "minutes" })] })), _jsx("span", { className: "text-text-muted", children: "with" }), _jsx("select", { "aria-label": "Buffer after each appointment", className: SELECT_CLASS, value: draft.buffer, onChange: (e) => onDraftChange({ buffer: e.target.value }), children: BUFFERS.map((minutes) => (_jsx("option", { value: minutes, children: minutes === "0" ? "no buffer" : `${minutes} min buffer after` }, minutes))) })] }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendarDays, { size: 20 }), children: [_jsx("div", { role: "group", "aria-label": "Days people can book", className: "flex gap-1 mb-2", children: DAY_NAMES.map((day, index) => (_jsx("button", { type: "button", "aria-label": day, "aria-pressed": draft.days[index], title: day, onClick: () => onDraftChange({ days: draft.days.map((open, i) => (i === index ? !open : open)) }), className: [
132
+ "w-8 h-8 rounded-full text-xs font-medium border",
133
+ draft.days[index] ? "bg-primary text-white border-primary" : "border-border text-text-muted hover:bg-surface-alt",
134
+ ].join(" "), children: day[0] }, day))) }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("input", { type: "time", "aria-label": "Bookable from", className: `${SELECT_CLASS} min-w-0`, value: draft.startTime, onChange: (e) => onDraftChange({ startTime: e.target.value }) }), _jsx("span", { className: "text-text-muted", children: "to" }), _jsx("input", { type: "time", "aria-label": "Bookable until", className: `${SELECT_CLASS} min-w-0`, value: draft.endTime, onChange: (e) => onDraftChange({ endTime: e.target.value }) })] }), _jsxs("p", { className: "text-xs text-text-muted mt-1", children: ["Times are in ", timeZone.replace(/_/g, " "), "."] })] }), _jsx(IconRow, { icon: _jsx(HiOutlineMapPin, { size: 20 }), children: _jsx("div", { role: "group", "aria-label": "How people can meet", className: "flex flex-wrap gap-x-4 gap-y-1 text-sm py-1", children: LOCATIONS.map((location) => (_jsxs("label", { className: "flex items-center gap-2", children: [_jsx("input", { type: "checkbox", checked: draft[location.key], onChange: (e) => onDraftChange({ [location.key]: e.target.checked }) }), location.label] }, location.key))) }) }), _jsxs(IconRow, { icon: _jsx(HiOutlineCalendar, { size: 20 }), children: [_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Bookings are added to" }), _jsx(CalendarField, { c: c })] })] })) }));
135
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ export interface DescriptionEditorProps {
3
+ /** The description to start with, as HTML. Read once, when the editor is created: TipTap owns the document from then on. */
4
+ value: string;
5
+ /** Called with the document as HTML (`<p></p>` for an empty one) each time it changes. */
6
+ onChange: (html: string) => void;
7
+ /** Focus the text when the editor appears (the "Add description" row that opened it). */
8
+ autoFocus?: boolean;
9
+ /** The accessible name of the text box. */
10
+ label?: string;
11
+ }
12
+ /**
13
+ * What a typed link address is: an `http`, `https` or `mailto` link, with `https://` put in front of an address that names no scheme ("example.com") and
14
+ * `mailto:` in front of a plain email address. `undefined` for anything else (`javascript:`, `data:`, ...).
15
+ */
16
+ export declare function linkFromInput(input: string): string | undefined;
17
+ /**
18
+ * The rich-text box of an event's description, as Google Calendar draws it: a toolbar (bold, italic, underline, numbered list, bulleted list, link, remove
19
+ * formatting) over the text. Built on TipTap with only the schema an event description may hold - paragraphs, line breaks, bold, italic, underline, lists and
20
+ * `http`/`https`/`mailto` links, so the HTML it writes has nothing the server would remove. Ctrl/Cmd+B, I and U format, Ctrl/Cmd+K asks for a link, and a
21
+ * paste keeps the text and drops the formatting.
22
+ *
23
+ * The toolbar is a WAI-ARIA toolbar: one tab stop, the arrow keys, Home and End move along it, and each toggle says whether it is on (`aria-pressed`). The
24
+ * buttons do not take focus from the text, so a selection survives pressing one.
25
+ */
26
+ export default function DescriptionEditor({ value, onChange, autoFocus, label }: DescriptionEditorProps): React.JSX.Element;
@@ -0,0 +1,192 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useRef, useState } from "react";
7
+ import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
8
+ import StarterKit from "@tiptap/starter-kit";
9
+ import Placeholder from "@tiptap/extension-placeholder";
10
+ import { BsEraser, BsLink45Deg, BsListOl, BsListUl, BsTypeBold, BsTypeItalic, BsTypeUnderline } from "react-icons/bs";
11
+ import { safeDescriptionHref } from "@rapidmx/react-shared/calendar/eventDescription.js";
12
+ /**
13
+ * The plain text on a clipboard as paragraphs, one for each line - what `handlePaste` inserts instead of the pasted formatting. Empty lines are kept as
14
+ * empty paragraphs, so a pasted block keeps its shape.
15
+ */
16
+ function paragraphsOf(text) {
17
+ return text.split(/\r\n|\r|\n/).map((line) => ({ type: "paragraph", content: line ? [{ type: "text", text: line }] : [] }));
18
+ }
19
+ /**
20
+ * What a typed link address is: an `http`, `https` or `mailto` link, with `https://` put in front of an address that names no scheme ("example.com") and
21
+ * `mailto:` in front of a plain email address. `undefined` for anything else (`javascript:`, `data:`, ...).
22
+ */
23
+ export function linkFromInput(input) {
24
+ const text = input.trim();
25
+ if (/\s/.test(text)) {
26
+ return undefined;
27
+ }
28
+ if (/^[a-z][a-z0-9+.-]*:/i.test(text)) {
29
+ return safeDescriptionHref(text);
30
+ }
31
+ return safeDescriptionHref(/^[^\s@/]+@[^\s@/]+$/.test(text) ? `mailto:${text}` : `https://${text}`);
32
+ }
33
+ const NO_MARKS = { bold: false, italic: false, underline: false, bulletList: false, orderedList: false, link: false };
34
+ /**
35
+ * The rich-text box of an event's description, as Google Calendar draws it: a toolbar (bold, italic, underline, numbered list, bulleted list, link, remove
36
+ * formatting) over the text. Built on TipTap with only the schema an event description may hold - paragraphs, line breaks, bold, italic, underline, lists and
37
+ * `http`/`https`/`mailto` links, so the HTML it writes has nothing the server would remove. Ctrl/Cmd+B, I and U format, Ctrl/Cmd+K asks for a link, and a
38
+ * paste keeps the text and drops the formatting.
39
+ *
40
+ * The toolbar is a WAI-ARIA toolbar: one tab stop, the arrow keys, Home and End move along it, and each toggle says whether it is on (`aria-pressed`). The
41
+ * buttons do not take focus from the text, so a selection survives pressing one.
42
+ */
43
+ export default function DescriptionEditor({ value, onChange, autoFocus, label = "Description" }) {
44
+ const onChangeRef = useRef(onChange);
45
+ onChangeRef.current = onChange;
46
+ const openLinkRef = useRef(null);
47
+ // The paste handler is created with the editor, before the editor exists.
48
+ const editorRef = useRef(null);
49
+ const editor = useEditor({
50
+ extensions: [
51
+ StarterKit.configure({
52
+ heading: false,
53
+ blockquote: false,
54
+ code: false,
55
+ codeBlock: false,
56
+ horizontalRule: false,
57
+ strike: false,
58
+ dropcursor: false,
59
+ gapcursor: false,
60
+ trailingNode: false,
61
+ link: {
62
+ openOnClick: false,
63
+ autolink: false,
64
+ linkOnPaste: false,
65
+ protocols: ["http", "https", "mailto"],
66
+ HTMLAttributes: { rel: "noopener noreferrer", target: "_blank", class: "text-primary-dark underline" },
67
+ },
68
+ }),
69
+ Placeholder.configure({ placeholder: "Add description" }),
70
+ ],
71
+ content: value,
72
+ autofocus: autoFocus ? "end" : false,
73
+ immediatelyRender: false,
74
+ editorProps: {
75
+ attributes: { role: "textbox", "aria-multiline": "true", "aria-label": label },
76
+ handlePaste: (_view, event) => {
77
+ // A paste that came without a clipboard (ProseMirror then reads it back through a hidden field) has no text to give.
78
+ const text = event.clipboardData?.getData("text/plain") ?? "";
79
+ if (!text) {
80
+ return false;
81
+ }
82
+ event.preventDefault();
83
+ editorRef.current.chain().focus().insertContent(paragraphsOf(text)).run();
84
+ return true;
85
+ },
86
+ handleKeyDown: (_view, event) => {
87
+ if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "k") {
88
+ event.preventDefault();
89
+ openLinkRef.current();
90
+ return true;
91
+ }
92
+ return false;
93
+ },
94
+ },
95
+ onUpdate: ({ editor: updated }) => onChangeRef.current(updated.getHTML()),
96
+ });
97
+ editorRef.current = editor;
98
+ // Which formats the caret is in (re-read on every transaction). There is no editor until it has mounted, and then nothing is on.
99
+ const marks = useEditorState({
100
+ editor,
101
+ selector: ({ editor: current }) => current
102
+ ? {
103
+ bold: current.isActive("bold"),
104
+ italic: current.isActive("italic"),
105
+ underline: current.isActive("underline"),
106
+ bulletList: current.isActive("bulletList"),
107
+ orderedList: current.isActive("orderedList"),
108
+ link: current.isActive("link"),
109
+ }
110
+ : NO_MARKS,
111
+ });
112
+ const [linkOpen, setLinkOpen] = useState(false);
113
+ const [linkText, setLinkText] = useState("");
114
+ const [linkError, setLinkError] = useState(null);
115
+ const [focusIndex, setFocusIndex] = useState(0);
116
+ const buttons = useRef([]);
117
+ function openLink() {
118
+ setLinkText(editor.getAttributes("link").href ?? "");
119
+ setLinkError(null);
120
+ setLinkOpen(true);
121
+ }
122
+ openLinkRef.current = openLink;
123
+ function closeLink() {
124
+ setLinkOpen(false);
125
+ editor.commands.focus();
126
+ }
127
+ function submitLink(event) {
128
+ // The description sits inside the event's own form: applying a link must not submit that.
129
+ event.preventDefault();
130
+ event.stopPropagation();
131
+ const chain = editor.chain().focus();
132
+ if (linkText.trim() === "") {
133
+ chain.extendMarkRange("link").unsetLink().run();
134
+ }
135
+ else {
136
+ const href = linkFromInput(linkText);
137
+ if (!href) {
138
+ setLinkError("Enter a web address (http or https) or an email address.");
139
+ return;
140
+ }
141
+ if (editor.state.selection.empty && !editor.isActive("link")) {
142
+ chain.insertContent({ type: "text", text: linkText.trim(), marks: [{ type: "link", attrs: { href } }] }).run();
143
+ }
144
+ else {
145
+ chain.extendMarkRange("link").setLink({ href }).run();
146
+ }
147
+ }
148
+ setLinkOpen(false);
149
+ }
150
+ const items = [
151
+ { label: "Bold", icon: BsTypeBold, pressed: marks.bold, onClick: () => editor.chain().focus().toggleBold().run() },
152
+ { label: "Italic", icon: BsTypeItalic, pressed: marks.italic, onClick: () => editor.chain().focus().toggleItalic().run() },
153
+ { label: "Underline", icon: BsTypeUnderline, pressed: marks.underline, onClick: () => editor.chain().focus().toggleUnderline().run() },
154
+ { label: "Numbered list", icon: BsListOl, pressed: marks.orderedList, onClick: () => editor.chain().focus().toggleOrderedList().run() },
155
+ { label: "Bulleted list", icon: BsListUl, pressed: marks.bulletList, onClick: () => editor.chain().focus().toggleBulletList().run() },
156
+ { label: "Link", icon: BsLink45Deg, pressed: marks.link, onClick: openLink },
157
+ { label: "Remove formatting", icon: BsEraser, onClick: () => editor.chain().focus().unsetAllMarks().run() },
158
+ ];
159
+ function handleToolbarKeyDown(event) {
160
+ const last = items.length - 1;
161
+ const next = event.key === "ArrowRight" ? (focusIndex === last ? 0 : focusIndex + 1)
162
+ : event.key === "ArrowLeft" ? (focusIndex === 0 ? last : focusIndex - 1)
163
+ : event.key === "Home" ? 0
164
+ : event.key === "End" ? last
165
+ : undefined;
166
+ if (next !== undefined) {
167
+ event.preventDefault();
168
+ setFocusIndex(next);
169
+ buttons.current[next].focus();
170
+ }
171
+ }
172
+ return (_jsxs("div", { className: "border border-border rounded-md bg-surface focus-within:border-primary", children: [_jsx("div", { role: "toolbar", "aria-label": "Description formatting", onKeyDown: handleToolbarKeyDown, className: "flex flex-wrap items-center gap-0.5 px-1.5 py-1 border-b border-border", children: items.map((item, index) => (_jsx("button", { ref: (element) => {
173
+ buttons.current[index] = element;
174
+ }, type: "button", "aria-label": item.label, title: item.label, "aria-pressed": item.pressed, disabled: !editor, tabIndex: index === focusIndex ? 0 : -1, onFocus: () => setFocusIndex(index),
175
+ // Pressing a button must not move focus (and the selection) out of the text.
176
+ onMouseDown: (event) => event.preventDefault(), onClick: item.onClick, className: [
177
+ "w-7 h-7 flex items-center justify-center rounded-sm disabled:opacity-40",
178
+ item.pressed ? "bg-primary/10 text-primary-dark" : "text-text-muted hover:bg-surface-alt hover:text-text",
179
+ ].join(" "), children: _jsx(item.icon, { size: 15, "aria-hidden": "true" }) }, item.label))) }), linkOpen && (_jsxs("div", { className: "flex flex-col gap-1 px-2 py-1.5 border-b border-border", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "Link address", placeholder: "https://example.com", value: linkText, onChange: (event) => {
180
+ setLinkText(event.target.value);
181
+ setLinkError(null);
182
+ }, onKeyDown: (event) => {
183
+ if (event.key === "Enter") {
184
+ submitLink(event);
185
+ }
186
+ else if (event.key === "Escape") {
187
+ // Closes the prompt, not the dialog around it.
188
+ event.stopPropagation();
189
+ closeLink();
190
+ }
191
+ }, className: "flex-1 min-w-0 text-sm py-1 px-2 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "button", onClick: submitLink, className: "text-xs font-semibold text-primary-dark hover:underline", children: "Apply" }), _jsx("button", { type: "button", onClick: closeLink, className: "text-xs font-medium text-text-muted hover:underline", children: "Cancel" })] }), linkError && (_jsx("p", { role: "alert", className: "text-xs text-danger", children: linkError }))] })), _jsx(EditorContent, { editor: editor, className: "px-3 py-2 text-sm max-h-64 overflow-y-auto [&_.tiptap]:outline-none [&_.tiptap]:min-h-[4.5rem] [&_.tiptap_ul]:list-disc [&_.tiptap_ul]:pl-5 [&_.tiptap_ol]:list-decimal [&_.tiptap_ol]:pl-5 [&_.tiptap_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_.tiptap_p.is-editor-empty:first-child]:before:text-text-muted [&_.tiptap_p.is-editor-empty:first-child]:before:float-left [&_.tiptap_p.is-editor-empty:first-child]:before:h-0 [&_.tiptap_p.is-editor-empty:first-child]:before:pointer-events-none" })] }));
192
+ }
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ export interface EventDescriptionViewProps {
3
+ /** The description as HTML, from the server or an invitation - untrusted, so it is read into allowed elements only (`parseEventDescription()`) and drawn as
4
+ * React elements, never as markup. */
5
+ html?: string | null;
6
+ /** The plain-text description, shown (line breaks kept) when there is no HTML, or when the HTML holds nothing this can draw. */
7
+ text?: string | null;
8
+ className?: string;
9
+ }
10
+ /**
11
+ * 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`),
12
+ * else the plain text. Nothing is drawn for an event with no description.
13
+ */
14
+ export default function EventDescriptionView({ html, text, className }: EventDescriptionViewProps): React.JSX.Element | null;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { htmlToPlainText, parseEventDescription } from "@rapidmx/react-shared/calendar/eventDescription.js";
3
+ function renderNodes(nodes) {
4
+ return nodes.map((node, index) => {
5
+ if ("text" in node) {
6
+ return node.text;
7
+ }
8
+ switch (node.tag) {
9
+ case "br":
10
+ return _jsx("br", {}, index);
11
+ case "a":
12
+ return (_jsx("a", { href: node.href, target: "_blank", rel: "noopener noreferrer", className: "text-primary-dark underline break-words", children: renderNodes(node.children) }, index));
13
+ case "ul":
14
+ return (_jsx("ul", { className: "list-disc pl-5 my-0.5", children: renderNodes(node.children) }, index));
15
+ case "ol":
16
+ return (_jsx("ol", { className: "list-decimal pl-5 my-0.5", children: renderNodes(node.children) }, index));
17
+ case "p":
18
+ return (_jsx("p", { className: "mb-1 last:mb-0 min-h-[1lh]", children: renderNodes(node.children) }, index));
19
+ case "li":
20
+ return _jsx("li", { children: renderNodes(node.children) }, index);
21
+ case "strong":
22
+ return _jsx("strong", { children: renderNodes(node.children) }, index);
23
+ case "em":
24
+ return _jsx("em", { children: renderNodes(node.children) }, index);
25
+ case "u":
26
+ return _jsx("u", { children: renderNodes(node.children) }, index);
27
+ }
28
+ });
29
+ }
30
+ /**
31
+ * 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`),
32
+ * else the plain text. Nothing is drawn for an event with no description.
33
+ */
34
+ export default function EventDescriptionView({ html, text, className }) {
35
+ if (htmlToPlainText(html) !== "") {
36
+ return _jsx("div", { className: ["break-words", className].filter(Boolean).join(" "), children: renderNodes(parseEventDescription(html)) });
37
+ }
38
+ return text?.trim() ? _jsx("div", { className: ["break-words whitespace-pre-wrap", className].filter(Boolean).join(" "), children: text }) : null;
39
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { AttendeeResponseInput } from "@rapidmx/react-shared/calendar/calendarApi.js";
3
+ import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
4
+ export interface EventDetailsProps {
5
+ occurrence: CalendarOccurrence;
6
+ /** Whether this mailbox is invited to the event rather than organizing it: only the organizer can change an event, so there is no
7
+ * Modify, and the mailbox may answer instead. */
8
+ isInvited: boolean;
9
+ /** 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
10
+ * also switches on the Accept / Tentative / Decline block. */
11
+ myResponse?: AttendeeResponseInput | "needsAction";
12
+ /** 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
13
+ * has hidden the guest list. */
14
+ isOwnAddress?: (address: string) => boolean;
15
+ calendarName?: string;
16
+ calendarColor?: string;
17
+ onClose: () => void;
18
+ /** Modify: switch to the edit form. */
19
+ onModify: () => void;
20
+ onSaved: () => void;
21
+ onDeleted: () => void;
22
+ }
23
+ /**
24
+ * What clicking an existing event shows first: everything the event says, read-only. The reader who organized it (or whose event has no
25
+ * other organizer) can Modify or Delete it; one who was invited can answer (Accept, Tentative, Decline) or remove it from their own
26
+ * 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
27
+ * guests to be added) the invited reader can "Request a change" / "Add guests": the request goes to the organizer (`RequestChangeForm`) and nothing here
28
+ * changes until their update arrives. When the organizer hides the guest list the reader sees only themselves and is told so.
29
+ *
30
+ * 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 -
31
+ * its time and that it is busy - with nothing to change, delete or answer.
32
+ */
33
+ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnAddress, calendarName, calendarColor, onClose, onModify, onSaved, onDeleted, }: EventDetailsProps): React.JSX.Element;