@rapidmx/web-client 0.15.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 (189) 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 -164
  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/auth/adminAccess.ts +99 -99
  15. package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
  16. package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
  17. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
  18. package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
  19. package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
  20. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
  21. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
  22. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
  23. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
  24. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
  25. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
  26. package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
  27. package/apps/shared/components/admin/usePagedList.tsx +129 -129
  28. package/apps/shared/components/calendar/EventEditor.tsx +5 -10
  29. package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
  30. package/apps/shared/components/calendar/EventModal.tsx +206 -206
  31. package/apps/shared/components/calendar/GuestInput.tsx +100 -0
  32. package/apps/shared/components/calendar/MonthView.tsx +185 -185
  33. package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
  34. package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
  35. package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
  36. package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
  37. package/apps/shared/components/calendar/allDay.ts +124 -124
  38. package/apps/shared/components/calendar/eventForm.ts +2 -2
  39. package/apps/shared/components/calendar/eventFormat.ts +49 -22
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
  41. package/apps/shared/components/contacts/ContactForm.tsx +383 -383
  42. package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
  43. package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
  44. package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
  45. package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
  46. package/apps/shared/components/layout/AppShell.tsx +480 -480
  47. package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
  48. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
  49. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
  50. package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
  51. package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
  52. package/apps/shared/components/layout/UserMenu.tsx +439 -424
  53. package/apps/shared/components/mail/ConversationList.tsx +323 -323
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
  55. package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
  56. package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
  57. package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
  58. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
  59. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
  60. package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
  61. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
  62. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
  63. package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
  64. package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
  65. package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
  66. package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
  67. package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
  68. package/apps/shared/components/mail/verificationSeals.ts +125 -125
  69. package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
  70. package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
  71. package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
  72. package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
  73. package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
  74. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
  75. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
  76. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
  77. package/apps/shared/keyboard/dispatch.ts +124 -124
  78. package/apps/shared/keyboard/format.ts +89 -89
  79. package/apps/shared/keyboard/keymap.ts +114 -114
  80. package/apps/shared/keyboard/registry.ts +65 -65
  81. package/apps/shared/keyboard/targets.ts +79 -79
  82. package/apps/shared/mail/folderCounts.ts +17 -0
  83. package/apps/shared/mail/folderTree.ts +143 -143
  84. package/apps/shared/mail/listAllPages.ts +39 -39
  85. package/apps/shared/mail/newMailNotifications.ts +171 -171
  86. package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
  87. package/apps/shared/mail/outbox/sendJob.ts +19 -1
  88. package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
  89. package/apps/shared/mail/primaryMailbox.ts +71 -0
  90. package/apps/shared/mail/useCollapsedSections.ts +146 -0
  91. package/apps/shared/mail/useMailConnection.ts +205 -203
  92. package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
  93. package/apps/shared/mail/useMarkMessageRead.ts +47 -47
  94. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  95. package/apps/shared/navigation/AppRouter.tsx +300 -300
  96. package/apps/shared/navigation/routerContext.tsx +83 -83
  97. package/apps/shared/notifications/store.ts +560 -560
  98. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
  99. package/apps/shared/search/crossMailboxSearch.ts +169 -0
  100. package/apps/shared/search/localIndexBuilder.ts +481 -481
  101. package/apps/shared/signing/enrollmentStorage.ts +33 -33
  102. package/apps/shared/signing/enrollmentTracker.ts +385 -385
  103. package/apps/shared/signing/enrollmentView.ts +251 -251
  104. package/apps/shared/signing/useNow.ts +19 -19
  105. package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
  106. package/apps/shared/styles/app.css +396 -386
  107. package/apps/www/calendar/index.tsx +578 -571
  108. package/apps/www/contacts/[uid].tsx +109 -109
  109. package/apps/www/contacts/index.tsx +6 -0
  110. package/apps/www/index.tsx +2847 -2479
  111. package/apps/www/messages/[uid].tsx +135 -135
  112. package/apps/www/settings/auto-reply/index.tsx +133 -133
  113. package/apps/www/settings/encryption/index.tsx +1287 -1287
  114. package/apps/www/settings/filters/[uid].tsx +176 -176
  115. package/apps/www/settings/filters/index.tsx +102 -102
  116. package/apps/www/settings/filters/new/index.tsx +140 -140
  117. package/apps/www/settings/labels/index.tsx +204 -204
  118. package/apps/www/settings/privacy/index.tsx +492 -492
  119. package/apps/www/settings/profile/index.tsx +248 -248
  120. package/apps/www/settings/read-receipts/index.tsx +147 -147
  121. package/apps/www/settings/sharing/index.tsx +256 -256
  122. package/apps/www/settings/signatures/[uid].tsx +172 -172
  123. package/apps/www/settings/signatures/index.tsx +88 -88
  124. package/apps/www/settings/signatures/new/index.tsx +135 -135
  125. package/apps/www/tasks/index.tsx +649 -649
  126. package/dist/apps/admin/data-requests/index.js +1 -1
  127. package/dist/apps/admin/index.js +2 -1
  128. package/dist/apps/admin/mailboxes/[uid].js +12 -2
  129. package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
  130. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
  131. package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
  132. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
  133. package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
  134. package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
  136. package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
  137. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
  138. package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
  139. package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
  140. package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
  141. package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
  142. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
  143. package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
  144. package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
  145. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
  146. package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
  147. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  148. package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
  149. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
  150. package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
  151. package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
  152. package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
  153. package/dist/apps/shared/components/layout/UserMenu.js +6 -5
  154. package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
  155. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
  156. package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
  157. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
  158. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
  159. package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
  160. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
  161. package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
  162. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  163. package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
  164. package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
  165. package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
  166. package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
  167. package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
  168. package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
  169. package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
  170. package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
  171. package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
  172. package/dist/apps/shared/mail/folderCounts.js +12 -0
  173. package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
  174. package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
  175. package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
  176. package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
  177. package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
  178. package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
  179. package/dist/apps/shared/mail/primaryMailbox.js +61 -0
  180. package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
  181. package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
  182. package/dist/apps/shared/mail/useMailConnection.js +3 -1
  183. package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
  184. package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
  185. package/dist/apps/shared/styles/app.css +396 -386
  186. package/dist/apps/www/calendar/index.js +3 -2
  187. package/dist/apps/www/contacts/index.js +3 -1
  188. package/dist/apps/www/index.js +426 -189
  189. package/package.json +2 -2
