@rapidmx/web-client 0.3.1 → 0.5.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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -9,6 +9,7 @@ import ContactsShell, { ContactsShellProps } from "../../shared/components/conta
9
9
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
10
10
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
11
11
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
12
13
 
13
14
  /**
14
15
  * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
@@ -30,6 +31,9 @@ function ContactDetailContent({ uid }: { uid: string }) {
30
31
  const [contact, setContact] = useState<Contact | null>(null);
31
32
  const [loading, setLoading] = useState(true);
32
33
  const [error, setError] = useState<string | null>(null);
34
+ // Kept apart from the load `error` - a failed delete must leave the contact on screen (with the
35
+ // reason) rather than replacing the whole page with an error as if it had never loaded.
36
+ const [deleteError, setDeleteError] = useState<string | null>(null);
33
37
  const [mode, setMode] = useState<Mode>("view");
34
38
 
35
39
  useEffect(() => {
@@ -46,14 +50,22 @@ function ContactDetailContent({ uid }: { uid: string }) {
46
50
  // rendered once `contact` is already resolved (see the `error || !contact` guard above it), so this
47
51
  // never runs with a stale/absent contact — no redundant null check needed here.
48
52
  async function handleDelete(contact: Contact) {
53
+ setDeleteError(null);
49
54
  try {
50
55
  await deleteContact(contact.uid, contact.version);
56
+ // A deleted contact's pinned signing keys must stop vouching for signatures.
57
+ clearPinnedSignerCache();
51
58
  window.location.href = "/contacts";
52
59
  } catch (err) {
53
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
60
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
54
61
  }
55
62
  }
56
63
 
64
+ // After a key change was resolved (or found stale): re-read the contact, keeping the one shown if that fails.
65
+ function handleKeysChanged() {
66
+ getContact(uid).then(setContact, () => undefined);
67
+ }
68
+
57
69
  if (loading) {
58
70
  return <p className="p-8 text-sm text-text-muted">Loading&hellip;</p>;
59
71
  }
@@ -82,7 +94,14 @@ function ContactDetailContent({ uid }: { uid: string }) {
82
94
 
83
95
  return (
84
96
  <div className="p-6">
85
- <ContactDetailPane contact={contact} onEdit={() => setMode("edit")} onDelete={() => handleDelete(contact)} backHref={backHref} />
97
+ {deleteError && <Alert>{deleteError}</Alert>}
98
+ <ContactDetailPane
99
+ contact={contact}
100
+ onEdit={() => setMode("edit")}
101
+ onDelete={() => handleDelete(contact)}
102
+ backHref={backHref}
103
+ onKeysChanged={handleKeysChanged}
104
+ />
86
105
  </div>
87
106
  );
88
107
  }
@@ -22,7 +22,13 @@ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js
22
22
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
23
23
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
24
24
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
25
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
26
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
25
27
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
28
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
29
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
30
+ import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
31
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
26
32
 
27
33
  const INPUT_CLASS =
28
34
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -30,7 +36,7 @@ const INPUT_CLASS =
30
36
  export default function ContactsPage(props: ContactsShellProps) {
31
37
  return (
32
38
  <ContactsShell {...props}>
33
- <ContactsContent />
39
+ <ContactsContent userUid={props.userUid} />
34
40
  </ContactsShell>
35
41
  );
36
42
  }
@@ -38,22 +44,33 @@ export default function ContactsPage(props: ContactsShellProps) {
38
44
  type Mode = "view" | "edit" | "new";
39
45
  type SortColumn = "name" | "info";
40
46
 
47
+ /** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
48
+ const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
49
+
41
50
  function primaryInfo(contact: Contact): string {
42
51
  return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
43
52
  }
44
53
 
54
+ /** How long an export's object URL is kept alive after the click - some browsers are still reading the
55
+ * blob after `click()` returns, so revoking it synchronously can cancel the download. */
56
+ const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60_000;
57
+
45
58
  function downloadTextFile(filename: string, content: string): void {
46
59
  const blob = new Blob([content], { type: "text/vcard" });
47
60
  const url = URL.createObjectURL(blob);
48
61
  const a = document.createElement("a");
49
62
  a.href = url;
50
63
  a.download = filename;
64
+ document.body.appendChild(a);
51
65
  a.click();
52
- URL.revokeObjectURL(url);
66
+ a.remove();
67
+ setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
53
68
  }
