@rapidmx/web-client 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/README.md +373 -373
  2. package/apps/admin/branding/index.tsx +39 -39
  3. package/apps/admin/data-requests/index.tsx +482 -480
  4. package/apps/admin/domains/[uid].tsx +164 -163
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -348
  6. package/apps/admin/index.tsx +127 -124
  7. package/apps/admin/mailboxes/[uid].tsx +269 -224
  8. package/apps/admin/mailboxes/new/index.tsx +28 -28
  9. package/apps/admin/plugins/index.tsx +15 -15
  10. package/apps/admin/retention-policy/index.tsx +39 -39
  11. package/apps/admin/signing-certificates/index.tsx +343 -343
  12. package/apps/escrow/audit-log/index.tsx +196 -196
  13. package/apps/escrow/matters/[uid].tsx +621 -621
  14. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  15. package/apps/shared/auth/adminAccess.ts +99 -99
  16. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  17. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  18. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -346
  19. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  20. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  21. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  22. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  23. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  24. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +53 -4
  25. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  26. package/apps/shared/components/admin/settings/PluginsManager.tsx +64 -10
  27. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  28. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  29. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  30. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  31. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  32. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  33. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  34. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  35. package/apps/shared/components/calendar/EventEditor.tsx +635 -0
  36. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  37. package/apps/shared/components/calendar/EventFormParts.tsx +591 -0
  38. package/apps/shared/components/calendar/EventModal.tsx +206 -946
  39. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  40. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  41. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  42. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  43. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  44. package/apps/shared/components/calendar/MonthView.tsx +185 -173
  45. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  46. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  47. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  48. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -222
  49. package/apps/shared/components/calendar/RequestChangeForm.tsx +240 -0
  50. package/apps/shared/components/calendar/SplitDayView.tsx +144 -143
  51. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  52. package/apps/shared/components/calendar/TimeGridView.tsx +246 -244
  53. package/apps/shared/components/calendar/allDay.ts +124 -124
  54. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  55. package/apps/shared/components/calendar/eventForm.ts +108 -0
  56. package/apps/shared/components/calendar/eventFormat.ts +210 -0
  57. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  58. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  59. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  60. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  61. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  62. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  63. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  64. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  65. package/apps/shared/components/layout/AppShell.tsx +480 -480
  66. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  67. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  68. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  69. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  70. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  71. package/apps/shared/components/layout/UserMenu.tsx +439 -372
  72. package/apps/shared/components/mail/ConversationList.tsx +323 -271
  73. package/apps/shared/components/mail/ConversationThreadPane.tsx +130 -5
  74. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  75. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  76. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1537
  77. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  78. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  79. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  80. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1673
  81. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  82. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  83. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  84. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  85. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  86. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  87. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  88. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  89. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  90. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  91. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  92. package/apps/shared/components/mail/layout/MailShell.tsx +546 -490
  93. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  94. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  95. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  96. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  97. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  98. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -253
  99. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  100. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  101. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  102. package/apps/shared/gestures/useSwipe.ts +114 -0
  103. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  104. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  105. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  106. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  107. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  108. package/apps/shared/keyboard/dispatch.ts +124 -124
  109. package/apps/shared/keyboard/format.ts +89 -89
  110. package/apps/shared/keyboard/keymap.ts +114 -114
  111. package/apps/shared/keyboard/registry.ts +65 -65
  112. package/apps/shared/keyboard/targets.ts +79 -79
  113. package/apps/shared/mail/folderCounts.ts +17 -0
  114. package/apps/shared/mail/folderTree.ts +143 -143
  115. package/apps/shared/mail/listAllPages.ts +39 -39
  116. package/apps/shared/mail/newMailNotifications.ts +3 -15
  117. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  118. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  119. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  120. package/apps/shared/mail/primaryMailbox.ts +71 -0
  121. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  122. package/apps/shared/mail/useMailConnection.ts +205 -203
  123. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  124. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  125. package/apps/shared/mail/useNewMailNotifications.ts +2 -2
  126. package/apps/shared/navigation/AppRouter.tsx +300 -300
  127. package/apps/shared/navigation/routerContext.tsx +83 -83
  128. package/apps/shared/notifications/preferences.ts +36 -0
  129. package/apps/shared/notifications/store.ts +9 -0
  130. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  131. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  132. package/apps/shared/search/localIndexBuilder.ts +481 -481
  133. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  134. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  135. package/apps/shared/signing/enrollmentView.ts +251 -251
  136. package/apps/shared/signing/useNow.ts +19 -19
  137. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  138. package/apps/shared/styles/app.css +396 -386
  139. package/apps/www/_routes.ts +1 -0
  140. package/apps/www/calendar/index.tsx +578 -469
  141. package/apps/www/contacts/[uid].tsx +109 -109
  142. package/apps/www/contacts/index.tsx +6 -0
  143. package/apps/www/index.tsx +2847 -2332
  144. package/apps/www/messages/[uid].tsx +135 -108
  145. package/apps/www/settings/auto-reply/index.tsx +133 -133
  146. package/apps/www/settings/encryption/index.tsx +1287 -1287
  147. package/apps/www/settings/filters/[uid].tsx +176 -176
  148. package/apps/www/settings/filters/index.tsx +102 -102
  149. package/apps/www/settings/filters/new/index.tsx +140 -140
  150. package/apps/www/settings/labels/index.tsx +204 -204
  151. package/apps/www/settings/privacy/index.tsx +492 -492
  152. package/apps/www/settings/profile/index.tsx +248 -0
  153. package/apps/www/settings/read-receipts/index.tsx +147 -147
  154. package/apps/www/settings/sharing/index.tsx +256 -256
  155. package/apps/www/settings/signatures/[uid].tsx +172 -172
  156. package/apps/www/settings/signatures/index.tsx +88 -88
  157. package/apps/www/settings/signatures/new/index.tsx +135 -135
  158. package/apps/www/tasks/index.tsx +649 -649
  159. package/dist/apps/admin/data-requests/index.js +1 -1
  160. package/dist/apps/admin/domains/[uid].js +4 -2
  161. package/dist/apps/admin/index.js +2 -1
  162. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  163. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  164. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  165. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  166. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  167. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  168. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  169. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  170. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  171. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  172. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  173. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  174. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  175. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +28 -4
  176. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  177. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  178. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  179. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  180. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  181. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  182. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  183. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  184. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  185. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  186. package/dist/apps/shared/components/calendar/EventEditor.js +544 -0
  187. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  188. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  189. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +70 -0
  190. package/dist/apps/shared/components/calendar/EventFormParts.js +191 -0
  191. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  192. package/dist/apps/shared/components/calendar/EventModal.js +57 -459
  193. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  194. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  195. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  196. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  197. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  198. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  199. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  200. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  201. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  202. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  203. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  204. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  205. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  206. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  207. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  208. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  209. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  210. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  211. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  212. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  213. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  214. package/dist/apps/shared/components/calendar/RequestChangeForm.js +127 -0
  215. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  216. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  217. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  218. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  219. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  220. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  221. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  222. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  223. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  224. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  225. package/dist/apps/shared/components/calendar/eventFormat.d.ts +80 -0
  226. package/dist/apps/shared/components/calendar/eventFormat.js +190 -0
  227. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  228. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  229. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  230. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  231. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  232. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  233. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  234. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  235. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  236. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  237. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  238. package/dist/apps/shared/components/layout/UserMenu.d.ts +14 -9
  239. package/dist/apps/shared/components/layout/UserMenu.js +74 -26
  240. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  241. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  242. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  243. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  244. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  245. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  246. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  247. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  248. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  249. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  250. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  251. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +16 -7
  252. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  253. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  254. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  255. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  256. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  257. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  258. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  259. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  260. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  261. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  262. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  263. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  264. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  265. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  266. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  267. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  268. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +10 -0
  269. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -46
  270. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  271. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  272. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  273. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  274. package/dist/apps/shared/components/settings/layout/SettingsShell.js +7 -5
  275. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  276. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  277. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  278. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  279. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  280. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  281. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  282. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  283. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  284. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  285. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  286. package/dist/apps/shared/mail/folderCounts.js +12 -0
  287. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  288. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  289. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  290. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  291. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  292. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  293. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  294. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  295. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  296. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  297. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  298. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  299. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  300. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  301. package/dist/apps/shared/notifications/preferences.js +36 -0
  302. package/dist/apps/shared/notifications/store.d.ts +1 -0
  303. package/dist/apps/shared/notifications/store.js +7 -23
  304. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  305. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  306. package/dist/apps/shared/styles/app.css +396 -386
  307. package/dist/apps/www/_routes.js +1 -0
  308. package/dist/apps/www/calendar/index.js +78 -7
  309. package/dist/apps/www/contacts/index.js +3 -1
  310. package/dist/apps/www/index.js +548 -210
  311. package/dist/apps/www/messages/[uid].js +13 -5
  312. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  313. package/dist/apps/www/settings/profile/index.js +124 -0
  314. package/package.json +2 -2