@@ -1,79 +1,79 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
-
6
- /** `<input>` types that take no typed text, so a letter pressed on one is a shortcut, not input. (Radios and ranges use the arrows.) */
7
- const NON_TEXT_INPUT_TYPES = new Set(["button", "checkbox", "submit", "reset", "image", "file", "color"]);
8
-
9
- /** Roles whose members have keys of their own (arrows, Enter, Escape, type-ahead): a bare key pressed inside one belongs to the widget. */
10
- const WIDGET_SELECTOR = [
11
- "menu",
12
- "menubar",
13
- "listbox",
14
- "tablist",
15
- "tree",
16
- "grid",
17
- "radiogroup",
18
- "slider",
19
- "spinbutton",
20
- "combobox",
21
- ]
22
- .map((role) => `[role="${role}"]`)
23
- .join(",");
24
-
25
- /** Elements Enter (and Space) already does something on. */
26
- const ACTIVATABLE_SELECTOR = 'a[href], button, summary, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="tab"], [role="option"]';
27
-
28
- /** An open popup's trigger: Escape closes the popup, so it is not a shortcut then. */
29
- const OPEN_POPUP_TRIGGER = '[aria-haspopup][aria-expanded="true"], [role="combobox"][aria-expanded="true"]';
30
-
31
- function elementOf(target: EventTarget | null): Element | null {
32
- return target instanceof Element ? target : null;
33
- }
34
-
35
- /** Whether the event target takes typed text: a text field, a select, a textarea, or anything contenteditable (the rich-text editor). */
36
- export function isTextEntry(target: EventTarget | null): boolean {
37
- const element = elementOf(target);
38
- if (!element) {
39
- return false;
40
- }
41
- const tag = element.tagName;
42
- if (tag === "TEXTAREA" || tag === "SELECT") {
43
- return true;
44
- }
45
- if (tag === "INPUT") {
46
- return !NON_TEXT_INPUT_TYPES.has((element as HTMLInputElement).type);
47
- }
48
- return !!element.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"]');
49
- }
50
-
51
- /** Whether the target is inside a widget that reads bare keys itself (an open menu, a listbox, a tab list, ...). */
52
- export function isInWidget(target: EventTarget | null): boolean {
53
- return !!elementOf(target)?.closest(WIDGET_SELECTOR);
54
- }
55
-
56
- /** Whether Enter/Space on the target already activates it (a button, a link, a menu item). */
57
- export function isActivatable(target: EventTarget | null): boolean {
58
- return !!elementOf(target)?.closest(ACTIVATABLE_SELECTOR);
59
- }
60
-
61
- /** Whether the target is the trigger of a popup that is open right now (or the combobox that owns an open list). */
62
- export function isPopupOpenAt(target: EventTarget | null): boolean {
63
- return !!elementOf(target)?.closest(OPEN_POPUP_TRIGGER);
64
- }
65
-
66
- /** Whether a modal dialog (`aria-modal="true"`: react-shared's `Modal` and `Drawer`, the unlock prompts, ...) is on screen. */
67
- export function isModalOpen(doc: Document): boolean {
68
- return doc.querySelector('[aria-modal="true"]') !== null;
69
- }
70
-
71
- /** Whether the target is inside a popover that is a dialog of its own (the emoji and GIF pickers, the send-later picker): Escape is theirs, not a view's. A region that declares a shortcut scope (a compose window) is not one. */
72
- export function isInPopoverDialog(target: EventTarget | null): boolean {
73
- return !!elementOf(target)?.closest('[role="dialog"]:not([data-shortcut-scope])');
74
- }
75
-
76
- /** The `data-shortcut-scope` of the region the target is in (a compose window), if any. */
77
- export function regionScopeOf(target: EventTarget | null): string | undefined {
78
- return elementOf(target)?.closest("[data-shortcut-scope]")?.getAttribute("data-shortcut-scope") ?? undefined;
79
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** `<input>` types that take no typed text, so a letter pressed on one is a shortcut, not input. (Radios and ranges use the arrows.) */
7
+ const NON_TEXT_INPUT_TYPES = new Set(["button", "checkbox", "submit", "reset", "image", "file", "color"]);
8
+
9
+ /** Roles whose members have keys of their own (arrows, Enter, Escape, type-ahead): a bare key pressed inside one belongs to the widget. */
10
+ const WIDGET_SELECTOR = [
11
+ "menu",
12
+ "menubar",
13
+ "listbox",
14
+ "tablist",
15
+ "tree",
16
+ "grid",
17
+ "radiogroup",
18
+ "slider",
19
+ "spinbutton",
20
+ "combobox",
21
+ ]
22
+ .map((role) => `[role="${role}"]`)
23
+ .join(",");
24
+
25
+ /** Elements Enter (and Space) already does something on. */
26
+ const ACTIVATABLE_SELECTOR = 'a[href], button, summary, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="tab"], [role="option"]';
27
+
28
+ /** An open popup's trigger: Escape closes the popup, so it is not a shortcut then. */
29
+ const OPEN_POPUP_TRIGGER = '[aria-haspopup][aria-expanded="true"], [role="combobox"][aria-expanded="true"]';
30
+
31
+ function elementOf(target: EventTarget | null): Element | null {
32
+ return target instanceof Element ? target : null;
33
+ }
34
+
35
+ /** Whether the event target takes typed text: a text field, a select, a textarea, or anything contenteditable (the rich-text editor). */
36
+ export function isTextEntry(target: EventTarget | null): boolean {
37
+ const element = elementOf(target);
38
+ if (!element) {
39
+ return false;
40
+ }
41
+ const tag = element.tagName;
42
+ if (tag === "TEXTAREA" || tag === "SELECT") {
43
+ return true;
44
+ }
45
+ if (tag === "INPUT") {
46
+ return !NON_TEXT_INPUT_TYPES.has((element as HTMLInputElement).type);
47
+ }
48
+ return !!element.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="searchbox"]');
49
+ }
50
+
51
+ /** Whether the target is inside a widget that reads bare keys itself (an open menu, a listbox, a tab list, ...). */
52
+ export function isInWidget(target: EventTarget | null): boolean {
53
+ return !!elementOf(target)?.closest(WIDGET_SELECTOR);
54
+ }
55
+
56
+ /** Whether Enter/Space on the target already activates it (a button, a link, a menu item). */
57
+ export function isActivatable(target: EventTarget | null): boolean {
58
+ return !!elementOf(target)?.closest(ACTIVATABLE_SELECTOR);
59
+ }
60
+
61
+ /** Whether the target is the trigger of a popup that is open right now (or the combobox that owns an open list). */
62
+ export function isPopupOpenAt(target: EventTarget | null): boolean {
63
+ return !!elementOf(target)?.closest(OPEN_POPUP_TRIGGER);
64
+ }
65
+
66
+ /** Whether a modal dialog (`aria-modal="true"`: react-shared's `Modal` and `Drawer`, the unlock prompts, ...) is on screen. */
67
+ export function isModalOpen(doc: Document): boolean {
68
+ return doc.querySelector('[aria-modal="true"]') !== null;
69
+ }
70
+
71
+ /** Whether the target is inside a popover that is a dialog of its own (the emoji and GIF pickers, the send-later picker): Escape is theirs, not a view's. A region that declares a shortcut scope (a compose window) is not one. */
72
+ export function isInPopoverDialog(target: EventTarget | null): boolean {
73
+ return !!elementOf(target)?.closest('[role="dialog"]:not([data-shortcut-scope])');
74
+ }
75
+
76
+ /** The `data-shortcut-scope` of the region the target is in (a compose window), if any. */
77
+ export function regionScopeOf(target: EventTarget | null): string | undefined {
78
+ return elementOf(target)?.closest("[data-shortcut-scope]")?.getAttribute("data-shortcut-scope") ?? undefined;
79
+ }
@@ -96,6 +96,23 @@ export function inboxUnreadTotal(mailboxFolders: { folders: Folder[] }[], counts
96
96
  );
