@rapidmx/web-client 0.30.0 → 0.31.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 (400) hide show
  1. package/apps/admin/audit-log/index.tsx +3 -3
  2. package/apps/admin/branding/index.tsx +3 -3
  3. package/apps/admin/data-requests/index.tsx +18 -7
  4. package/apps/admin/distribution-lists/[uid].tsx +3 -3
  5. package/apps/admin/distribution-lists/index.tsx +3 -3
  6. package/apps/admin/distribution-lists/new/index.tsx +3 -3
  7. package/apps/admin/domains/[uid].tsx +6 -5
  8. package/apps/admin/domains/index.tsx +3 -3
  9. package/apps/admin/domains/new/index.tsx +3 -3
  10. package/apps/admin/escrow-scopes/[uid].tsx +11 -5
  11. package/apps/admin/escrow-scopes/index.tsx +3 -3
  12. package/apps/admin/escrow-scopes/new/index.tsx +9 -3
  13. package/apps/admin/index.tsx +4 -4
  14. package/apps/admin/ingest-queue/index.tsx +3 -3
  15. package/apps/admin/mailboxes/[uid].tsx +15 -7
  16. package/apps/admin/quarantine/index.tsx +4 -4
  17. package/apps/admin/retention-policy/index.tsx +3 -3
  18. package/apps/admin/signing-certificates/index.tsx +3 -3
  19. package/apps/admin/transport-rules/[uid].tsx +4 -4
  20. package/apps/admin/transport-rules/index.tsx +3 -3
  21. package/apps/admin/transport-rules/new/index.tsx +3 -3
  22. package/apps/escrow/matters/[uid].tsx +26 -2
  23. package/apps/escrow/matters/new/index.tsx +4 -0
  24. package/apps/shared/calendar/calendarLiveUpdates.ts +11 -10
  25. package/apps/shared/calendar/useCalendarReminders.ts +2 -0
  26. package/apps/shared/components/admin/ActionAlert.tsx +32 -0
  27. package/apps/shared/components/admin/diagnostics/InformationPanel.tsx +1 -1
  28. package/apps/shared/components/admin/diagnostics/InstalledPlugins.tsx +1 -1
  29. package/apps/shared/components/admin/diagnostics/LogsPanel.tsx +1 -1
  30. package/apps/shared/components/admin/diagnostics/RuntimePanel.tsx +1 -1
  31. package/apps/shared/components/admin/diagnostics/SystemPanel.tsx +1 -1
  32. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +4 -4
  33. package/apps/shared/components/admin/elevation.ts +19 -0
  34. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +58 -3
  35. package/apps/shared/components/admin/layout/AdminShell.tsx +6 -1
  36. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +3 -3
  37. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +4 -4
  38. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +4 -4
  39. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  40. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +6 -6
  41. package/apps/shared/components/admin/settings/BrandingForm.tsx +22 -9
  42. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -4
  43. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +33 -4
  44. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +3 -3
  45. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +8 -3
  46. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +3 -3
  47. package/apps/shared/components/admin/settings/PluginsManager.tsx +39 -17
  48. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +25 -12
  49. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +40 -7
  50. package/apps/shared/components/admin/setup/SetupWizard.tsx +8 -6
  51. package/apps/shared/components/admin/signOut.ts +3 -0
  52. package/apps/shared/components/admin/usePagedList.tsx +2 -2
  53. package/apps/shared/components/calendar/DescriptionEditor.tsx +2 -1
  54. package/apps/shared/components/calendar/EventDetails.tsx +34 -8
  55. package/apps/shared/components/calendar/EventEditor.tsx +90 -40
  56. package/apps/shared/components/calendar/EventFormParts.tsx +5 -2
  57. package/apps/shared/components/calendar/EventMatchNav.tsx +14 -2
  58. package/apps/shared/components/calendar/EventModal.tsx +25 -1
  59. package/apps/shared/components/calendar/FindATime.tsx +10 -2
  60. package/apps/shared/components/calendar/ListView.tsx +99 -25
  61. package/apps/shared/components/calendar/RecurrenceEditor.tsx +19 -8
  62. package/apps/shared/components/calendar/SplitDayView.tsx +14 -18
  63. package/apps/shared/components/calendar/TaskQuickForm.tsx +3 -2
  64. package/apps/shared/components/calendar/TimeGridView.tsx +35 -19
  65. package/apps/shared/components/calendar/gridLayout.ts +94 -0
  66. package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
  67. package/apps/shared/components/calendar/timePicker.ts +5 -2
  68. package/apps/shared/components/contacts/ContactCard.tsx +15 -4
  69. package/apps/shared/components/contacts/ContactDetailPane.tsx +54 -15
  70. package/apps/shared/components/contacts/ContactForm.tsx +61 -17
  71. package/apps/shared/components/contacts/ContactPhotoEditor.tsx +88 -0
  72. package/apps/shared/components/contacts/ContactPhotoField.tsx +36 -39
  73. package/apps/shared/components/contacts/layout/ContactsShell.tsx +1 -1
  74. package/apps/shared/components/contacts/participantDetails.ts +18 -1
  75. package/apps/shared/components/layout/AppShell.tsx +9 -0
  76. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +9 -4
  77. package/apps/shared/components/layout/MailboxProvisioning.tsx +31 -3
  78. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +4 -2
  79. package/apps/shared/components/mail/ConversationList.tsx +49 -6
  80. package/apps/shared/components/mail/ConversationThreadPane.tsx +1 -1
  81. package/apps/shared/components/mail/MenuButton.tsx +3 -400
  82. package/apps/shared/components/mail/MessageDetailPane.tsx +16 -13
  83. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +11 -1
  84. package/apps/shared/components/mail/compose/ComposeWindow.tsx +57 -11
  85. package/apps/shared/components/mail/compose/GifPicker.tsx +10 -4
  86. package/apps/shared/components/mail/compose/RecipientInput.tsx +9 -7
  87. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +14 -1
  88. package/apps/shared/components/mail/compose/encryptionRequirement.ts +5 -0
  89. package/apps/shared/components/mail/compose/quotedBody.ts +31 -6
  90. package/apps/shared/components/mail/layout/MailShell.tsx +1 -1
  91. package/apps/shared/components/mail/reading/MessageBody.tsx +13 -7
  92. package/apps/shared/components/mail/reading/bodyContent.ts +2 -0
  93. package/apps/shared/components/mail/reading/bodyHtml.ts +25 -2
  94. package/apps/shared/components/mail/writableMailboxes.ts +2 -0
  95. package/apps/shared/components/rules/RuleBuilder.tsx +11 -2
  96. package/apps/shared/components/settings/GravatarSetting.tsx +27 -0
  97. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -1
  98. package/apps/shared/components/tasks/dueDate.ts +29 -0
  99. package/apps/shared/components/tasks/layout/TasksShell.tsx +1 -1
  100. package/apps/shared/mail/decryptedMessages.ts +51 -2
  101. package/apps/shared/mail/listSnapshots.ts +2 -0
  102. package/apps/shared/mail/outbox/sendDecision.ts +14 -3
  103. package/apps/shared/mail/outbox/sendJob.ts +5 -0
  104. package/apps/shared/mail/useMailLiveUpdates.ts +7 -1
  105. package/apps/shared/mail/usePermanentDelete.tsx +3 -1
  106. package/apps/shared/notifications/apiErrors.ts +2 -0
  107. package/apps/shared/notifications/preferences.ts +1 -1
  108. package/apps/shared/notifications/store.ts +3 -1
  109. package/apps/shared/plugins/pluginNav.ts +4 -2
  110. package/apps/www/calendar/index.tsx +24 -12
  111. package/apps/www/contacts/[uid].tsx +6 -1
  112. package/apps/www/contacts/index.tsx +66 -12
  113. package/apps/www/index.tsx +91 -28
  114. package/apps/www/messages/[uid].tsx +8 -3
  115. package/apps/www/settings/auto-reply/index.tsx +13 -0
  116. package/apps/www/settings/blocked-senders/index.tsx +4 -0
  117. package/apps/www/settings/encryption/index.tsx +66 -4
  118. package/apps/www/settings/privacy/index.tsx +4 -1
  119. package/apps/www/settings/profile/index.tsx +12 -1
  120. package/apps/www/settings/sharing/index.tsx +11 -1
  121. package/apps/www/settings/signatures/[uid].tsx +6 -4
  122. package/apps/www/tasks/index.tsx +97 -29
  123. package/dist/apps/admin/audit-log/index.js +3 -3
  124. package/dist/apps/admin/branding/index.js +3 -3
  125. package/dist/apps/admin/data-requests/index.js +17 -7
  126. package/dist/apps/admin/distribution-lists/[uid].js +3 -3
  127. package/dist/apps/admin/distribution-lists/index.js +3 -3
  128. package/dist/apps/admin/distribution-lists/new/index.js +3 -3
  129. package/dist/apps/admin/domains/[uid].js +6 -5
  130. package/dist/apps/admin/domains/index.js +3 -3
  131. package/dist/apps/admin/domains/new/index.js +3 -3
  132. package/dist/apps/admin/escrow-scopes/[uid].js +11 -6
  133. package/dist/apps/admin/escrow-scopes/index.js +3 -3
  134. package/dist/apps/admin/escrow-scopes/new/index.js +9 -4
  135. package/dist/apps/admin/index.js +4 -4
  136. package/dist/apps/admin/ingest-queue/index.js +3 -3
  137. package/dist/apps/admin/mailboxes/[uid].js +15 -7
  138. package/dist/apps/admin/quarantine/index.js +4 -4
  139. package/dist/apps/admin/retention-policy/index.js +3 -3
  140. package/dist/apps/admin/signing-certificates/index.js +3 -3
  141. package/dist/apps/admin/transport-rules/[uid].js +4 -4
  142. package/dist/apps/admin/transport-rules/index.js +3 -3
  143. package/dist/apps/admin/transport-rules/new/index.js +3 -3
  144. package/dist/apps/escrow/matters/[uid].js +8 -3
  145. package/dist/apps/escrow/matters/new/index.js +4 -0
  146. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -7
  147. package/dist/apps/shared/calendar/calendarLiveUpdates.js +10 -9
  148. package/dist/apps/shared/calendar/useCalendarReminders.js +2 -0
  149. package/dist/apps/shared/components/admin/ActionAlert.d.ts +13 -0
  150. package/dist/apps/shared/components/admin/ActionAlert.js +24 -0
  151. package/dist/apps/shared/components/admin/diagnostics/InformationPanel.js +1 -1
  152. package/dist/apps/shared/components/admin/diagnostics/InstalledPlugins.js +1 -1
  153. package/dist/apps/shared/components/admin/diagnostics/LogsPanel.js +1 -1
  154. package/dist/apps/shared/components/admin/diagnostics/RuntimePanel.js +1 -1
  155. package/dist/apps/shared/components/admin/diagnostics/SystemPanel.js +1 -1
  156. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +4 -4
  157. package/dist/apps/shared/components/admin/elevation.d.ts +9 -0
  158. package/dist/apps/shared/components/admin/elevation.js +16 -0
  159. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +2 -0
  160. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +41 -1
  161. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -1
  162. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +3 -3
  163. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +4 -4
  164. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +4 -4
  165. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  166. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +6 -6
  167. package/dist/apps/shared/components/admin/settings/BrandingForm.js +17 -7
  168. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +4 -4
  169. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +17 -5
  170. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +3 -3
  171. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +8 -3
  172. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +3 -3
  173. package/dist/apps/shared/components/admin/settings/PluginsManager.js +26 -12
  174. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +17 -10
  175. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +18 -8
  176. package/dist/apps/shared/components/admin/setup/SetupWizard.js +8 -6
  177. package/dist/apps/shared/components/admin/signOut.js +3 -0
  178. package/dist/apps/shared/components/admin/usePagedList.js +2 -2
  179. package/dist/apps/shared/components/calendar/DescriptionEditor.js +2 -1
  180. package/dist/apps/shared/components/calendar/EventDetails.d.ts +3 -1
  181. package/dist/apps/shared/components/calendar/EventDetails.js +7 -4
  182. package/dist/apps/shared/components/calendar/EventEditor.js +81 -33
  183. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -1
  184. package/dist/apps/shared/components/calendar/EventFormParts.js +3 -3
  185. package/dist/apps/shared/components/calendar/EventMatchNav.d.ts +1 -1
  186. package/dist/apps/shared/components/calendar/EventMatchNav.js +12 -2
  187. package/dist/apps/shared/components/calendar/EventModal.js +10 -2
  188. package/dist/apps/shared/components/calendar/FindATime.js +4 -3
  189. package/dist/apps/shared/components/calendar/ListView.d.ts +10 -4
  190. package/dist/apps/shared/components/calendar/ListView.js +92 -27
  191. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  192. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +15 -10
  193. package/dist/apps/shared/components/calendar/SplitDayView.js +11 -17
  194. package/dist/apps/shared/components/calendar/TaskQuickForm.js +3 -2
  195. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -21
  196. package/dist/apps/shared/components/calendar/gridLayout.d.ts +41 -0
  197. package/dist/apps/shared/components/calendar/gridLayout.js +83 -0
  198. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
  199. package/dist/apps/shared/components/calendar/timePicker.js +5 -2
  200. package/dist/apps/shared/components/contacts/ContactCard.js +9 -3
  201. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +5 -3
  202. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -5
  203. package/dist/apps/shared/components/contacts/ContactForm.js +54 -16
  204. package/dist/apps/shared/components/contacts/ContactPhotoEditor.d.ts +21 -0
  205. package/dist/apps/shared/components/contacts/ContactPhotoEditor.js +56 -0
  206. package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +7 -4
  207. package/dist/apps/shared/components/contacts/ContactPhotoField.js +19 -21
  208. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +1 -1
  209. package/dist/apps/shared/components/contacts/participantDetails.d.ts +4 -1
  210. package/dist/apps/shared/components/contacts/participantDetails.js +17 -1
  211. package/dist/apps/shared/components/layout/AppShell.js +9 -0
  212. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +2 -3
  213. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +4 -1
  214. package/dist/apps/shared/components/layout/MailboxProvisioning.js +24 -2
  215. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +4 -2
  216. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +4 -2
  217. package/dist/apps/shared/components/mail/ConversationList.js +49 -6
  218. package/dist/apps/shared/components/mail/ConversationThreadPane.js +1 -1
  219. package/dist/apps/shared/components/mail/MenuButton.d.ts +2 -78
  220. package/dist/apps/shared/components/mail/MenuButton.js +2 -211
  221. package/dist/apps/shared/components/mail/MessageDetailPane.js +10 -6
  222. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +3 -0
  223. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +10 -1
  224. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +55 -10
  225. package/dist/apps/shared/components/mail/compose/GifPicker.js +10 -4
  226. package/dist/apps/shared/components/mail/compose/RecipientInput.js +9 -7
  227. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +7 -1
  228. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +13 -1
  229. package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +5 -0
  230. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -5
  231. package/dist/apps/shared/components/mail/layout/MailShell.js +1 -1
  232. package/dist/apps/shared/components/mail/reading/MessageBody.js +11 -6
  233. package/dist/apps/shared/components/mail/reading/bodyContent.js +2 -0
  234. package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +5 -1
  235. package/dist/apps/shared/components/mail/reading/bodyHtml.js +23 -2
  236. package/dist/apps/shared/components/mail/writableMailboxes.js +2 -0
  237. package/dist/apps/shared/components/rules/RuleBuilder.js +9 -1
  238. package/dist/apps/shared/components/settings/GravatarSetting.d.ts +6 -0
  239. package/dist/apps/shared/components/settings/GravatarSetting.js +15 -0
  240. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -1
  241. package/dist/apps/shared/components/tasks/dueDate.d.ts +12 -0
  242. package/dist/apps/shared/components/tasks/dueDate.js +26 -0
  243. package/dist/apps/shared/components/tasks/layout/TasksShell.js +1 -1
  244. package/dist/apps/shared/mail/decryptedMessages.d.ts +8 -0
  245. package/dist/apps/shared/mail/decryptedMessages.js +46 -2
  246. package/dist/apps/shared/mail/listSnapshots.js +2 -0
  247. package/dist/apps/shared/mail/outbox/sendDecision.d.ts +6 -3
  248. package/dist/apps/shared/mail/outbox/sendDecision.js +13 -3
  249. package/dist/apps/shared/mail/outbox/sendJob.d.ts +1 -0
  250. package/dist/apps/shared/mail/outbox/sendJob.js +5 -1
  251. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +3 -0
  252. package/dist/apps/shared/mail/useMailLiveUpdates.js +6 -1
  253. package/dist/apps/shared/mail/usePermanentDelete.d.ts +2 -0
  254. package/dist/apps/shared/mail/usePermanentDelete.js +1 -1
  255. package/dist/apps/shared/notifications/apiErrors.js +2 -0
  256. package/dist/apps/shared/notifications/preferences.d.ts +1 -1
  257. package/dist/apps/shared/notifications/preferences.js +1 -1
  258. package/dist/apps/shared/notifications/store.js +3 -1
  259. package/dist/apps/shared/plugins/pluginNav.d.ts +2 -1
  260. package/dist/apps/shared/plugins/pluginNav.js +4 -2
  261. package/dist/apps/www/calendar/index.js +24 -12
  262. package/dist/apps/www/contacts/[uid].js +6 -2
  263. package/dist/apps/www/contacts/index.js +62 -11
  264. package/dist/apps/www/index.js +85 -30
  265. package/dist/apps/www/messages/[uid].js +8 -3
  266. package/dist/apps/www/settings/auto-reply/index.js +13 -1
  267. package/dist/apps/www/settings/blocked-senders/index.js +4 -0
  268. package/dist/apps/www/settings/encryption/index.js +29 -5
  269. package/dist/apps/www/settings/privacy/index.js +3 -2
  270. package/dist/apps/www/settings/profile/index.js +4 -1
  271. package/dist/apps/www/settings/sharing/index.js +10 -1
  272. package/dist/apps/www/settings/signatures/[uid].js +6 -4
  273. package/dist/apps/www/tasks/index.js +94 -32
  274. package/dist/lib/admin/auditLogApi.js +2 -2
  275. package/dist/lib/admin/distributionListsApi.js +2 -2
  276. package/dist/lib/admin/domainsApi.js +2 -2
  277. package/dist/lib/admin/escrowAuditLogApi.js +2 -2
  278. package/dist/lib/admin/escrowScopesApi.js +2 -2
  279. package/dist/lib/admin/pluginsApi.d.ts +13 -2
  280. package/dist/lib/admin/pluginsApi.js +8 -0
  281. package/dist/lib/admin/retentionPolicyApi.d.ts +3 -0
  282. package/dist/lib/admin/retentionPolicyApi.js +3 -0
  283. package/dist/lib/admin/transportRulesApi.js +2 -2
  284. package/dist/lib/appearance/preferencesApi.d.ts +1 -1
  285. package/dist/lib/appearance/preferencesApi.js +3 -14
  286. package/dist/lib/auth/session.d.ts +51 -12
  287. package/dist/lib/auth/session.js +214 -38
  288. package/dist/lib/branding/brandingApi.d.ts +3 -3
  289. package/dist/lib/branding/brandingApi.js +9 -21
  290. package/dist/lib/calendar/calendarApi.js +3 -1
  291. package/dist/lib/calendar/eventSearch.js +43 -6
  292. package/dist/lib/calendar/freeBusyApi.d.ts +2 -1
  293. package/dist/lib/calendar/freeBusyApi.js +13 -5
  294. package/dist/lib/calendar/recurrence.js +51 -3
  295. package/dist/lib/calendar/useDayKey.d.ts +7 -0
  296. package/dist/lib/calendar/useDayKey.js +43 -0
  297. package/dist/lib/components/avatar/ContactAvatar.d.ts +6 -2
  298. package/dist/lib/components/avatar/ContactAvatar.js +37 -8
  299. package/dist/lib/components/avatar/ContactPhotoAvatar.d.ts +11 -0
  300. package/dist/lib/components/avatar/ContactPhotoAvatar.js +18 -0
  301. package/dist/lib/components/forms/CameraCaptureModal.d.ts +17 -0
  302. package/dist/lib/components/forms/CameraCaptureModal.js +88 -0
  303. package/dist/lib/components/forms/ImageEditBadge.d.ts +28 -0
  304. package/dist/lib/components/forms/ImageEditBadge.js +62 -0
  305. package/dist/lib/components/menus/MenuButton.d.ts +78 -0
  306. package/dist/lib/components/menus/MenuButton.js +219 -0
  307. package/dist/lib/components/navigation/BottomTabBar.d.ts +3 -1
  308. package/dist/lib/components/navigation/BottomTabBar.js +22 -3
  309. package/dist/lib/components/overlays/PopoverPortal.d.ts +2 -1
  310. package/dist/lib/components/overlays/PopoverPortal.js +3 -2
  311. package/dist/lib/components/pickers/TimeZonePicker.js +13 -1
  312. package/dist/lib/contacts/contactsApi.d.ts +5 -2
  313. package/dist/lib/contacts/contactsApi.js +16 -19
  314. package/dist/lib/contacts/gravatar.d.ts +14 -3
  315. package/dist/lib/contacts/gravatar.js +42 -5
  316. package/dist/lib/contacts/preparePhoto.d.ts +24 -0
  317. package/dist/lib/contacts/preparePhoto.js +250 -0
  318. package/dist/lib/contacts/useContactPhotoSrc.d.ts +19 -0
  319. package/dist/lib/contacts/useContactPhotoSrc.js +153 -0
  320. package/dist/lib/contacts/vcard.d.ts +3 -0
  321. package/dist/lib/contacts/vcard.js +59 -7
  322. package/dist/lib/crypto/escrowKeys.d.ts +8 -0
  323. package/dist/lib/crypto/escrowKeys.js +20 -1
  324. package/dist/lib/crypto/messageSecurity.d.ts +7 -4
  325. package/dist/lib/crypto/messageSecurity.js +23 -5
  326. package/dist/lib/crypto/passwordUnlock.d.ts +6 -1
  327. package/dist/lib/crypto/passwordUnlock.js +13 -2
  328. package/dist/lib/crypto/smime.js +12 -2
  329. package/dist/lib/mail/compose/composeQuoting.js +3 -2
  330. package/dist/lib/mail/compose/quotedImages.d.ts +6 -3
  331. package/dist/lib/mail/compose/quotedImages.js +35 -9
  332. package/dist/lib/mail/labelsApi.js +2 -2
  333. package/dist/lib/mail/mailApi.d.ts +8 -5
  334. package/dist/lib/mail/mailApi.js +73 -39
  335. package/dist/lib/mail/mailDetailHooks.js +11 -3
  336. package/dist/lib/mail/mailFilterRulesApi.js +2 -2
  337. package/dist/lib/mail/mailSignaturesApi.js +2 -2
  338. package/dist/lib/mail/mailboxImportApi.d.ts +1 -1
  339. package/dist/lib/mail/mailboxImportApi.js +3 -15
  340. package/dist/lib/mail/messageBodySanitizer.d.ts +6 -0
  341. package/dist/lib/mail/messageBodySanitizer.js +44 -1
  342. package/dist/lib/search/queryGrammar.js +12 -4
  343. package/dist/lib/search/searchTier3.d.ts +6 -1
  344. package/dist/lib/search/searchTier3.js +54 -25
  345. package/dist/lib/tasks/tasksApi.js +3 -3
  346. package/dist/lib/util/api.d.ts +26 -4
  347. package/dist/lib/util/api.js +73 -16
  348. package/dist/lib/util/apiQuery.d.ts +11 -0
  349. package/dist/lib/util/apiQuery.js +11 -0
  350. package/dist/lib/util/settleWithLimit.d.ts +5 -0
  351. package/dist/lib/util/settleWithLimit.js +25 -0
  352. package/lib/admin/auditLogApi.ts +2 -2
  353. package/lib/admin/distributionListsApi.ts +2 -2
  354. package/lib/admin/domainsApi.ts +2 -2
  355. package/lib/admin/escrowAuditLogApi.ts +2 -2
  356. package/lib/admin/escrowScopesApi.ts +2 -2
  357. package/lib/admin/pluginsApi.ts +21 -2
  358. package/lib/admin/retentionPolicyApi.ts +4 -0
  359. package/lib/admin/transportRulesApi.ts +2 -2
  360. package/lib/appearance/preferencesApi.ts +3 -14
  361. package/lib/auth/session.ts +239 -39
  362. package/lib/branding/brandingApi.ts +9 -21
  363. package/lib/calendar/calendarApi.ts +3 -1
  364. package/lib/calendar/eventSearch.ts +46 -6
  365. package/lib/calendar/freeBusyApi.ts +14 -6
  366. package/lib/calendar/recurrence.ts +59 -8
  367. package/lib/calendar/useDayKey.ts +45 -0
  368. package/lib/components/avatar/ContactAvatar.tsx +46 -8
  369. package/lib/components/avatar/ContactPhotoAvatar.tsx +26 -0
  370. package/lib/components/forms/CameraCaptureModal.tsx +133 -0
  371. package/lib/components/forms/ImageEditBadge.tsx +121 -0
  372. package/lib/components/menus/MenuButton.tsx +408 -0
  373. package/lib/components/navigation/BottomTabBar.tsx +30 -8
  374. package/lib/components/overlays/PopoverPortal.tsx +3 -2
  375. package/lib/components/pickers/TimeZonePicker.tsx +17 -1
  376. package/lib/contacts/contactsApi.ts +18 -19
  377. package/lib/contacts/gravatar.ts +47 -5
  378. package/lib/contacts/preparePhoto.ts +273 -0
  379. package/lib/contacts/useContactPhotoSrc.ts +177 -0
  380. package/lib/contacts/vcard.ts +60 -7
  381. package/lib/crypto/escrowKeys.ts +21 -1
  382. package/lib/crypto/messageSecurity.ts +33 -9
  383. package/lib/crypto/passwordUnlock.ts +14 -2
  384. package/lib/crypto/smime.ts +13 -2
  385. package/lib/mail/compose/composeQuoting.ts +3 -2
  386. package/lib/mail/compose/quotedImages.ts +39 -9
  387. package/lib/mail/labelsApi.ts +2 -2
  388. package/lib/mail/mailApi.ts +96 -39
  389. package/lib/mail/mailDetailHooks.ts +11 -3
  390. package/lib/mail/mailFilterRulesApi.ts +2 -2
  391. package/lib/mail/mailSignaturesApi.ts +2 -2
  392. package/lib/mail/mailboxImportApi.ts +9 -15
  393. package/lib/mail/messageBodySanitizer.ts +47 -1
  394. package/lib/search/queryGrammar.ts +13 -4
  395. package/lib/search/searchTier3.ts +65 -32
  396. package/lib/tasks/tasksApi.ts +3 -3
  397. package/lib/util/api.ts +99 -16
  398. package/lib/util/apiQuery.ts +13 -0
  399. package/lib/util/settleWithLimit.ts +25 -0
  400. package/package.json +1 -1