54
69
 
55
- function ContactsContent() {
56
- const { folderUid, mailboxUid } = useContactsShell();
70
+ function ContactsContent({ userUid }: { userUid?: string }) {
71
+ const { folderUid, mailboxUid, mailboxes } = useContactsShell();
72
+ // The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
73
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
57
74
  const { openCompose } = useCompose();
58
75
  const isMobile = useIsMobile();
59
76
  const [contacts, setContacts] = useState<Contact[]>([]);
@@ -62,6 +79,9 @@ function ContactsContent() {
62
79
  const [deletedError, setDeletedError] = useState<string | null>(null);
63
80
  const [loading, setLoading] = useState(true);
64
81
  const [error, setError] = useState<string | null>(null);
82
+ // Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
83
+ const [truncated, setTruncated] = useState(false);
84
+ const [deletedTruncated, setDeletedTruncated] = useState(false);
65
85
  const [query, setQuery] = useState("");
66
86
  const [view, setView] = useState<ContactsView>({ type: "all" });
67
87
  const [sortColumn, setSortColumn] = useState<SortColumn>("name");
@@ -69,6 +89,9 @@ function ContactsContent() {
69
89
  const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
70
90
  const [selectedUid, setSelectedUid] = useState<string | null>(null);
71
91
  const [mode, setMode] = useState<Mode>("view");
92
+ // Set after a contact is created in a different mailbox than the one this list shows.
93
+ const [savedElsewhere, setSavedElsewhere] = useState<{ mailboxUid: string; displayName: string } | null>(null);
94
+ const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
72
95
 
73
96
  /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
74
97
  * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
@@ -77,13 +100,17 @@ function ContactsContent() {
77
100
  function reload(): Promise<void> {
78
101
  if (!folderUid) {
79
102
  setContacts([]);
103
+ setTruncated(false);
80
104
  setLoading(false);
81
105
  return Promise.resolve();
82
106
  }
83
107
  setLoading(true);
84
108
  setError(null);
85
- return listContacts(folderUid, { limit: 500 })
86
- .then(setContacts)
109
+ return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
110
+ .then((result) => {
111
+ setContacts(result.items);
112
+ setTruncated(result.truncated);
113
+ })
87
114
  .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
88
115
  .finally(() => setLoading(false));
89
116
  }
@@ -92,14 +119,56 @@ function ContactsContent() {
92
119
  void reload();
93
120
  }, [folderUid]);
94
121
 
122
+ // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
123
+ // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
124
+ // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
125
+ // access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
126
+ // asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
127
+ // load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
128
+ // and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
129
+ const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
130
+ const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
131
+ // Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
132
+ const [delegateCanUpdate, setDelegateCanUpdate] = useState<boolean | undefined>(undefined);
133
+ useEffect(() => {
134
+ if (ownsMailbox) {
135
+ return;
136
+ }
137
+ let cancelled = false;
138
+ getMyMailboxAccess(mailboxUid!).then(
139
+ (access) => {
140
+ if (!cancelled) {
141
+ setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
142
+ setDelegateCanUpdate(access.canUpdate);
143
+ }
144
+ },
145
+ () => undefined,
146
+ );
147
+ return () => {
148
+ cancelled = true;
149
+ };
150
+ }, [ownsMailbox]);
151
+ const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
152
+
95
153
  useEffect(() => {
96
154
  if (view.type !== "deleted" || !folderUid) {
97
155
  return;
98
156
  }
99
157
  setDeletedLoading(true);
100
158
  setDeletedError(null);
101
- listDeletedContacts(folderUid, { limit: 500 })
102
- .then(setDeletedContacts)
159
+ listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
160
+ .then((result) => {
161
+ // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
162
+ // contacts, which must never be listed as deleted.
163
+ if (result.items.some((c) => c.deleted !== true)) {
164
+ setDeletedContacts([]);
165
+ setDeletedTruncated(false);
166
+ setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
167
+ return;
168
+ }
169
+ setDeletedContacts(result.items);
170
+ setDeletedTruncated(result.truncated);
171
+ })
103
172
  .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
104
173
  .finally(() => setDeletedLoading(false));
105
174
  // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
@@ -192,8 +261,15 @@ function ContactsContent() {
192
261
  }
193
262
 
194
263
  function handleSaved(contact: Contact) {
195
- setSelectedUid(contact.uid);
196
264
  setMode("view");
265
+ if (contact.mailboxUid !== mailboxUid) {
266
+ // Created in another mailbox (via the form's Mailbox selector) - it won't be in this list.
267
+ setSelectedUid(null);
268
+ setSavedElsewhere({ mailboxUid: contact.mailboxUid, displayName: contact.displayName });
269
+ return;
270
+ }
271
+ setSavedElsewhere(null);
272
+ setSelectedUid(contact.uid);
197
273
  void reload();
198
274
  }
199
275
 
@@ -204,6 +280,8 @@ function ContactsContent() {
204
280
  async function handleDelete(contact: Contact) {
205
281
  try {
206
282
  await deleteContact(contact.uid, contact.version);
283
+ // Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
284
+ clearPinnedSignerCache();
207
285
  setSelectedUid(null);
208
286
  setMode("view");
209
287
  void reload();
@@ -213,6 +291,7 @@ function ContactsContent() {
213
291
  }
214
292
 
215
293
  async function handleBulkDelete() {
294
+ setConfirmingBulkDelete(false);
216
295
  let bulkError: string | null = null;
217
296
  for (const contact of checkedContacts) {
218
297
  try {
@@ -221,6 +300,7 @@ function ContactsContent() {
221
300
  bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
222
301
  }
223
302
  }
303
+ clearPinnedSignerCache();
224
304
  setCheckedUids(new Set());
225
305
  await reload();
226
306
  setError(bulkError);
@@ -237,9 +317,9 @@ function ContactsContent() {
237
317
 
238
318
  function handleEmail() {
239
319
  const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a): a is string => Boolean(a));
240
- // `ContactsShell` only ever renders this component once `mailboxUid` is resolved (see its own
241
- // invariant comment) — same non-null pattern as `organizerAddress` in the calendar page.
242
- openCompose({ mailboxUid: mailboxUid!, to: addresses.join(", ") });
320
+ // No mailboxUid: a fresh message defaults to the caller's own mailbox, not whichever mailbox's
321
+ // contacts happen to be open - the compose window's From field can still change it.
322
+ openCompose({ to: addresses.join(", ") });
243
323
  }
244
324
 
245
325
  async function handleToggleFavorite() {
@@ -252,6 +332,7 @@ function ContactsContent() {
252
332
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
253
333
  }
254
334
  }
335
+ clearPinnedSignerCache();
255
336
  await reload();
256
337
  setError(bulkError);
257
338
  }
@@ -265,11 +346,14 @@ function ContactsContent() {
265
346
  for (const contact of checkedContacts) {
266
347
  const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
267
348
  try {
268
- await updateContact({ ...contact, categories });
349
+ // Only the changed field - sending the whole fetched contact back includes server-managed
350
+ // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
351
+ await updateContact({ uid: contact.uid, version: contact.version, categories });
269
352
  } catch (err) {
270
353
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
271
354
  }
272
355
  }
356
+ clearPinnedSignerCache();
273
357
  await reload();
274
358
  setError(bulkError);
275
359
  }
@@ -295,13 +379,14 @@ function ContactsContent() {
295
379
  bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
296
380
  }
297
381
  }
382
+ clearPinnedSignerCache();
298
383
  await reload();
299
384
  setError(bulkError);
300
385
  }
301
386
 
302
387
  return (
303
388
  <div className="flex-1 flex min-h-0">
304
- <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} />
389
+ <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
305
390
  {/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
306
391
  in-place — see the pane's own comment below) is showing, so the two never compete for the
307
392
  same row's width. Desktop always shows both side by side, unchanged. */}
@@ -316,7 +401,7 @@ function ContactsContent() {
316
401
  allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
317
402
  onNewContact={handleNew}
318
403
  onEdit={handleToolbarEdit}
319
- onDelete={handleBulkDelete}
404
+ onDelete={() => setConfirmingBulkDelete(true)}
320
405
  onEmail={handleEmail}
321
406
  onToggleFavorite={handleToggleFavorite}
322
407
  onAddCategory={handleAddCategory}
@@ -338,6 +423,14 @@ function ContactsContent() {
338
423
  <Alert>{error}</Alert>
339
424
  </div>
340
425
  )}
426
+ {(isDeletedView ? deletedTruncated : truncated) && (
427
+ <div className="p-3">
428
+ <Alert>
429
+ This folder has more contacts than can be shown at once - only the first {LIST_PAGE_SIZE * MAX_LIST_PAGES} are
430
+ listed.
431
+ </Alert>
432
+ </div>
433
+ )}
341
434
  {deletedError && isDeletedView && (
342
435
  <div className="p-3">
343
436
  <Alert>{deletedError}</Alert>
@@ -421,16 +514,54 @@ function ContactsContent() {
421
514
  see handleSelectRow), so this pane only needs to show there for "new", which stays in-place
422
515
  on every device (an unsaved contact has no uid for a route). Always visible on desktop. */}
423
516
  <div className={["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" ")}>
517
+ {savedElsewhere && mode !== "new" && (
518
+ <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
519
+ {savedElsewhere.displayName} was added to{" "}
520
+ {mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
521
+ <a
522
+ href={`/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`}
523
+ className="font-medium text-primary-dark hover:underline"
524
+ >
525
+ View that mailbox&rsquo;s contacts
526
+ </a>
527
+ </p>
528
+ )}
424
529
  {mode === "new" && mailboxUid && folderUid ? (
425
- <ContactForm mailboxUid={mailboxUid} folderUid={folderUid} onSaved={handleSaved} onCancel={handleCancel} />
530
+ <ContactForm
531
+ mailboxUid={mailboxUid}
532
+ folderUid={folderUid}
533
+ mailboxes={writableMailboxes}
534
+ onSaved={handleSaved}
535
+ onCancel={handleCancel}
536
+ />
426
537
  ) : mode === "edit" && selected ? (
427
538
  <ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
428
539
  ) : selected ? (
429
- <ContactDetailPane contact={selected} onEdit={() => setMode("edit")} onDelete={() => handleDelete(selected)} />
540
+ <ContactDetailPane
541
+ contact={selected}
542
+ onEdit={() => setMode("edit")}
543
+ onDelete={() => handleDelete(selected)}
544
+ onKeysChanged={() => void reload()}
545
+ canResolveKeys={ownsMailbox || delegateCanUpdate}
546
+ />
430
547
  ) : (
431
548
  <p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
432
549
  )}
433
550
  </div>
551
+ <Modal open={confirmingBulkDelete} onClose={() => setConfirmingBulkDelete(false)} title="Delete contacts">
552
+ <p className="text-sm mb-5">
553
+ Delete {checkedContacts.length} selected {checkedContacts.length === 1 ? "contact" : "contacts"}? They
554
+ move to Deleted contacts.
555
+ </p>
556
+ <div className="flex gap-3 justify-end">
557
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingBulkDelete(false)}>
558
+ Cancel
559
+ </Button>
560
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={handleBulkDelete}>
561
+ Delete
562
+ </Button>
563
+ </div>
564
+ </Modal>
434
565
  </div>
435
566
  );
436
567
  }