97
97
  }
98
98
 
99
+ /**
100
+ * The folder types whose unread count is left out of a collapsed sidebar section's heading badge: Drafts and Outbox (their badges
101
+ * count messages held, not unread mail), and Sent Items, Deleted Items and Junk Email (an unread count there is noise, not mail
102
+ * to notice). It matches the folders `badgeFor()` shows no unread badge for; every other type - Inbox, Archive, the user's own
103
+ * folders - counts, so mail that a rule filed out of the Inbox is not overlooked in a hidden section. Change it here to change that.
104
+ */
105
+ export const UNREAD_BADGE_EXCLUDED_FOLDER_TYPES: readonly FolderType[] = ["drafts", "outbox", "sent_items", "deleted_items", "junk"];
106
+
107
+ /** The unread messages in the `folders` that carry an unread count (all but `UNREAD_BADGE_EXCLUDED_FOLDER_TYPES`) - what a collapsed
108
+ * sidebar section shows on its heading, so new mail in a hidden section is still noticed. */
109
+ export function unreadBadgeTotal(folders: readonly Folder[], counts: Record<string, FolderCount>): number {
110
+ return folders.reduce(
111
+ (total, folder) => (UNREAD_BADGE_EXCLUDED_FOLDER_TYPES.includes(folder.type) ? total : total + countOfFolder(folder, counts).unread),
112
+ 0,
113
+ );
114
+ }
115
+
99
116
  /** How long after the last change of this page's own has finished a server-published count is trusted again: one published
100
117
  * about an earlier state of the folder could otherwise arrive late and put back what the change just took away. */
