@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,129 +1,129 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
-
9
- /** Page size for the async-request lists (the server returns them newest first and caps `limit` at 500). */
10
- export const REQUEST_LIST_PAGE_SIZE = 50;
11
-
12
- export interface PagedList<T> {
13
- items: T[];
14
- /** `true` until the first page has loaded (or failed). */
15
- loading: boolean;
16
- loadError: string | null;
17
- /** Whether the last page fetched was full, so another may follow. */
18
- hasMore: boolean;
19
- loadingMore: boolean;
20
- /** Re-fetches from the first page, dropping anything loaded beyond it. */
21
- reload: () => Promise<void>;
22
- /** Fetches the next page and appends what's new in it. */
23
- loadMore: () => Promise<void>;
24
- /** Replaces one already-loaded item in place (e.g. with an action's response). */
25
- replaceItem: (item: T) => void;
26
- }
27
-
28
- /**
29
- * A newest-first list fetched a page at a time with "Load more". Only the most recently started `reload()`'s
30
- * responses are applied, so a slow earlier fetch (e.g. the mount fetch racing a reload after a create) can't
31
- * overwrite a newer list, and a "Load more" that was in flight during a reload is dropped.
32
- *
33
- * Paging is by offset, so "Load more" re-reads the previous page alongside the next one and continues after the last
34
- * row already shown: rows created since shift older rows onto later pages (repeats are skipped), and rows deleted
35
- * since shift them onto earlier pages (which a plain next-page fetch would silently skip). If that row can't be found
36
- * in the two pages at all (it was deleted, or more than a page of rows came or went), the list reloads from the start.
37
- */
38
- export function usePagedList<T extends { uid: string }>(
39
- fetchPage: (params: { limit: number; page: number }) => Promise<T[]>,
40
- errorMessage: string,
41
- ): PagedList<T> {
42
- const pageSize = REQUEST_LIST_PAGE_SIZE;
43
- const [items, setItems] = useState<T[]>([]);
44
- const [loading, setLoading] = useState(true);
45
- const [loadError, setLoadError] = useState<string | null>(null);
46
- const [hasMore, setHasMore] = useState(false);
47
- const [loadingMore, setLoadingMore] = useState(false);
48
- const generation = useRef(0);
49
- const nextPage = useRef(0);
50
- const loaded = useRef<T[]>(items);
51
- loaded.current = items;
52
-
53
- function describe(err: unknown): string {
54
- return err instanceof ApiRequestError ? err.message : errorMessage;
55
- }
56
-
57
- async function reload() {
58
- const current = ++generation.current;
59
- try {
60
- const data = await fetchPage({ limit: pageSize, page: 0 });
61
- if (current !== generation.current) return;
62
- setItems(data);
63
- setHasMore(data.length === pageSize);
64
- setLoadError(null);
65
- nextPage.current = 1;
66
- } catch (err) {
67
- if (current === generation.current) setLoadError(describe(err));
68
- } finally {
69
- if (current === generation.current) setLoading(false);
70
- }
71
- }
72
-
73
- async function loadMore() {
74
- const current = generation.current;
75
- const page = nextPage.current;
76
- setLoadingMore(true);
77
- try {
78
- const [previous, data] = await Promise.all([
79
- fetchPage({ limit: pageSize, page: page - 1 }),
80
- fetchPage({ limit: pageSize, page }),
81
- ]);
82
- if (current !== generation.current) return;
83
- const window: T[] = [...previous, ...data];
84
- // "Load more" is only offered after a full page, so at least one row is shown.
85
- const lastShown: string = loaded.current[loaded.current.length - 1].uid;
86
- const boundary: number = window.map((item) => item.uid).lastIndexOf(lastShown);
87
- if (boundary === -1) {
88
- await reload();
89
- return;
90
- }
91
- const after: T[] = window.slice(boundary + 1);
92
- setItems((prev) => [...prev, ...after.filter((item) => !prev.some((existing) => existing.uid === item.uid))]);
93
- setHasMore(data.length === pageSize);
94
- setLoadError(null);
95
- nextPage.current = page + 1;
96
- } catch (err) {
97
- if (current === generation.current) setLoadError(describe(err));
98
- } finally {
99
- setLoadingMore(false);
100
- }
101
- }
102
-
103
- function replaceItem(item: T) {
104
- setItems((prev) => prev.map((existing) => (existing.uid === item.uid ? item : existing)));
105
- }
106
-
107
- return { items, loading, loadError, hasMore, loadingMore, reload, loadMore, replaceItem };
108
- }
109
-
110
- /** The "Load more" button for a `usePagedList()` list - renders nothing once there's nothing more to load. */
111
- export function LoadMoreButton({ list, label }: { list: Pick<PagedList<unknown>, "hasMore" | "loadingMore" | "loadMore">; label: string }) {
112
- if (!list.hasMore) {
113
- return null;
114
- }
115
- return (
116
- <div className="mt-3">
117
- <Button
118
- type="button"
119
- variant="secondary"
120
- className="!w-auto"
121
- loading={list.loadingMore}
122
- disabled={list.loadingMore}
123
- onClick={() => void list.loadMore()}
124
- >
125
- {label}
126
- </Button>
127
- </div>
128
- );
129
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
+
9
+ /** Page size for the async-request lists (the server returns them newest first and caps `limit` at 500). */
10
+ export const REQUEST_LIST_PAGE_SIZE = 50;
11
+
12
+ export interface PagedList<T> {
13
+ items: T[];
14
+ /** `true` until the first page has loaded (or failed). */
15
+ loading: boolean;
16
+ loadError: string | null;
17
+ /** Whether the last page fetched was full, so another may follow. */
18
+ hasMore: boolean;
19
+ loadingMore: boolean;
20
+ /** Re-fetches from the first page, dropping anything loaded beyond it. */
21
+ reload: () => Promise<void>;
22
+ /** Fetches the next page and appends what's new in it. */
23
+ loadMore: () => Promise<void>;
24
+ /** Replaces one already-loaded item in place (e.g. with an action's response). */
25
+ replaceItem: (item: T) => void;
26
+ }
27
+
28
+ /**
29
+ * A newest-first list fetched a page at a time with "Load more". Only the most recently started `reload()`'s
30
+ * responses are applied, so a slow earlier fetch (e.g. the mount fetch racing a reload after a create) can't
31
+ * overwrite a newer list, and a "Load more" that was in flight during a reload is dropped.
32
+ *
33
+ * Paging is by offset, so "Load more" re-reads the previous page alongside the next one and continues after the last
34
+ * row already shown: rows created since shift older rows onto later pages (repeats are skipped), and rows deleted
35
+ * since shift them onto earlier pages (which a plain next-page fetch would silently skip). If that row can't be found
36
+ * in the two pages at all (it was deleted, or more than a page of rows came or went), the list reloads from the start.
37
+ */
38
+ export function usePagedList<T extends { uid: string }>(
39
+ fetchPage: (params: { limit: number; page: number }) => Promise<T[]>,
40
+ errorMessage: string,
41
+ ): PagedList<T> {
42
+ const pageSize = REQUEST_LIST_PAGE_SIZE;
43
+ const [items, setItems] = useState<T[]>([]);
44
+ const [loading, setLoading] = useState(true);
45
+ const [loadError, setLoadError] = useState<string | null>(null);
46
+ const [hasMore, setHasMore] = useState(false);
47
+ const [loadingMore, setLoadingMore] = useState(false);
48
+ const generation = useRef(0);
49
+ const nextPage = useRef(0);
50
+ const loaded = useRef<T[]>(items);
51
+ loaded.current = items;
52
+
53
+ function describe(err: unknown): string {
54
+ return err instanceof ApiRequestError ? err.message : errorMessage;
55
+ }
56
+
57
+ async function reload() {
58
+ const current = ++generation.current;
59
+ try {
60
+ const data = await fetchPage({ limit: pageSize, page: 0 });
61
+ if (current !== generation.current) return;
62
+ setItems(data);
63
+ setHasMore(data.length === pageSize);
64
+ setLoadError(null);
65
+ nextPage.current = 1;
66
+ } catch (err) {
67
+ if (current === generation.current) setLoadError(describe(err));
68
+ } finally {
69
+ if (current === generation.current) setLoading(false);
70
+ }
71
+ }
72
+
73
+ async function loadMore() {
74
+ const current = generation.current;
75
+ const page = nextPage.current;
76
+ setLoadingMore(true);
77
+ try {
78
+ const [previous, data] = await Promise.all([
79
+ fetchPage({ limit: pageSize, page: page - 1 }),
80
+ fetchPage({ limit: pageSize, page }),
81
+ ]);
82
+ if (current !== generation.current) return;
83
+ const window: T[] = [...previous, ...data];
84
+ // "Load more" is only offered after a full page, so at least one row is shown.
85
+ const lastShown: string = loaded.current[loaded.current.length - 1].uid;
86
+ const boundary: number = window.map((item) => item.uid).lastIndexOf(lastShown);
87
+ if (boundary === -1) {
88
+ await reload();
89
+ return;
90
+ }
91
+ const after: T[] = window.slice(boundary + 1);
92
+ setItems((prev) => [...prev, ...after.filter((item) => !prev.some((existing) => existing.uid === item.uid))]);
93
+ setHasMore(data.length === pageSize);
94
+ setLoadError(null);
95
+ nextPage.current = page + 1;
96
+ } catch (err) {
97
+ if (current === generation.current) setLoadError(describe(err));
98
+ } finally {
99
+ setLoadingMore(false);
100
+ }
101
+ }
102
+
103
+ function replaceItem(item: T) {
104
+ setItems((prev) => prev.map((existing) => (existing.uid === item.uid ? item : existing)));
105
+ }
106
+
107
+ return { items, loading, loadError, hasMore, loadingMore, reload, loadMore, replaceItem };
108
+ }
109
+
110
+ /** The "Load more" button for a `usePagedList()` list - renders nothing once there's nothing more to load. */
111
+ export function LoadMoreButton({ list, label }: { list: Pick<PagedList<unknown>, "hasMore" | "loadingMore" | "loadMore">; label: string }) {
112
+ if (!list.hasMore) {
113
+ return null;
114
+ }
115
+ return (
116
+ <div className="mt-3">
117
+ <Button
118
+ type="button"
119
+ variant="secondary"
120
+ className="!w-auto"
121
+ loading={list.loadingMore}
122
+ disabled={list.loadingMore}
123
+ onClick={() => void list.loadMore()}
124
+ >
125
+ {label}
126
+ </Button>
127
+ </div>
128
+ );
129
+ }
@@ -201,6 +201,7 @@ export default function EventEditor({
201
201
  timezone: occurrence?.timezone ?? deviceZone,
202
202
  attendees: occurrence?.attendees ?? [],
203
203
  guestDraft: "",
204
+ guestInvalid: [],
204
205
  recurrenceRule: occurrence?.recurrenceRule ?? null,
205
206
  reminderMinutes: occurrence?.reminderMinutesBeforeStart?.toString() ?? "",
206
207
  busyStatus: occurrence?.busyStatus ?? "busy",
@@ -271,13 +272,8 @@ export default function EventEditor({
271
272
  // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
272
273
  const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
273
274
 
274
- dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || hasDescriptionText(values.descriptionHtml));
275
+ dirtyRef.current = !!(values.title.trim() || values.location.trim() || values.attendees.length > 0 || values.guestDraft.trim() || values.guestInvalid.length > 0 || hasDescriptionText(values.descriptionHtml));
275
276
 
276
- function addGuests(text: string): string[] {
277
- const merged = mergeGuests(values.attendees, text);
278
- update({ attendees: merged.attendees, guestDraft: merged.invalid.join(" ") });
279
- return merged.invalid;
280
- }
281
277
  function updateAttendee(index: number, patch: Partial<Attendee>) {
282
278
  setValues((prev) => ({ ...prev, attendees: prev.attendees.map((a, i) => (i === index ? { ...a, ...patch } : a)) }));
283
279
  }
@@ -466,8 +462,8 @@ export default function EventEditor({
466
462
  setError("A title is required.");
467
463
  return;
468
464
  }
469
- // An address typed into the guests field but not added yet is added now - or, not being one, stops the save.
470
- const merged = mergeGuests(values.attendees, values.guestDraft);
465
+ // An address typed into the guests field but not added yet is added now - or, not being one (nor what was already flagged there), stops the save.
466
+ const merged = mergeGuests(values.attendees, [...values.guestInvalid, values.guestDraft].join(", "), [effectiveOrganizerAddress]);
471
467
  if (merged.invalid.length > 0) {
472
468
  setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
473
469
  return;
@@ -478,7 +474,7 @@ export default function EventEditor({
478
474
  return;
479
475
  }
480
476
  if (values.guestDraft) {
481
- update({ attendees: merged.attendees, guestDraft: "" });
477
+ update({ attendees: merged.attendees, guestDraft: "", guestInvalid: [] });
482
478
  }
483
479
  let startDate: string;
484
480
  let endDate: string;
@@ -607,7 +603,6 @@ export default function EventEditor({
607
603
  values.timezone,
608
604
  ),
609
605
  editingSingleOccurrence,
610
- addGuests,
611
606
  updateAttendee,
612
607
  removeAttendee,
613
608
  addResource,
@@ -6,6 +6,7 @@ import React, { ReactNode, useRef, useState } from "react";
6
6
  import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
7
7
  import { AttendeeRole, EventVisibility, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
8
8
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
9
+ import GuestInput from "./GuestInput.js";
9
10
  import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
10
11
  import RecurrenceEditor from "./RecurrenceEditor.js";
11
12
  import ResourcePicker from "./ResourcePicker.js";
@@ -103,63 +104,31 @@ export function VideoConferencingRow({ c }: { c: EventFormController }) {
103
104
  }
104
105
 
105
106
  /**
106
- * The guests field: an address box (Enter, a comma or a semicolon adds what is typed) and what has been added - chips in the quick popover,
107
- * rows with a role, the answer so far and a remove button in the full form, which also offers rooms and equipment.
107
+ * The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
108
+ * turns what is picked, typed or pasted into chips - drawn in the field itself in the quick popover. In the full form the guests are rows with a
109
+ * role, the answer so far and a remove button instead (the field then draws only what is not an address), and rooms and equipment can be added.
108
110
  */
109
111
  export function GuestsField({ c, compact }: { c: EventFormController; compact: boolean }) {
110
112
  const { values } = c;
111
- const [hint, setHint] = useState<string | null>(null);
112
113
  const [pickerOpen, setPickerOpen] = useState(false);
113
114
  const pickerAnchor = useRef<HTMLButtonElement>(null);
114
115
 
115
- function commit() {
116
- const invalid = c.addGuests(values.guestDraft);
117
- setHint(invalid.length > 0 ? `“${invalid[0]}” isn’t a valid email address.` : null);
118
- }
119
-
120
116
  return (
121
117
  <div className="flex flex-col gap-2">
122
- <input
123
- type="text"
124
- aria-label="Add guests"
118
+ <GuestInput
119
+ id="event-guests"
120
+ label="Add guests"
125
121
  placeholder="Add guests"
126
- className={compact ? QUIET_INPUT_CLASS : INPUT_CLASS}
127
- value={values.guestDraft}
128
- onChange={(e) => {
129
- setHint(null);
130
- c.update({ guestDraft: e.target.value });
131
- }}
132
- onKeyDown={(e) => {
133
- if (e.key === "Enter" || e.key === "," || e.key === ";") {
134
- e.preventDefault();
135
- commit();
136
- }
137
- }}
122
+ variant={compact ? "quiet" : "box"}
123
+ listedElsewhere={!compact}
124
+ guests={values.attendees}
125
+ invalid={values.guestInvalid}
126
+ draft={values.guestDraft}
127
+ skipAddresses={[c.organizerAddress]}
128
+ mailboxUid={values.targetMailboxUid}
129
+ onChange={(next) => c.update({ attendees: next.guests, guestInvalid: next.invalid, guestDraft: next.draft })}
138
130
  />
139
- {hint && (
140
- <p role="alert" className="text-xs text-danger">
141
- {hint}
142
- </p>
143
- )}
144
- {compact ? (
145
- values.attendees.length > 0 && (
146
- <ul className="flex flex-wrap gap-1.5">
147
- {values.attendees.map((attendee, i) => (
148
- <li key={i} className="flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5">
149
- <span className="truncate max-w-[16rem]">{attendee.address}</span>
150
- <button
151
- type="button"
152
- onClick={() => c.removeAttendee(i)}
153
- className="w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text"
154
- aria-label={`Remove attendee ${i + 1}`}
155
- >
156
- <HiOutlineXMark size={12} aria-hidden="true" />
157
- </button>
158
- </li>
159
- ))}
160
- </ul>
161
- )
162
- ) : (
131
+ {compact ? null : (
163
132
  <>
164
133
  {values.attendees.length > 0 && (
165
134
  <ul className="flex flex-col gap-2">