@@ -1,215 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
1
  ///////////////////////////////////////////////////////////////////////////////
3
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
3
  // SPDX-License-Identifier: MPL-2.0
5
4
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useRef, useState } from "react";
7
- import { HiCheck, HiChevronDown, HiChevronLeft, HiChevronRight, HiMinus } from "react-icons/hi2";
8
- import PopoverPortal from "../../../../lib/components/overlays/PopoverPortal.js";
9
- // `PopoverPortal` positions a fixed-size box (it has no auto-height mode), so the menu's height is
10
- // computed from its own contents rather than measured - from whichever level is *shown*, so a submenu
11
- // isn't left standing in the parent menu's taller box. These are the exact heights the classes below
12
- // render at, so the box is never short enough to clip its last row: an item is `h-9`, a group label
13
- // `h-6`, a note as many `leading-4` lines as it wraps to, a separator a 1px rule inside `my-1`, and the
14
- // list itself `py-1`.
15
- const ITEM_HEIGHT = 36;
16
- const ITEM_DESCRIPTION_HEIGHT = 16;
17
- const GROUP_LABEL_HEIGHT = 24;
18
- const NOTE_LINE_HEIGHT = 16;
19
- const NOTE_PADDING = 4;
20
- /** A note wraps, so its height depends on how much of it fits a line: `text-xs` averages a little over 6px
21
- * a character, and the note sits inside the list's `px-3`. Rounded so the estimate is never *under* the
22
- * lines the browser actually draws - a box a few pixels too tall shows blank space, one too short clips. */
23
- const NOTE_CHAR_WIDTH = 6.4;
24
- const NOTE_PADDING_X = 24;
25
- const SEPARATOR_HEIGHT = 9;
26
- const LIST_PADDING = 8;
27
- const MENU_MAX_HEIGHT = 460;
28
- const DEFAULT_MENU_WIDTH = 248;
29
- /** How tall `note` renders at `width`, wrapped. */
30
- function noteHeight(note, width) {
31
- const charsPerLine = Math.max(1, Math.floor((width - NOTE_PADDING_X) / NOTE_CHAR_WIDTH));
32
- return Math.ceil(note.length / charsPerLine) * NOTE_LINE_HEIGHT + NOTE_PADDING;
33
- }
34
- /** The height `PopoverPortal` is asked for - exact for a short menu, capped (the list scrolls) for a long one. */
35
- export function menuHeight(sections, width = DEFAULT_MENU_WIDTH) {
36
- let height = LIST_PADDING;
37
- sections.forEach((section, index) => {
38
- if (index > 0) {
39
- height += SEPARATOR_HEIGHT;
40
- }
41
- if (section.label) {
42
- height += GROUP_LABEL_HEIGHT;
43
- }
44
- for (const item of section.items) {
45
- height += ITEM_HEIGHT + (item.description ? ITEM_DESCRIPTION_HEIGHT : 0);
46
- }
47
- if (section.note) {
48
- height += noteHeight(section.note, width);
49
- }
50
- });
51
- return Math.min(height, MENU_MAX_HEIGHT);
52
- }
53
- /**
54
- * A toolbar button that opens an ARIA menu: `role="menu"` rows with checkmarks for the current choice,
55
- * full keyboard support (Enter/Space or Arrow Down to open, arrows/Home/End to move with wrap, Enter/Space
56
- * to choose, Escape or Tab to close with focus returning to the trigger) and a click outside to dismiss.
57
- *
58
- * Rendered through `PopoverPortal` rather than as an absolutely-positioned child: the mail list toolbar
59
- * lives inside the list's own `overflow-y-auto` scroll container, which clips an absolute popup however
60
- * high its `z-index` - the same reason Compose's own pickers portal out (see `PopoverPortal`'s own doc
61
- * comment). The portal supplies the positioning, the outside-click dismissal and the box; the menu
62
- * semantics, roving focus and keyboard handling are this component's own.
63
- */
64
- export default function MenuButton({ label, iconOnly = false, icon, disabled, title, sections, width = DEFAULT_MENU_WIDTH, className = "", "aria-label": ariaLabel, onOpenChange, }) {
65
- const [open, setOpen] = useState(false);
66
- const [activeIndex, setActiveIndex] = useState(0);
67
- // The key of the item whose submenu is showing, or `null` at the top level. Held as a key, not as the
68
- // item itself, because `sections` is rebuilt on every render.
69
- const [submenuKey, setSubmenuKey] = useState(null);
70
- const triggerRef = useRef(null);
71
- const itemRefs = useRef([]);
72
- // State, not a ref: `PopoverPortal` renders nothing at all until its own positioning effect has run, so
73
- // the rows don't exist yet when this component's effects first fire. Keying the focus effect on the
74
- // menu node itself makes it run again once they do.
75
- const [menuNode, setMenuNode] = useState(null);
76
- const openSubmenu = sections
77
- .flatMap((section) => section.items)
78
- .find((item) => item.key === submenuKey && !!item.submenu);
79
- /** The row that leaves a submenu again - synthesized rather than asked of the caller, so every submenu
80
- * has the same way back however it was built. */
81
- const backItem = {
82
- key: "__back",
83
- label: `Back to ${ariaLabel}`,
84
- keepOpen: true,
85
- onSelect: () => leaveSubmenu(),
86
- };
87
- const shownSections = openSubmenu
88
- ? [{ key: "__back", items: [backItem] }, ...openSubmenu.submenu]
89
- : sections;
90
- const items = shownSections.flatMap((section) => section.items);
91
- const enabledIndexes = items.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
92
- /** The row focus starts on: the current choice where there is one, else the first row that can take focus. */
93
- function initialActiveIndex(within) {
94
- const checked = within.findIndex((item) => item.checked === true && !item.disabled);
95
- const enabled = within.map((item, index) => (item.disabled ? -1 : index)).filter((index) => index !== -1);
96
- return checked === -1 ? (enabled[0] ?? 0) : checked;
97
- }
98
- function openMenu() {
99
- setSubmenuKey(null);
100
- setActiveIndex(initialActiveIndex(sections.flatMap((section) => section.items)));
101
- setOpen(true);
102
- onOpenChange?.(true);
103
- }
104
- function closeMenu(returnFocus) {
105
- setOpen(false);
106
- setSubmenuKey(null);
107
- onOpenChange?.(false);
108
- if (returnFocus) {
109
- triggerRef.current?.focus();
110
- }
111
- }
112
- function enterSubmenu(item) {
113
- setSubmenuKey(item.key);
114
- const rows = item.submenu.flatMap((section) => section.items);
115
- // Past the Back row, onto the first row of the submenu itself - or, when none of them can take focus (every row is unavailable right now), onto
116
- // the Back row, so the keyboard still has a place to be.
117
- setActiveIndex(rows.some((row) => !row.disabled) ? 1 + initialActiveIndex(rows) : 0);
118
- }
119
- function leaveSubmenu() {
120
- // Back on the row the submenu was opened from.
121
- const parentIndex = sections.flatMap((section) => section.items).findIndex((item) => item.key === submenuKey);
122
- setSubmenuKey(null);
123
- setActiveIndex(parentIndex);
124
- }
125
- /** What a row does when it is chosen: open its submenu, or run it and close unless it asked to stay. */
126
- function selectItem(item) {
127
- if (item.submenu) {
128
- enterSubmenu(item);
129
- return;
130
- }
131
- item.onSelect();
132
- if (!item.keepOpen) {
133
- closeMenu(true);
134
- }
135
- }
136
- // Roving focus: only the active row is tabbable, and it takes DOM focus whenever it changes, so the
137
- // arrow keys move the screen reader's cursor too rather than only a visual highlight.
138
- useEffect(() => {
139
- if (open && menuNode) {
140
- // A disabled row can't take focus, so a menu with nothing enabled focuses its own container -
141
- // otherwise the keys it handles would go to whatever had focus before it opened.
142
- if (enabledIndexes.length === 0) {
143
- menuNode.focus();
144
- }
145
- else {
146
- itemRefs.current[activeIndex]?.focus();
147
- }
148
- }
149
- // `submenuKey` too: drilling in or out can land on the same index in the other level's list, and
150
- // the row that index *means* is a different button, which the effect must move focus to.
151
- }, [open, activeIndex, submenuKey, menuNode]);
152
- function moveActive(delta) {
153
- if (enabledIndexes.length === 0) {
154
- return;
155
- }
156
- const position = enabledIndexes.indexOf(activeIndex);
157
- setActiveIndex(enabledIndexes[(position + delta + enabledIndexes.length) % enabledIndexes.length]);
158
- }
159
- function handleMenuKeyDown(e) {
160
- if (e.key === "ArrowDown") {
161
- e.preventDefault();
162
- moveActive(1);
163
- }
164
- else if (e.key === "ArrowUp") {
165
- e.preventDefault();
166
- moveActive(-1);
167
- }
168
- else if (e.key === "Home") {
169
- e.preventDefault();
170
- setActiveIndex(enabledIndexes[0] ?? 0);
171
- }
172
- else if (e.key === "End") {
173
- e.preventDefault();
174
- setActiveIndex(enabledIndexes[enabledIndexes.length - 1] ?? 0);
175
- }
176
- else if (e.key === "ArrowRight" && items[activeIndex]?.submenu) {
177
- e.preventDefault();
178
- enterSubmenu(items[activeIndex]);
179
- }
180
- else if ((e.key === "ArrowLeft" || e.key === "Escape") && openSubmenu) {
181
- // ARIA's own submenu behaviour: Escape leaves the submenu for its parent menu rather than
182
- // dismissing the whole thing.
183
- e.preventDefault();
184
- leaveSubmenu();
185
- }
186
- else if (e.key === "Escape" || e.key === "Tab") {
187
- e.preventDefault();
188
- closeMenu(true);
189
- }
190
- }
191
- function handleTriggerKeyDown(e) {
192
- if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
193
- e.preventDefault();
194
- openMenu();
195
- }
196
- }
197
- let itemIndex = -1;
198
- return (_jsxs(_Fragment, { children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-haspopup": "menu", "aria-expanded": open, "aria-label": ariaLabel, title: title, disabled: disabled, onClick: () => (open ? closeMenu(false) : openMenu()), onKeyDown: handleTriggerKeyDown, className: iconOnly
199
- ? className
200
- : [
201
- // `min-w-0` so a long label ("Filter: Has attachments") truncates instead of pushing
202
- // whatever sits beside it in the toolbar off the edge of a 384px list column.
203
- "inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-md text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent",
204
- className,
205
- ].join(" "), children: [icon, !iconOnly && _jsx("span", { className: "truncate", children: label }), !iconOnly && _jsx(HiChevronDown, { size: 14, "aria-hidden": "true", className: "shrink-0 text-text-muted" })] }), open && (_jsx(PopoverPortal, { anchorRef: triggerRef, onClose: () => closeMenu(false), width: width, height: menuHeight(shownSections, width), "aria-label": ariaLabel, children: _jsx("div", { ref: setMenuNode, role: "menu", tabIndex: -1, "aria-label": ariaLabel, onKeyDown: handleMenuKeyDown, className: "flex-1 overflow-y-auto py-1", children: shownSections.map((section, sectionIndex) => (_jsxs("div", { role: "group", "aria-label": section.label, className: sectionIndex > 0 ? "border-t border-border mt-1 pt-1" : "", children: [section.label && (_jsx("div", { "aria-hidden": "true", className: "h-6 flex items-center px-3 text-xs font-semibold uppercase tracking-wide text-text-muted", children: section.label })), section.items.map((item) => {
206
- itemIndex += 1;
207
- const index = itemIndex;
208
- const role = item.role ?? "menuitem";
209
- return (_jsxs("button", { ref: (node) => {
210
- itemRefs.current[index] = node;
211
- }, type: "button", role: role, disabled: item.disabled, title: item.title, tabIndex: index === activeIndex ? 0 : -1, ...(role === "menuitem"
212
- ? {}
213
- : { "aria-checked": item.checked === "mixed" ? "mixed" : !!item.checked }), ...(item.submenu ? { "aria-haspopup": "menu", "aria-expanded": false } : {}), onClick: () => selectItem(item), className: "w-full flex items-start gap-2 px-3 py-2 text-left text-sm text-text hover:bg-surface-alt disabled:opacity-50 disabled:hover:bg-transparent", children: [_jsxs("span", { className: "w-4 shrink-0 flex justify-center pt-0.5", children: [item.key === "__back" && (_jsx(HiChevronLeft, { size: 14, "aria-hidden": "true", className: "text-text-muted" })), item.checked === "mixed" ? (_jsx(HiMinus, { size: 14, "aria-hidden": "true", className: "text-primary-dark" })) : (item.checked && _jsx(HiCheck, { size: 14, "aria-hidden": "true", className: "text-primary-dark" }))] }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex h-5 items-center gap-1.5", children: [item.icon && (_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-text-muted", children: item.icon })), item.swatchColor && (_jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: item.swatchColor } })), _jsx("span", { className: "truncate", children: item.label })] }), item.description && (_jsx("span", { className: "block h-4 text-xs leading-4 text-text-muted truncate font-normal", children: item.description }))] }), item.submenu && (_jsx(HiChevronRight, { size: 14, "aria-hidden": "true", className: "shrink-0 mt-0.5 text-text-muted" }))] }, item.key));
214
- }), section.note && _jsx("p", { className: "px-3 py-1 text-xs leading-4 text-text-muted", children: section.note })] }, section.key))) }) }))] }));
215
- }
5
+ // `MenuButton` lives in `lib/components/menus` so shared form components (`ImageEditBadge`) can use it; this keeps the mail components' import path working.
6
+ export { default, menuHeight } from "../../../../lib/components/menus/MenuButton.js";
@@ -48,7 +48,7 @@ import LabelMenuButton from "./labelMenu.js";
48
48
  import Button from "../../../../lib/components/buttons/Button.js";