101
118
  export const COUNT_QUIET_MS = 1_500;
@@ -1,143 +1,143 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
6
- import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
7
-
8
- /**
9
- * The sidebar's order of a mailbox's well-known folders; anything else (`user`) follows, alphabetically. Also the order push channels are asked
10
- * for (`pushChannelsFor()`), so when the server's channel cap bites it is the folders lowest in this list that go without live updates.
11
- */
12
- export const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "deleted_items", "junk", "archive"];
13
-
14
- /**
15
- * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
16
- * Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
17
- * to just the mail ones itself, or those other apps' folders leak into its sidebar.
18
- */
19
- export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
20
-
21
- /** The well-known folders the server creates lazily around a first send: what the sidebar shows a placeholder for while a message is on its way. */
22
- export const SEND_FOLDER_TYPES = ["outbox", "sent_items"] as const;
23
-
24
- export function isMailFolder(folder: { type: string }): boolean {
25
- return MAIL_FOLDER_TYPES.has(folder.type);
26
- }
27
-
28
- function sortKey(type: string): number {
29
- const index = FOLDER_ORDER.indexOf(type);
30
- return index === -1 ? FOLDER_ORDER.length : index;
31
- }
32
-
33
- /** Well-known folders in `FOLDER_ORDER`, then the rest by name. A new array; the input is left alone. */
34
- export function sortFolders(folders: Folder[]): Folder[] {
35
- return [...folders].sort((a, b) => sortKey(a.type) - sortKey(b.type) || a.name.localeCompare(b.name));
36
- }
37
-
38
- /** What a sidebar row is: a folder that exists, or (`placeholder`) one that is expected to but has not arrived yet. */
39
- export type FolderRow = { kind: "folder"; folder: Folder } | { kind: "placeholder"; type: string };
40
-
41
- /**
42
- * The rows of one mailbox's sidebar section, in order: every folder that exists, and - while `sending` messages are on their way and the
43
- * server has not made the folder yet - a placeholder for each of the Outbox and Sent Items that is missing. The one place a folder that has not
44
- * arrived is drawn, so it can never appear twice: the moment the real folder is in `folders` its placeholder is not built.
45
- */
46
- export function folderRows(folders: Folder[], sending: number): FolderRow[] {
47
- const rows: FolderRow[] = sortFolders(folders).map((folder) => ({ kind: "folder", folder }));
48
- if (sending > 0) {
49
- for (const type of SEND_FOLDER_TYPES) {
50
- if (!folders.some((folder) => folder.type === type)) {
51
- rows.push({ kind: "placeholder", type });
52
- }
53
- }
54
- rows.sort((a, b) => sortKey(rowType(a)) - sortKey(rowType(b)));
55
- }
56
- return rows;
57
- }
58
-
59
- function rowType(row: FolderRow): string {
60
- return row.kind === "folder" ? row.folder.type : row.type;
61
- }
62
-
63
- /** What a folder can be changed to by an event or a refetch: everything about it except the counts, which the counts overlay owns. */
64
- export type FolderChange = Partial<Pick<Folder, "name" | "type" | "parentFolderUid" | "color">> & { uid: string; mailboxUid?: string };
65
-
66
- const CHANGEABLE = ["name", "type", "parentFolderUid", "color"] as const;
67
-
68
- /**
69
- * Files `folder` in the tree: added under its mailbox when unknown (a type Mail does not list is ignored), else its name, type, parent and
70
- * colour brought up to date (a folder that became a type Mail does not list leaves the tree). Returns `entries` itself - the same array - when
71
- * nothing changed, because everything on screen that depends on the tree starts over when it is a new one.
72
- */
73
- export function upsertFolder(entries: MailboxFolders[], folder: Folder | FolderChange): MailboxFolders[] {
74
- let changed = false;
75
- const next = entries.map((entry) => {
76
- const existing = entry.folders.find((f) => f.uid === folder.uid);
77
- if (!existing) {
78
- // Only a whole folder can be added: an update for one that is not known has nothing to add it with (the refetch it causes will).
79
- if (entry.mailbox.uid !== folder.mailboxUid || !isFullFolder(folder) || !isMailFolder(folder)) {
80
- return entry;
81
- }
82
- // A well-known folder is one per mailbox - the server treats the oldest as the real one - so a second of the same type (made by a client that did
83
- // not know it existed) is not a second row: the older of the two is the one shown.
84
- const sameType = folder.type === "user" ? undefined : entry.folders.find((f) => f.type === folder.type);
85
- if (sameType) {
86
- if (!isOlder(folder, sameType)) {
87
- return entry;
88
- }
89
- changed = true;
90
- return { ...entry, folders: entry.folders.map((f) => (f === sameType ? folder : f)) };
91
- }
92
- changed = true;
93
- return { ...entry, folders: [...entry.folders, folder] };
94
- }
95
- const patch: Partial<Folder> = {};
96
- for (const key of CHANGEABLE) {
97
- if (key in folder && folder[key] !== undefined && folder[key] !== existing[key]) {
98
- (patch as Record<string, unknown>)[key] = folder[key];
99
- }
100
- }
101
- if (Object.keys(patch).length === 0) {
102
- return entry;
103
- }
104
- changed = true;
105
- const updated = { ...existing, ...patch };
106
- return { ...entry, folders: isMailFolder(updated) ? entry.folders.map((f) => (f === existing ? updated : f)) : entry.folders.filter((f) => f !== existing) };
107
- });
108
- return changed ? next : entries;
109
- }
110
-
111
- function isFullFolder(folder: Folder | FolderChange): folder is Folder {
112
- return typeof folder.name === "string" && typeof folder.type === "string";
113
- }
114
-
115
- /** Whether `a` was created before `b`: only when both say when. */
116
- function isOlder(a: Folder, b: Folder): boolean {
117
- // (A folder from the network may lack the date: `Date.parse(undefined)` is NaN, which is "cannot tell".)
118
- const first = Date.parse(a.dateCreated);
119
- const second = Date.parse(b.dateCreated);
120
- return Number.isFinite(first) && Number.isFinite(second) && first < second;
121
- }
122
-
123
- /** Takes a deleted folder out of the tree. The same array when it was not in it. */
124
- export function removeFolder(entries: MailboxFolders[], folderUid: string): MailboxFolders[] {
125
- if (!entries.some((entry) => entry.folders.some((folder) => folder.uid === folderUid))) {
126
- return entries;
127
- }
128
- return entries.map((entry) => (entry.folders.some((folder) => folder.uid === folderUid) ? { ...entry, folders: entry.folders.filter((folder) => folder.uid !== folderUid) } : entry));
129
- }
130
-
131
- /**
132
- * Brings one mailbox's part of the tree up to date with a listing the server just gave (`listFolders()`): folders it has that the tree lacks
133
- * are added, changed names and types are taken. Nothing is removed - a listing that began before a folder was created here would take it out
134
- * again, and deletions arrive as events - and the counts are not touched (see `useFolderCounts()`). The same array when there is nothing to do.
135
- */
136
- export function reconcileFolders(entries: MailboxFolders[], listed: Folder[]): MailboxFolders[] {
137
- return listed.reduce((tree, folder) => upsertFolder(tree, folder), entries);
138
- }
139
-
140
- /** Every folder uid in the tree. */
141
- export function knownFolderUids(entries: MailboxFolders[]): Set<string> {
142
- return new Set(entries.flatMap((entry) => entry.folders.map((folder) => folder.uid)));
143
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
6
+ import type { MailboxFolders } from "../components/mail/layout/MailShell.js";
7
+
8
+ /**
9
+ * The sidebar's order of a mailbox's well-known folders; anything else (`user`) follows, alphabetically. Also the order push channels are asked
10
+ * for (`pushChannelsFor()`), so when the server's channel cap bites it is the folders lowest in this list that go without live updates.
11
+ */
12
+ export const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "deleted_items", "junk", "archive"];
13
+
14
+ /**
15
+ * A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see `CalendarShell`/`ContactsShell`/`TasksShell`), not
16
+ * Mail - `listFolders()` returns every well-known folder for the mailbox regardless of which app owns it, so Mail's own folder tree filters down
17
+ * to just the mail ones itself, or those other apps' folders leak into its sidebar.
18
+ */
19
+ export const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
20
+
21
+ /** The well-known folders the server creates lazily around a first send: what the sidebar shows a placeholder for while a message is on its way. */
22
+ export const SEND_FOLDER_TYPES = ["outbox", "sent_items"] as const;
23
+
24
+ export function isMailFolder(folder: { type: string }): boolean {
25
+ return MAIL_FOLDER_TYPES.has(folder.type);
26
+ }
27
+
28
+ function sortKey(type: string): number {
29
+ const index = FOLDER_ORDER.indexOf(type);
30
+ return index === -1 ? FOLDER_ORDER.length : index;
31
+ }
32
+
33
+ /** Well-known folders in `FOLDER_ORDER`, then the rest by name. A new array; the input is left alone. */
34
+ export function sortFolders(folders: Folder[]): Folder[] {
35
+ return [...folders].sort((a, b) => sortKey(a.type) - sortKey(b.type) || a.name.localeCompare(b.name));
36
+ }
37
+
38
+ /** What a sidebar row is: a folder that exists, or (`placeholder`) one that is expected to but has not arrived yet. */
39
+ export type FolderRow = { kind: "folder"; folder: Folder } | { kind: "placeholder"; type: string };
40
+
41
+ /**
42
+ * The rows of one mailbox's sidebar section, in order: every folder that exists, and - while `sending` messages are on their way and the
43
+ * server has not made the folder yet - a placeholder for each of the Outbox and Sent Items that is missing. The one place a folder that has not
44
+ * arrived is drawn, so it can never appear twice: the moment the real folder is in `folders` its placeholder is not built.
45
+ */
46
+ export function folderRows(folders: Folder[], sending: number): FolderRow[] {
47
+ const rows: FolderRow[] = sortFolders(folders).map((folder) => ({ kind: "folder", folder }));
48
+ if (sending > 0) {
49
+ for (const type of SEND_FOLDER_TYPES) {
50
+ if (!folders.some((folder) => folder.type === type)) {
51
+ rows.push({ kind: "placeholder", type });
52
+ }
53
+ }
54
+ rows.sort((a, b) => sortKey(rowType(a)) - sortKey(rowType(b)));
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ function rowType(row: FolderRow): string {
60
+ return row.kind === "folder" ? row.folder.type : row.type;
61
+ }
62
+
63
+ /** What a folder can be changed to by an event or a refetch: everything about it except the counts, which the counts overlay owns. */
64
+ export type FolderChange = Partial<Pick<Folder, "name" | "type" | "parentFolderUid" | "color">> & { uid: string; mailboxUid?: string };
65
+
66
+ const CHANGEABLE = ["name", "type", "parentFolderUid", "color"] as const;
67
+
68
+ /**
69
+ * Files `folder` in the tree: added under its mailbox when unknown (a type Mail does not list is ignored), else its name, type, parent and
70
+ * colour brought up to date (a folder that became a type Mail does not list leaves the tree). Returns `entries` itself - the same array - when
71
+ * nothing changed, because everything on screen that depends on the tree starts over when it is a new one.
72
+ */
73
+ export function upsertFolder(entries: MailboxFolders[], folder: Folder | FolderChange): MailboxFolders[] {
74
+ let changed = false;
75
+ const next = entries.map((entry) => {
76
+ const existing = entry.folders.find((f) => f.uid === folder.uid);
77
+ if (!existing) {
78
+ // Only a whole folder can be added: an update for one that is not known has nothing to add it with (the refetch it causes will).
79
+ if (entry.mailbox.uid !== folder.mailboxUid || !isFullFolder(folder) || !isMailFolder(folder)) {
80
+ return entry;
81
+ }
82
+ // A well-known folder is one per mailbox - the server treats the oldest as the real one - so a second of the same type (made by a client that did
83
+ // not know it existed) is not a second row: the older of the two is the one shown.
84
+ const sameType = folder.type === "user" ? undefined : entry.folders.find((f) => f.type === folder.type);
85
+ if (sameType) {
86
+ if (!isOlder(folder, sameType)) {
87
+ return entry;
88
+ }
89
+ changed = true;
90
+ return { ...entry, folders: entry.folders.map((f) => (f === sameType ? folder : f)) };
91
+ }
92
+ changed = true;
93
+ return { ...entry, folders: [...entry.folders, folder] };
94
+ }
95
+ const patch: Partial<Folder> = {};
96
+ for (const key of CHANGEABLE) {
97
+ if (key in folder && folder[key] !== undefined && folder[key] !== existing[key]) {
98
+ (patch as Record<string, unknown>)[key] = folder[key];
99
+ }
100
+ }
101
+ if (Object.keys(patch).length === 0) {
102
+ return entry;
103
+ }
104
+ changed = true;
105
+ const updated = { ...existing, ...patch };
106
+ return { ...entry, folders: isMailFolder(updated) ? entry.folders.map((f) => (f === existing ? updated : f)) : entry.folders.filter((f) => f !== existing) };
107
+ });
108
+ return changed ? next : entries;
109
+ }
110
+
111
+ function isFullFolder(folder: Folder | FolderChange): folder is Folder {
112
+ return typeof folder.name === "string" && typeof folder.type === "string";
113
+ }
114
+
115
+ /** Whether `a` was created before `b`: only when both say when. */
116
+ function isOlder(a: Folder, b: Folder): boolean {
117
+ // (A folder from the network may lack the date: `Date.parse(undefined)` is NaN, which is "cannot tell".)
118
+ const first = Date.parse(a.dateCreated);
119
+ const second = Date.parse(b.dateCreated);
120
+ return Number.isFinite(first) && Number.isFinite(second) && first < second;
121
+ }
122
+
123
+ /** Takes a deleted folder out of the tree. The same array when it was not in it. */
124
+ export function removeFolder(entries: MailboxFolders[], folderUid: string): MailboxFolders[] {
125
+ if (!entries.some((entry) => entry.folders.some((folder) => folder.uid === folderUid))) {
126
+ return entries;
127
+ }
128
+ return entries.map((entry) => (entry.folders.some((folder) => folder.uid === folderUid) ? { ...entry, folders: entry.folders.filter((folder) => folder.uid !== folderUid) } : entry));
129
+ }
130
+
131
+ /**
132
+ * Brings one mailbox's part of the tree up to date with a listing the server just gave (`listFolders()`): folders it has that the tree lacks
133
+ * are added, changed names and types are taken. Nothing is removed - a listing that began before a folder was created here would take it out
134
+ * again, and deletions arrive as events - and the counts are not touched (see `useFolderCounts()`). The same array when there is nothing to do.
135
+ */
136
+ export function reconcileFolders(entries: MailboxFolders[], listed: Folder[]): MailboxFolders[] {
137
+ return listed.reduce((tree, folder) => upsertFolder(tree, folder), entries);
138
+ }
139
+
140
+ /** Every folder uid in the tree. */
141
+ export function knownFolderUids(entries: MailboxFolders[]): Set<string> {
142
+ return new Set(entries.flatMap((entry) => entry.folders.map((folder) => folder.uid)));
143
+ }
@@ -1,39 +1,39 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
-
6
- /** Page size the list pages (Contacts, Tasks) request per call - restapi caps `limit` at 500. */
7
- export const LIST_PAGE_SIZE = 500;
8
-
9
- /** Safety cap on pages fetched - stops a server that ignores `page` (and keeps returning a full first
10
- * page) from looping forever. 40 pages x 500 = 20,000 items. */
11
- export const MAX_LIST_PAGES = 40;
12
-
13
- export interface ListAllPagesResult<T> {
14
- items: T[];
15
- /** `true` when fetching stopped at `maxPages` with the last page still full - there may be more items
16
- * the caller isn't showing, so it should say so rather than presenting `items` as the whole list. */
17
- truncated: boolean;
18
- }
19
-
20
- /**
21
- * Fetches every page of a zero-based `limit`/`page` list endpoint, stopping at the first short page
22
- * (fewer than `pageSize` items) - so a folder with more items than one page holds is shown in full
23
- * rather than silently cut off at the first page. Stops at `maxPages` regardless, reporting `truncated`.
24
- */
25
- export async function listAllPages<T>(
26
- fetchPage: (page: number) => Promise<T[]>,
27
- pageSize: number = LIST_PAGE_SIZE,
28
- maxPages: number = MAX_LIST_PAGES,
29
- ): Promise<ListAllPagesResult<T>> {
30
- const items: T[] = [];
31
- for (let page = 0; page < maxPages; page++) {
32
- const batch = await fetchPage(page);
33
- items.push(...batch);
34
- if (batch.length < pageSize) {
35
- return { items, truncated: false };
36
- }
37
- }
38
- return { items, truncated: true };
39
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Page size the list pages (Contacts, Tasks) request per call - restapi caps `limit` at 500. */
7
+ export const LIST_PAGE_SIZE = 500;
8
+
9
+ /** Safety cap on pages fetched - stops a server that ignores `page` (and keeps returning a full first
10
+ * page) from looping forever. 40 pages x 500 = 20,000 items. */
11
+ export const MAX_LIST_PAGES = 40;
12
+
13
+ export interface ListAllPagesResult<T> {
14
+ items: T[];
15
+ /** `true` when fetching stopped at `maxPages` with the last page still full - there may be more items
16
+ * the caller isn't showing, so it should say so rather than presenting `items` as the whole list. */
17
+ truncated: boolean;
18
+ }
19
+
20
+ /**
21
+ * Fetches every page of a zero-based `limit`/`page` list endpoint, stopping at the first short page
22
+ * (fewer than `pageSize` items) - so a folder with more items than one page holds is shown in full
23
+ * rather than silently cut off at the first page. Stops at `maxPages` regardless, reporting `truncated`.
24
+ */
25
+ export async function listAllPages<T>(
26
+ fetchPage: (page: number) => Promise<T[]>,
27
+ pageSize: number = LIST_PAGE_SIZE,
28
+ maxPages: number = MAX_LIST_PAGES,
29
+ ): Promise<ListAllPagesResult<T>> {
30
+ const items: T[] = [];
31
+ for (let page = 0; page < maxPages; page++) {
32
+ const batch = await fetchPage(page);
33
+ items.push(...batch);
34
+ if (batch.length < pageSize) {
35
+ return { items, truncated: false };
36
+ }
37
+ }
38
+ return { items, truncated: true };
39
+ }