@@ -0,0 +1,271 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { RefObject, useEffect, useId, useRef, useState } from "react";
6
+ import { HiOutlineCheck, HiOutlineEllipsisHorizontal } from "react-icons/hi2";
7
+ import { InviteResponse, MessageInvite, ProposedTime, proposeNewTime, respondToMessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
8
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
10
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
11
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
12
+ import { notifyApiError } from "../../../notifications/apiErrors.js";
13
+ import { calendarHref, conflictSummary, formatInviteWhen } from "./inviteFormat.js";
14
+ import InviteTimeline from "./InviteTimeline.js";
15
+ import ProposeTimeForm from "./ProposeTimeForm.js";
16
+
17
+ /** The popover's width, and the room it leaves the window's edges. */
18
+ const WIDTH = 360;
19
+ const WINDOW_MARGIN = 16;
20
+ /** What the popover is tall for its own text and footer, before the day view (px), and how tall the day view is per hour drawn. */
21
+ const CHROME_HEIGHT = 190;
22
+ const CONFLICT_LINE_HEIGHT = 22;
23
+ const HOURS_HEIGHT = 36;
24
+ const PROPOSE_HEIGHT = 340;
25
+
26
+ /** The hours the day view draws for an invitation, as `InviteTimeline` will (a fixed estimate is all a popover's box needs). */
27
+ function estimateHeight(invite: MessageInvite, proposing: boolean): number {
28
+ const cap = window.innerHeight - WINDOW_MARGIN;
29
+ if (proposing) {
30
+ return Math.min(PROPOSE_HEIGHT, cap);
31
+ }
32
+ const start = invite.startDate ? new Date(invite.startDate) : undefined;
33
+ const end = invite.endDate ? new Date(invite.endDate) : undefined;
34
+ const spanHours = start && end ? Math.max(0, (end.getTime() - start.getTime()) / 3_600_000) : 1;
35
+ // The day view spans the invitation with two hours either side, and never less than five.
36
+ const hours = Math.min(24, Math.max(5, Math.ceil(spanHours) + 4));
37
+ const allDay = invite.schedule.filter((entry) => entry.allDay).length;
38
+ return Math.min(CHROME_HEIGHT + hours * HOURS_HEIGHT + allDay * CONFLICT_LINE_HEIGHT + (invite.conflicts.length > 0 ? CONFLICT_LINE_HEIGHT : 0), cap);
39
+ }
40
+
41
+ const RESPONSES: { response: InviteResponse; label: string; text: string; failure: string }[] = [
42
+ { response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
43
+ { response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
44
+ { response: "tentative", label: "Tentative", text: "?", failure: "Couldn't respond tentatively to this meeting" },
45
+ ];
46
+
47
+ interface RsvpPanelProps {
48
+ messageUid: string;
49
+ invite: MessageInvite;
50
+ /** Called with the invitation as it stands after an answer or a proposal - the popover then closes. */
51
+ onDone: (invite: MessageInvite) => void;
52
+ /** Escape, or Cancel: close without returning anything. */
53
+ onClose: () => void;
54
+ /** The proposal form is showing, so the box can be sized for it. */
55
+ onProposingChange?: (proposing: boolean) => void;
56
+ }
57
+
58
+ /** What is inside the RSVP popover (or dialog): the meeting, a day view around it and the buttons that answer it. */
59
+ function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }: RsvpPanelProps) {
60
+ const titleId = useId();
61
+ const [pending, setPending] = useState<InviteResponse | "propose" | null>(null);
62
+ const [proposing, setProposingState] = useState(false);
63
+ const [menuOpen, setMenuOpen] = useState(false);
64
+ const rootRef = useRef<HTMLDivElement>(null);
65
+ const moreRef = useRef<HTMLButtonElement>(null);
66
+ const menuRef = useRef<HTMLDivElement>(null);
67
+ const busy = pending !== null;
68
+ const when = formatInviteWhen(invite);
69
+
70
+ // Focus goes to the first thing to do when the panel opens.
71
+ useEffect(() => {
72
+ rootRef.current?.querySelector<HTMLElement>("button:not([disabled])")?.focus();
73
+ }, []);
74
+
75
+ function setProposing(next: boolean) {
76
+ setProposingState(next);
77
+ onProposingChange?.(next);
78
+ }
79
+
80
+ async function answer(response: InviteResponse, failure: string) {
81
+ setPending(response);
82
+ try {
83
+ onDone(await respondToMessageInvite(messageUid, response));
84
+ } catch (err) {
85
+ notifyApiError(err, failure);
86
+ setPending(null);
87
+ }
88
+ }
89
+
90
+ async function propose(proposal: ProposedTime) {
91
+ setPending("propose");
92
+ try {
93
+ onDone(await proposeNewTime(messageUid, proposal));
94
+ } catch (err) {
95
+ notifyApiError(err, "Couldn't send the proposed time");
96
+ setPending(null);
97
+ }
98
+ }
99
+
100
+ function handleKeyDown(event: React.KeyboardEvent) {
101
+ if (event.key === "Escape") {
102
+ event.stopPropagation();
103
+ if (menuOpen) {
104
+ setMenuOpen(false);
105
+ moreRef.current?.focus();
106
+ } else {
107
+ onClose();
108
+ }
109
+ }
110
+ }
111
+
112
+ function handleMenuKeyDown(event: React.KeyboardEvent) {
113
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
114
+ return;
115
+ }
116
+ event.preventDefault();
117
+ const items = Array.from(menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]'));
118
+ const at = items.indexOf(document.activeElement as HTMLElement);
119
+ items[(at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
120
+ }
121
+
122
+ return (
123
+ <div
124
+ ref={rootRef}
125
+ role="group"
126
+ aria-labelledby={titleId}
127
+ tabIndex={-1}
128
+ onKeyDown={handleKeyDown}
129
+ className="relative flex-1 min-h-0 flex flex-col text-sm text-text outline-none"
130
+ >
131
+ <div className="flex-1 min-h-0 overflow-y-auto px-4 pt-3 pb-2 flex flex-col gap-2">
132
+ <h3 id={titleId} className="font-semibold break-words">
133
+ {invite.summary?.trim() || "(no title)"}
134
+ </h3>
135
+ {when && <p className="break-words">{when}{invite.recurring && <span className="text-text-muted"> (Repeats)</span>}</p>}
136
+ {invite.conflicts.length > 0 && (
137
+ <p className="text-danger break-words">
138
+ <span className="font-medium">Conflicts with:</span> {conflictSummary(invite.conflicts)}
139
+ </p>
140
+ )}
141
+ {proposing ? (
142
+ <ProposeTimeForm
143
+ invite={invite}
144
+ busy={pending === "propose"}
145
+ onSubmit={(proposal) => void propose(proposal)}
146
+ onCancel={() => setProposing(false)}
147
+ />
148
+ ) : (
149
+ <InviteTimeline invite={invite} />
150
+ )}
151
+ </div>
152
+ {!proposing && (
153
+ <div className="flex items-center gap-2 px-4 py-3 border-t border-border" aria-busy={busy || undefined}>
154
+ {RESPONSES.map(({ response, label, text, failure }) => {
155
+ const current = invite.response === response;
156
+ return (
157
+ <Button
158
+ key={response}
159
+ type="button"
160
+ variant={response === "accepted" && !current ? "primary" : "secondary"}
161
+ className={["!w-auto", response === "tentative" ? "!px-3" : "", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" ")}
162
+ aria-label={label}
163
+ title={label}
164
+ aria-pressed={current}
165
+ aria-busy={pending === response || undefined}
166
+ loading={pending === response}
167
+ disabled={busy || current}
168
+ onClick={() => void answer(response, failure)}
169
+ >
170
+ {current && <HiOutlineCheck size={14} aria-hidden="true" />}
171
+ {text}
172
+ </Button>
173
+ );
174
+ })}
175
+ <div className="ml-auto relative">
176
+ <button
177
+ ref={moreRef}
178
+ type="button"
179
+ aria-label="More actions"
180
+ aria-haspopup="menu"
181
+ aria-expanded={menuOpen}
182
+ disabled={busy}
183
+ onClick={() => setMenuOpen((open) => !open)}
184
+ className="inline-flex items-center justify-center p-1.5 rounded-md border border-border text-text hover:bg-surface-alt disabled:opacity-50"
185
+ >
186
+ <HiOutlineEllipsisHorizontal size={18} aria-hidden="true" />
187
+ </button>
188
+ {menuOpen && (
189
+ <div
190
+ ref={(node) => {
191
+ menuRef.current = node;
192
+ // A menu that has just opened takes the focus, as a menu does.
193
+ node?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
194
+ }}
195
+ role="menu"
196
+ aria-label="More actions"
197
+ onKeyDown={handleMenuKeyDown}
198
+ className="absolute bottom-full right-0 mb-1 w-48 py-1 rounded-md border border-border bg-surface shadow-modal z-10"
199
+ >
200
+ {invite.canPropose && (
201
+ <button
202
+ type="button"
203
+ role="menuitem"
204
+ onClick={() => {
205
+ setMenuOpen(false);
206
+ setProposing(true);
207
+ }}
208
+ className="w-full px-3 py-2 text-left hover:bg-surface-alt"
209
+ >
210
+ Propose new time
211
+ </button>
212
+ )}
213
+ <a role="menuitem" href={calendarHref(invite)} className="block px-3 py-2 hover:bg-surface-alt">
214
+ Open in calendar
215
+ </a>
216
+ </div>
217
+ )}
218
+ </div>
219
+ </div>
220
+ )}
221
+ </div>
222
+ );
223
+ }
224
+
225
+ export interface InviteRsvpPopoverProps {
226
+ messageUid: string;
227
+ invite: MessageInvite;
228
+ /** The RSVP button: the popover hangs from it, and focus goes back to it when the popover closes. */
229
+ anchorRef: RefObject<HTMLElement | null>;
230
+ /** Called with the invitation as it stands after an answer or a proposal. The popover has closed itself. */
231
+ onDone: (invite: MessageInvite) => void;
232
+ onClose: () => void;
233
+ }
234
+
235
+ /**
236
+ * What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
237
+ * the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
238
+ * An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
239
+ * updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
240
+ *
241
+ * Focus moves to the first button when it opens, Escape closes it (a menu open inside it first), and focus returns to the RSVP button.
242
+ */
243
+ export default function InviteRsvpPopover({ messageUid, invite, anchorRef, onDone, onClose }: InviteRsvpPopoverProps) {
244
+ const isMobile = useIsMobile();
245
+ const [proposing, setProposing] = useState(false);
246
+ const title = invite.summary?.trim() || "(no title)";
247
+
248
+ function close() {
249
+ onClose();
250
+ anchorRef.current?.focus();
251
+ }
252
+
253
+ function done(updated: MessageInvite) {
254
+ onDone(updated);
255
+ close();
256
+ }
257
+
258
+ const panel = <RsvpPanel messageUid={messageUid} invite={invite} onDone={done} onClose={close} onProposingChange={setProposing} />;
259
+ if (isMobile) {
260
+ return (
261
+ <Modal open onClose={close} title="Meeting invitation">
262
+ {panel}
263
+ </Modal>
264
+ );
265
+ }
266
+ return (
267
+ <PopoverPortal anchorRef={anchorRef} onClose={onClose} width={WIDTH} height={estimateHeight(invite, proposing)} aria-label={`RSVP: ${title}`}>
268
+ {panel}
269
+ </PopoverPortal>
270
+ );
271
+ }
@@ -0,0 +1,200 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { InviteScheduleEntry, MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
7
+ import { validDate } from "./inviteFormat.js";
8
+
9
+ /** How tall one hour is drawn, in px. */
10
+ const HOUR_HEIGHT = 36;
11
+ /** The room the hour labels take at the left, as a CSS length. */
12
+ const LABEL_WIDTH = "3.25rem";
13
+ /** The hours of context shown before the invitation and after it, and the least the day view is tall (in hours). */
14
+ const CONTEXT_HOURS = 2;
15
+ const MIN_HOURS = 5;
16
+
17
+ const MINUTE_MS = 60_000;
18
+
19
+ /** A block of the day and where it sits among the blocks it overlaps. */
20
+ export interface PlacedBlock {
21
+ /** Which of the overlapping columns it is drawn in. */
22
+ column: number;
23
+ /** How many columns its cluster of overlapping blocks needs. */
24
+ columns: number;
25
+ }
26
+
27
+ /**
28
+ * Lays overlapping blocks side by side: blocks that overlap (directly or through a chain of others) form a cluster, and each block takes the first
29
+ * column of the cluster that is free where it starts. `blocks` must be sorted by start; the result is in the same order.
30
+ */
31
+ export function placeBlocks(blocks: { start: number; end: number }[]): PlacedBlock[] {
32
+ const placed: PlacedBlock[] = [];
33
+ let cluster: { index: number; column: number }[] = [];
34
+ let columnEnds: number[] = [];
35
+ let clusterEnd = -Infinity;
36
+ const close = () => {
37
+ for (const member of cluster) {
38
+ placed[member.index] = { column: member.column, columns: columnEnds.length };
39
+ }
40
+ cluster = [];
41
+ columnEnds = [];
42
+ };
43
+ blocks.forEach((block, index) => {
44
+ if (block.start >= clusterEnd) {
45
+ close();
46
+ }
47
+ let column = columnEnds.findIndex((end) => end <= block.start);
48
+ if (column === -1) {
49
+ column = columnEnds.length;
50
+ }
51
+ columnEnds[column] = block.end;
52
+ cluster.push({ index, column });
53
+ clusterEnd = Math.max(clusterEnd, block.end);
54
+ });
55
+ close();
56
+ return placed;
57
+ }
58
+
59
+ /** The hours the day view spans: the invitation's own, with `CONTEXT_HOURS` either side (at least `MIN_HOURS`), kept inside the invitation's day. */
60
+ function hourWindow(start: Date, end: Date): { from: number; to: number } {
61
+ const sameDay = start.toDateString() === end.toDateString();
62
+ const endHour = sameDay ? Math.ceil(end.getHours() + end.getMinutes() / 60) : 24;
63
+ let from = Math.max(0, start.getHours() - CONTEXT_HOURS);
64
+ let to = Math.min(24, endHour + CONTEXT_HOURS);
65
+ if (to - from < MIN_HOURS) {
66
+ to = Math.min(24, from + MIN_HOURS);
67
+ from = Math.max(0, to - MIN_HOURS);
68
+ }
69
+ return { from, to };
70
+ }
71
+
72
+ interface Block {
73
+ key: string;
74
+ title: string;
75
+ start: number;
76
+ end: number;
77
+ kind: "invite" | "conflict" | "busy" | "free";
78
+ tentative: boolean;
79
+ }
80
+
81
+ const BLOCK_CLASS: Record<Block["kind"], string> = {
82
+ invite: "bg-primary/20 border-primary text-text font-semibold",
83
+ conflict: "bg-danger-bg border-danger text-danger",
84
+ busy: "bg-surface border-border text-text",
85
+ free: "bg-transparent border-border text-text-muted",
86
+ };
87
+
88
+ const CLOCK = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
89
+ const HOUR = new Intl.DateTimeFormat(undefined, { hour: "numeric" });
90
+
91
+ export interface InviteTimelineProps {
92
+ invite: Pick<MessageInvite, "startDate" | "endDate" | "summary" | "conflicts" | "schedule">;
93
+ }
94
+
95
+ /**
96
+ * A small day agenda around an invitation, as Outlook draws it in its RSVP card: hour rows for the invitation's day (from a couple of hours before it
97
+ * to a couple after), the invitation itself as a highlighted block, and the reader's other events as blocks - the ones that overlap it marked in red
98
+ * (and named as conflicts to a screen reader). Overlapping blocks stand side by side. All-day events are listed above the hours rather than drawn.
99
+ * Draws nothing when the invitation has no usable time.
100
+ */
101
+ export default function InviteTimeline({ invite }: InviteTimelineProps) {
102
+ const start = validDate(invite.startDate);
103
+ const end = validDate(invite.endDate);
104
+ if (!start || !end || end <= start) {
105
+ return null;
106
+ }
107
+ const { from, to } = hourWindow(start, end);
108
+ const windowStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), from).getTime();
109
+ const windowEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), to).getTime();
110
+ const conflictUids = new Set(invite.conflicts.map((entry) => entry.uid));
111
+ // A conflict the schedule doesn't list is still drawn.
112
+ const entries: InviteScheduleEntry[] = [...invite.schedule, ...invite.conflicts.filter((c) => !invite.schedule.some((s) => s.uid === c.uid))];
113
+ const allDay = entries.filter((entry) => entry.allDay);
114
+
115
+ const blocks: Block[] = [{ key: "invite", title: invite.summary?.trim() || "(no title)", start: start.getTime(), end: end.getTime(), kind: "invite", tentative: false }];
116
+ for (const entry of entries) {
117
+ const entryStart = validDate(entry.startDate)?.getTime();
118
+ const entryEnd = validDate(entry.endDate)?.getTime();
119
+ if (entry.allDay || entryStart === undefined || entryEnd === undefined) {
120
+ continue;
121
+ }
122
+ blocks.push({
123
+ key: entry.uid,
124
+ title: entry.title || "(no title)",
125
+ start: entryStart,
126
+ end: entryEnd,
127
+ kind: conflictUids.has(entry.uid) ? "conflict" : entry.busy ? "busy" : "free",
128
+ tentative: entry.tentative,
129
+ });
130
+ }
131
+ // Clipped to the hours shown; a block entirely outside them isn't drawn.
132
+ const visible = blocks
133
+ .map((block) => ({ ...block, start: Math.max(block.start, windowStart), end: Math.min(block.end, windowEnd), fullStart: block.start, fullEnd: block.end }))
134
+ .filter((block) => block.end > block.start)
135
+ .sort((a, b) => a.start - b.start || a.end - b.end);
136
+ const placed = placeBlocks(visible);
137
+ const hours = Array.from({ length: to - from }, (_, i) => from + i);
138
+
139
+ return (
140
+ <div aria-label="Your schedule around this meeting" role="group" className="text-xs">
141
+ {allDay.length > 0 && (
142
+ <ul aria-label="All-day events" className="mb-1 flex flex-col gap-0.5">
143
+ {allDay.map((entry) => (
144
+ <li key={entry.uid} className="px-2 py-0.5 rounded-sm border border-border bg-surface text-text-muted truncate">
145
+ All day: {entry.title || "(no title)"}
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ )}
150
+ <div className="relative border-t border-border" style={{ height: hours.length * HOUR_HEIGHT }}>
151
+ {hours.map((hour) => (
152
+ <div
153
+ key={hour}
154
+ aria-hidden="true"
155
+ className="absolute inset-x-0 border-b border-border text-text-muted"
156
+ style={{ top: (hour - from) * HOUR_HEIGHT, height: HOUR_HEIGHT }}
157
+ >
158
+ <span className="absolute left-0 top-0 pr-1 leading-none pt-0.5" style={{ width: LABEL_WIDTH }}>
159
+ {HOUR.format(new Date(2000, 0, 1, hour))}
160
+ </span>
161
+ </div>
162
+ ))}
163
+ <ul aria-label="Events">
164
+ {visible.map((block, index) => {
165
+ const { column, columns } = placed[index];
166
+ const top = ((block.start - windowStart) / MINUTE_MS / 60) * HOUR_HEIGHT;
167
+ const height = Math.max(18, ((block.end - block.start) / MINUTE_MS / 60) * HOUR_HEIGHT - 1);
168
+ const range = `${CLOCK.format(block.fullStart)} to ${CLOCK.format(block.fullEnd)}`;
169
+ return (
170
+ <li
171
+ key={block.key}
172
+ title={`${block.title}, ${range}`}
173
+ data-kind={block.kind}
174
+ className={[
175
+ "absolute overflow-hidden rounded-sm border px-1.5 py-0.5 leading-tight",
176
+ BLOCK_CLASS[block.kind],
177
+ block.tentative ? "border-dashed" : "",
178
+ ].join(" ")}
179
+ style={{
180
+ top,
181
+ height,
182
+ left: `calc(${LABEL_WIDTH} + (100% - ${LABEL_WIDTH}) * ${column} / ${columns})`,
183
+ width: `calc((100% - ${LABEL_WIDTH}) / ${columns} - 2px)`,
184
+ }}
185
+ >
186
+ <span className="block truncate">{block.title}</span>
187
+ <span className="sr-only">
188
+ , {range}
189
+ {block.kind === "invite" ? ", this meeting" : ""}
190
+ {block.kind === "conflict" ? ", conflicts with this meeting" : ""}
191
+ {block.tentative ? ", tentative" : ""}
192
+ </span>
193
+ </li>
194
+ );
195
+ })}
196
+ </ul>
197
+ </div>
198
+ </div>
199
+ );
200
+ }
@@ -0,0 +1,109 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useId, useState } from "react";
6
+ import type { MessageInvite, ProposedTime } from "@rapidmx/react-shared/calendar/inviteApi.js";
7
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
+ import { isoDay, validDate } from "./inviteFormat.js";
9
+
10
+ const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text";
11
+
12
+ /** `HH:MM` of an instant in the reader's zone, the value of a time input. */
13
+ function clock(date: Date): string {
14
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
15
+ }
16
+
17
+ export interface ProposeTimeFormProps {
18
+ /** The invitation being answered: its own time (in the reader's zone) is what the fields start out as. */
19
+ invite: Pick<MessageInvite, "startDate" | "endDate">;
20
+ /** A proposal is on its way: the form is off. */
21
+ busy?: boolean;
22
+ /** Called with the proposed instants once the fields are valid. The caller reports a failure and closes the form on success. */
23
+ onSubmit: (proposal: ProposedTime) => void;
24
+ onCancel: () => void;
25
+ }
26
+
27
+ /**
28
+ * The small form behind "Propose new time": a date and a start and end time - in the reader's own time zone, prefilled with the invitation's own
29
+ * time - and an optional note to the organizer. It validates what it can (all three fields, and an end after the start) before anything is sent.
30
+ * The end is on the start's day: a proposal spans one day, as Outlook's does from a list.
31
+ */
32
+ export default function ProposeTimeForm({ invite, busy = false, onSubmit, onCancel }: ProposeTimeFormProps) {
33
+ const start = validDate(invite.startDate);
34
+ const end = validDate(invite.endDate);
35
+ const [date, setDate] = useState(start ? isoDay(start) : "");
36
+ const [startTime, setStartTime] = useState(start ? clock(start) : "");
37
+ const [endTime, setEndTime] = useState(end ? clock(end) : "");
38
+ const [comment, setComment] = useState("");
39
+ const [error, setError] = useState<string | null>(null);
40
+ const id = useId();
41
+
42
+ function submit(event: React.FormEvent) {
43
+ event.preventDefault();
44
+ if (!date || !startTime || !endTime) {
45
+ setError("Choose a date and a start and end time.");
46
+ return;
47
+ }
48
+ const proposedStart = new Date(`${date}T${startTime}:00`);
49
+ const proposedEnd = new Date(`${date}T${endTime}:00`);
50
+ if (proposedEnd <= proposedStart) {
51
+ setError("The end time must be after the start time.");
52
+ return;
53
+ }
54
+ setError(null);
55
+ const note = comment.trim();
56
+ onSubmit({ startDate: proposedStart.toISOString(), endDate: proposedEnd.toISOString(), ...(note ? { comment: note } : {}) });
57
+ }
58
+
59
+ return (
60
+ <form aria-label="Propose a new time" onSubmit={submit} noValidate className="flex flex-col gap-2 text-sm">
61
+ <div className="flex flex-wrap gap-2">
62
+ <div className="flex-1 min-w-[9rem]">
63
+ <label htmlFor={`${id}-date`} className="block text-xs text-text-muted mb-0.5">
64
+ Date
65
+ </label>
66
+ <input id={`${id}-date`} type="date" value={date} disabled={busy} onChange={(e) => setDate(e.target.value)} className={INPUT_CLASS} />
67
+ </div>
68
+ <div className="flex-1 min-w-[6rem]">
69
+ <label htmlFor={`${id}-start`} className="block text-xs text-text-muted mb-0.5">
70
+ Start
71
+ </label>
72
+ <input id={`${id}-start`} type="time" value={startTime} disabled={busy} onChange={(e) => setStartTime(e.target.value)} className={INPUT_CLASS} />
73
+ </div>
74
+ <div className="flex-1 min-w-[6rem]">
75
+ <label htmlFor={`${id}-end`} className="block text-xs text-text-muted mb-0.5">
76
+ End
77
+ </label>
78
+ <input id={`${id}-end`} type="time" value={endTime} disabled={busy} onChange={(e) => setEndTime(e.target.value)} className={INPUT_CLASS} />
79
+ </div>
80
+ </div>
81
+ <div>
82
+ <label htmlFor={`${id}-comment`} className="block text-xs text-text-muted mb-0.5">
83
+ Comment (optional)
84
+ </label>
85
+ <textarea
86
+ id={`${id}-comment`}
87
+ rows={2}
88
+ value={comment}
89
+ disabled={busy}
90
+ onChange={(e) => setComment(e.target.value)}
91
+ className={`${INPUT_CLASS} resize-y`}
92
+ />
93
+ </div>
94
+ {error && (
95
+ <p role="alert" className="text-danger">
96
+ {error}
97
+ </p>
98
+ )}
99
+ <div className="flex flex-wrap gap-2">
100
+ <Button type="submit" className="!w-auto" loading={busy} disabled={busy} aria-busy={busy || undefined}>
101
+ Send proposal
102
+ </Button>
103
+ <Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={onCancel}>
104
+ Cancel
105
+ </Button>
106
+ </div>
107
+ </form>
108
+ );
109
+ }