49
49
  import EncryptedBody from "./reading/EncryptedBody.js";
50
50
  import { ENCRYPTED_SUBJECT_PLACEHOLDER, displaySubject } from "./reading/EncryptedPreview.js";
51
- import { rememberDecrypted } from "../../mail/decryptedMessages.js";
51
+ import { mapWithConcurrency, rememberDecrypted } from "../../mail/decryptedMessages.js";
52
52
  import MessageBody, { BodySkeleton } from "./reading/MessageBody.js";
53
53
  import { BODY_FONT_STYLE, CardShell, SenderAvatar, SubjectCard } from "./reading/MessageCard.js";
54
54
  import MessageMoreMenu from "./reading/MessageMoreMenu.js";
@@ -210,6 +210,8 @@ function subjectCore(subject) {
210
210
  function sameSubject(subject, threadSubject) {
211
211
  return subjectCore(subject) === subjectCore(threadSubject);
212
212
  }
213
+ /** How many earlier messages of a conversation are loaded at once when printing it. */
214
+ const PRINT_CONCURRENCY = 4;
213
215
  function formatBytes(bytes) {
214
216
  if (bytes >= 1000000)
215
217
  return `${(bytes / 1000000).toFixed(1)} MB`;
@@ -779,10 +781,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
779
781
  (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
780
782
  ? security.attachments
781
783
  : undefined;
782
- // Any state that involves a signature shows the address actually signed for (the protected From when the message
784
+ // Any state with a valid signature shows the address actually signed for (the protected From when the message
783
785
  // carries one - it equals the outer From's address, or verification would have failed) next to the badge, never a
784
- // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com.
785
- const signatureShown = security !== null && security.state !== "unprotected" && security.state !== "encrypted";
786
+ // display name alone: anyone can put "ceo@corp.com" in the name of a message sent from x@corp-pay.com. A failed
787
+ // signature vouches for nothing, so its message shows the outer address like an unsigned one.
788
+ const signatureShown = security !== null && (verifiedOrSealed || UNVERIFIED_SIGNER_STATES.has(security.state));
786
789
  const senderAddress = (signatureShown ? extractAddresses(security.protectedHeaders?.from)[0] : undefined) ?? message.from.address;
787
790
  const senderName = message.from.displayName;
788
791
  const senderNameCheck = checkSenderName(senderName, senderAddress);
@@ -841,11 +844,12 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
841
844
  try {
842
845
  const content = bodyContent ?? (await fetchBodyContent(message.uid, message.version));
843
846
  const older = await olderConversationMessages(message, client).catch(() => []);
844
- const earlier = await Promise.all(older.map((other) => printableOlderMessage(other).catch(() => ({
847
+ // A few at a time: a long conversation is hundreds of messages, each of them several requests.
848
+ const earlier = await mapWithConcurrency(older, PRINT_CONCURRENCY, (other) => printableOlderMessage(other).catch(() => ({
845
849
  subject: other.subject || "(no subject)",
846
850
  headers: printHeaders(other, formatMailAddress(other.from)),
847
851
  content: { kind: "text", text: "This message could not be loaded, so it is not printed." },
848
- }))));
852
+ })));
849
853
  const printable = buildConversationPrintDocument(shownSubject, [
850
854
  {
851
855
  subject: shownSubject,
@@ -1,5 +1,8 @@
1
1
  import React from "react";
2
2
  import type { Editor } from "@tiptap/react";
3
+ /** What a typed link target is stored as. One without a scheme (`example.com`) would be a relative link, which readers' clients drop as dead text: a bare
4
+ * email address becomes `mailto:`, anything else that is not a path or an anchor `https://`. A target with a scheme is kept as it is. */
5
+ export declare function linkHref(typed: string): string;
3
6
  export interface ComposeToolbarProps {
4
7
  /** `null` before the editor has mounted client-side (see `RichTextEditor`'s `immediatelyRender: false`
5
8
  * doc comment) — every button is disabled in that state rather than the toolbar rendering nothing, so
@@ -6,6 +6,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import { Suspense, lazy, useRef, useState } from "react";
7
7
  import { BsArrowClockwise, BsArrowCounterclockwise, BsEraser, BsHighlighter, BsImage, BsIndent, BsJustify, BsLink45Deg, BsListOl, BsListUl, BsPaletteFill, BsTable, BsTextCenter, BsTextLeft, BsTextRight, BsTypeBold, BsTypeItalic, BsTypeStrikethrough, BsTypeUnderline, BsUnindent, } from "react-icons/bs";
8
8
  import GifPicker from "./GifPicker.js";
9
+ /** What a typed link target is stored as. One without a scheme (`example.com`) would be a relative link, which readers' clients drop as dead text: a bare
10
+ * email address becomes `mailto:`, anything else that is not a path or an anchor `https://`. A target with a scheme is kept as it is. */
11
+ export function linkHref(typed) {
12
+ const target = typed.trim();
13
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || /^[/#]/.test(target)) {
14
+ return target;
15
+ }
16
+ return /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(target) ? `mailto:${target}` : `https://${target}`;
17
+ }
9
18
  /** Loaded when the picker is first opened: the emoji list is a half-megabyte JSON file that nothing else needs. */
10
19
  const EmojiPicker = lazy(() => import("./EmojiPicker.js"));
11
20
  const FONT_FAMILIES = [
@@ -73,7 +82,7 @@ export default function ComposeToolbar({ editor, onUploadImage }) {
73
82
  run((ed) => ed.chain().focus().extendMarkRange("link").unsetLink().run());
74
83
  }
75
84
  else {
76
- run((ed) => ed.chain().focus().extendMarkRange("link").setLink({ href: linkUrl.trim() }).run());
85
+ run((ed) => ed.chain().focus().extendMarkRange("link").setLink({ href: linkHref(linkUrl) }).run());
77
86
  }
78
87
  setOpenPopup(null);
79
88
  }
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useEffect, useRef, useState } from "react";
7
7
  import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineArrowTopRightOnSquare, HiOutlineChevronDown, HiOutlineIdentification, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../../lib/util/api.js";
9
- import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, deleteAttachment, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
9
+ import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, forgetDraftImages, getMailbox, getMessage, listFolders, listMailboxes, deleteAttachment, uploadAttachment, } from "../../../../../lib/mail/mailApi.js";
10
10
  import { listMailSignatures } from "../../../../../lib/mail/mailSignaturesApi.js";
11
11
  import { buildComposeBodyHtml } from "../../../../../lib/mail/compose/composeQuoting.js";
12
12
  import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
@@ -223,6 +223,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
223
223
  /** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
224
224
  const uploadsInFlightRef = useRef(0);
225
225
  const uploadWaitersRef = useRef([]);
226
+ /** How many uploads have failed so far: a Send that waited on uploads compares it to see whether one of them did. */
227
+ const uploadFailuresRef = useRef(0);
228
+ /** Set while a Send waits for uploads: Close and Discard call it to cancel that send. */
229
+ const cancelUploadWaitRef = useRef(null);
230
+ /** The draft a plaintext save was last sent for, so a message that turns out to need encryption knows a copy may be on the server. */
231
+ const plaintextRequestedRef = useRef(null);
232
+ const [uploadsActive, setUploadsActive] = useState(false);
226
233
  /** The latest save request, if any - each save is chained after the one before it. Always settles (never
227
234
  * rejects), to the saved message, or `undefined` when that save failed. */
228
235
  const saveInFlightRef = useRef(Promise.resolve(undefined));
@@ -479,6 +486,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
479
486
  const saved = await saveInFlightRef.current;
480
487
  // Once it's out of `supersededDraftsRef` a landing save can't update `superseded.version` any more.
481
488
  const version = saved?.uid === superseded.uid ? saved.version : superseded.version;
489
+ forgetDraftImages(superseded.uid);
482
490
  try {
483
491
  await deleteMessage(superseded.uid, version, client);
484
492
  }
@@ -516,7 +524,8 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
516
524
  * the old draft is deleted only once the new one has been created. Recipients, subject, and body are
517
525
  * kept. */
518
526
  function handleFromChange(nextMailboxUid) {
519
- if (nextMailboxUid === mailboxUid || hasUploads) {
527
+ // An upload still on the wire counts: it would land on the draft this switch supersedes.
528
+ if (nextMailboxUid === mailboxUid || hasUploads || uploadsInFlightRef.current > 0) {
520
529
  return;
521
530
  }
522
531
  if (draft) {
@@ -590,12 +599,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
590
599
  uploadStarted();
591
600
  try {
592
601
  // An inline part with a Content-ID of its own: without one the server has nothing to point the message's `cid:` reference at.
593
- const attachment = await uploadAttachment(draft.uid, file, { inline: true });
602
+ const attachment = await uploadAttachment(draft.uid, file, { inline: true }, client);
594
603
  setHasUploads(true);
595
604
  void refreshDraftVersion(draft.uid);
596
605
  return attachmentContentUrl(attachment.uid);
597
606
  }
598
607
  catch (err) {
608
+ uploadFailuresRef.current += 1;
599
609
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
600
610
  return null;
601
611
  }
@@ -606,10 +616,12 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
606
616
  /** Send waits for every upload started before it: see `submit()`. */
607
617
  function uploadStarted() {
608
618
  uploadsInFlightRef.current += 1;
619
+ setUploadsActive(true);
609
620
  }
610
621
  function uploadFinished() {
611
622
  uploadsInFlightRef.current -= 1;
612
623
  if (uploadsInFlightRef.current === 0) {
624
+ setUploadsActive(false);
613
625
  for (const resolve of uploadWaitersRef.current.splice(0)) {
614
626
  resolve();
615
627
  }
@@ -645,13 +657,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
645
657
  try {
646
658
  for (const file of files) {
647
659
  try {
648
- const attachment = await uploadAttachment(draft.uid, file);
660
+ const attachment = await uploadAttachment(draft.uid, file, {}, client);
649
661
  setHasUploads(true);
650
662
  setAttachments((prev) => [...prev, attachment]);
651
663
  void refreshDraftVersion(draft.uid);
652
664
  }
653
665
  catch (err) {
654
666
  allAttached = false;
667
+ uploadFailuresRef.current += 1;
655
668
  setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
656
669
  }
657
670
  }
@@ -769,7 +782,25 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
769
782
  try {
770
783
  if (uploadsInFlightRef.current > 0) {
771
784
  setWaitingForUploads(true);
772
- await new Promise((resolve) => uploadWaitersRef.current.push(resolve));
785
+ const failuresBefore = uploadFailuresRef.current;
786
+ let cancelled = false;
787
+ await new Promise((resolve) => {
788
+ uploadWaitersRef.current.push(resolve);
789
+ cancelUploadWaitRef.current = () => {
790
+ cancelled = true;
791
+ resolve();
792
+ };
793
+ });
794
+ cancelUploadWaitRef.current = null;
795
+ // Close or Discard was pressed meanwhile: the message is not to be sent.
796
+ if (cancelled) {
797
+ return;
798
+ }
799
+ // An attachment the sender asked for did not arrive: say so rather than sending the message without it.
800
+ if (uploadFailuresRef.current > failuresBefore) {
801
+ setSendError("An attachment could not be uploaded, so the message was not sent. Remove it or try again.");
802
+ return;
803
+ }
773
804
  // The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
774
805
  await new Promise((resolve) => setTimeout(resolve, 0));
775
806
  setWaitingForUploads(false);
@@ -876,6 +907,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
876
907
  setSaveStatus("saved");
877
908
  return { message: before };
878
909
  }
910
+ plaintextRequestedRef.current = target.uid;
879
911
  try {
880
912
  const saved = await assembleDraft(target.uid, {
881
913
  to: parseAddresses(current.to),
@@ -924,6 +956,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
924
956
  const version = Math.max(current.version, saved?.uid === current.uid ? saved.version : 0);
925
957
  try {
926
958
  await deleteMessage(current.uid, version, client);
959
+ forgetDraftImages(current.uid);
927
960
  }
928
961
  catch (err) {
929
962
  if (!(err instanceof ApiRequestError) || (err.status !== 404 && err.status !== 409)) {
@@ -936,6 +969,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
936
969
  throw getErr;
937
970
  });
938
971
  if (!fresh) {
972
+ forgetDraftImages(current.uid);
939
973
  return;
940
974
  }
941
975
  // The version changed because another window sent or scheduled it - that message isn't ours to delete.
@@ -943,6 +977,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
943
977
  throw new ApiRequestError(NO_LONGER_A_DRAFT_MESSAGE, 409);
944
978
  }
945
979
  await deleteMessage(fresh.uid, fresh.version, client);
980
+ forgetDraftImages(current.uid);
946
981
  }
947
982
  }
948
983
  /**
@@ -951,6 +986,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
951
986
  * deleted, and a delete that fails keeps the window open with the error.
952
987
  */
953
988
  async function discardNow() {
989
+ cancelUploadWaitRef.current?.();
954
990
  finishedRef.current = true;
955
991
  pendingSaveRef.current = false;
956
992
  setClosePrompt(null);
@@ -990,6 +1026,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
990
1026
  * *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
991
1027
  * other message. A failed save keeps the window open with Discard / Keep editing. */
992
1028
  async function handleClose() {
1029
+ cancelUploadWaitRef.current?.();
993
1030
  if (!hasUserContent) {
994
1031
  void discardNow();
995
1032
  return;
@@ -1084,10 +1121,13 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1084
1121
  * uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
1085
1122
  function scrubSavedDraft(target) {
1086
1123
  lastSavedRef.current = null;
1124
+ plaintextRequestedRef.current = null;
1087
1125
  setSaveStatus("idle");
1088
1126
  const previous = saveInFlightRef.current;
1089
1127
  const scrub = (async () => {
1090
1128
  await previous;
1129
+ // A save that was on the wire has landed by now and recorded itself as the last one saved - which the scrub just replaced.
1130
+ lastSavedRef.current = null;
1091
1131
  try {
1092
1132
  // Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
1093
1133
  return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" }, client);
@@ -1106,7 +1146,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1106
1146
  }
1107
1147
  // Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
1108
1148
  useEffect(() => {
1109
- if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
1149
+ if (encryptionDecided && draft && (lastSavedRef.current?.startsWith(`${draft.uid}:`) || plaintextRequestedRef.current === draft.uid)) {
1110
1150
  scrubSavedDraft(draft);
1111
1151
  }
1112
1152
  }, [encryptionDecided, draft?.uid]);
@@ -1223,6 +1263,9 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1223
1263
  // isn't mistaken for an edit (and autosaved) just because the editor was focused or clicked.
1224
1264
  /** The quote has been added: unless the reader has typed something already, that is still exactly what compose seeded. */
1225
1265
  function handleQuoteAppended(normalized) {
1266
+ // Added for good: an editor mounted again (after a minimize and restore) is seeded from the body that already holds the quote, and
1267
+ // must not be handed it a second time.
1268
+ setSeededWithoutQuote(false);
1226
1269
  const latest = latestRef.current;
1227
1270
  if (latest.html === latest.seededHtml) {
1228
1271
  setHtml(normalized);
@@ -1247,8 +1290,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1247
1290
  // dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
1248
1291
  // a shortcut pressed while its button would be disabled is consumed and does nothing.
1249
1292
  // Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
1250
- // while an earlier press is still being handled it does nothing (`submit()` guards that itself).
1251
- const canSend = !!draft && !closing && !waitingForUploads;
1293
+ // while an earlier press is still being handled it does nothing (`submit()` guards that itself). A reply or forward waits for its quoted original
1294
+ // (at most `QUOTE_FETCH_TIMEOUT_MS`): sent sooner it would go without the quote, a forward without its content, a Reply All to the sender alone.
1295
+ // Nor before the editor is there (it waits for the signature): sent sooner a reply would go without its quote and signature.
1296
+ const canSend = !!draft && contentReady && !closing && !waitingForUploads && !quotePending;
1252
1297
  useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
1253
1298
  useShortcut(SHORTCUTS.compose.saveDraft, () => {
1254
1299
  // Autosave's own conditions: never a draft that must not be stored as plaintext.
@@ -1280,7 +1325,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1280
1325
  ].join(" "), children: [dropActive && (_jsxs("div", { "data-drop-overlay": "", className: "pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-primary bg-primary/10 text-center text-primary-dark", children: [_jsx("span", { className: "text-base font-semibold", children: "Drop files to attach them" }), _jsx("span", { className: "text-sm", children: "Pictures go into the message where the cursor is" })] })), !inline && !isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: ["h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white", inline ? "" : "cursor-pointer"].join(" "), onClick: inline ? undefined : onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [inline ? (_jsx(HeaderButton, { label: "Pop out", onClick: () => onPopOut?.(), icon: HiOutlineArrowTopRightOnSquare })) : (_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus })), !inline && !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
1281
1326
  // Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
1282
1327
  // (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
1283
- value: mailboxUid, disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1328
+ value: mailboxUid, disabled: hasUploads || uploadsActive, title: hasUploads || uploadsActive ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
1284
1329
  // De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
1285
1330
  // fields (or one field twice) naming the same mailbox in different casing now resolve to
1286
1331
  // one shared status object (see `addressCacheKey()`'s own doc comment), so without this
@@ -1302,5 +1347,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
1302
1347
  return null;
1303
1348
  }
1304
1349
  return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
1305
- })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsxs("li", { className: "inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted", children: [attachment.filename, _jsx("button", { type: "button", "aria-label": `Remove ${attachment.filename}`, title: `Remove ${attachment.filename}`, disabled: removingAttachmentUid === attachment.uid, onClick: () => void handleRemoveAttachment(attachment), className: "inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Attach my contact card", title: ownCardAttached ? "Your contact card is attached" : "Attach my contact card", "aria-pressed": ownCardAttached, disabled: !draft || !mailbox || ownCardAttached, onClick: () => void handleAttachOwnCard(), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineIdentification, { size: 18 }) }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1350
+ })(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized, editorRef: editorRef })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsxs("li", { className: "inline-flex items-center gap-1 text-xs font-medium py-1 pl-2.5 pr-1 rounded-pill bg-surface-alt text-text-muted", children: [attachment.filename, _jsx("button", { type: "button", "aria-label": `Remove ${attachment.filename}`, title: `Remove ${attachment.filename}`, disabled: removingAttachmentUid === attachment.uid, onClick: () => void handleRemoveAttachment(attachment), className: "inline-flex items-center justify-center rounded-pill p-0.5 hover:bg-border hover:text-text disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary", children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !canSend, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !canSend, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("button", { type: "button", "aria-label": "Attach my contact card", title: ownCardAttached ? "Your contact card is attached" : "Attach my contact card", "aria-pressed": ownCardAttached, disabled: !draft || !mailbox || ownCardAttached, onClick: () => void handleAttachOwnCard(), className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineIdentification, { size: 18 }) }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
1306
1351
  }
@@ -24,13 +24,19 @@ export default function GifPicker({ anchorRef, onSelect, onClose }) {
24
24
  useEffect(() => {
25
25
  setLoading(true);
26
26
  setError(null);
27
+ // A search that was still on the wire when the query changed (or the popup closed) is not heard: a slow answer to an earlier query
28
+ // must not replace the results of the one now shown.
29
+ let stale = false;
27
30
  const handle = setTimeout(() => {
28
31
  searchGifs(query)
29
- .then(setGifs)
30
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
31
- .finally(() => setLoading(false));
32
+ .then((found) => !stale && setGifs(found))
33
+ .catch((err) => !stale && setError(err instanceof ApiRequestError ? err.message : "Could not load GIFs."))
34
+ .finally(() => !stale && setLoading(false));
32
35
  }, SEARCH_DEBOUNCE_MS);
33
- return () => clearTimeout(handle);
36
+ return () => {
37
+ stale = true;
38
+ clearTimeout(handle);
39
+ };
34
40
  }, [query]);
35
41
  return (_jsxs(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: WIDTH, height: HEIGHT, "aria-label": "Insert GIF", children: [_jsx("div", { className: "p-2 border-b border-border shrink-0", children: _jsx("input", { type: "text", autoFocus: true, "aria-label": "Search GIFs", placeholder: "Search GIFs", value: query, onChange: (e) => setQuery(e.target.value), className: "w-full text-sm py-1.5 px-2 border border-border rounded-sm bg-surface focus:outline-none focus:border-primary" }) }), _jsx("div", { className: "flex-1 overflow-y-auto p-2", children: error ? (_jsx("p", { className: "text-xs text-danger", children: error })) : loading ? (_jsx("p", { className: "text-xs text-text-muted", children: "Loading\u2026" })) : gifs.length === 0 ? (_jsx("p", { className: "text-xs text-text-muted", children: "No GIFs found." })) : (_jsx("div", { className: "grid grid-cols-2 gap-1.5", children: gifs.map((gif) => (_jsx("button", { type: "button", title: gif.title, onClick: () => onSelect(gif.url), className: "rounded-sm overflow-hidden hover:opacity-80", children: _jsx("img", { src: gif.previewUrl, alt: gif.title, className: "w-full h-20 object-cover" }) }, gif.id))) })) })] }));
36
42
  }