@rapidmx/web-client 0.9.0 → 0.10.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 (226) hide show
  1. package/README.md +192 -88
  2. package/apps/shared/auth/accountUrl.ts +9 -0
  3. package/apps/shared/auth/adminAccess.ts +99 -0
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +2 -1
  5. package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
  6. package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
  7. package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
  8. package/apps/shared/components/layout/AppShell.tsx +411 -288
  9. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
  10. package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -164
  11. package/apps/shared/components/layout/UserMenu.tsx +303 -185
  12. package/apps/shared/components/mail/ConversationList.tsx +269 -262
  13. package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
  14. package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
  15. package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
  16. package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1387
  17. package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
  18. package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
  19. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
  20. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1736
  21. package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
  22. package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
  23. package/apps/shared/components/mail/compose/composePerf.ts +46 -0
  24. package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
  25. package/apps/shared/components/mail/layout/MailShell.tsx +476 -472
  26. package/apps/shared/components/mail/unreadStyle.tsx +75 -0
  27. package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
  28. package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
  29. package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
  30. package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
  31. package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
  32. package/apps/shared/keyboard/dispatch.ts +124 -0
  33. package/apps/shared/keyboard/format.ts +89 -0
  34. package/apps/shared/keyboard/keymap.ts +114 -0
  35. package/apps/shared/keyboard/match.ts +44 -0
  36. package/apps/shared/keyboard/parse.ts +136 -0
  37. package/apps/shared/keyboard/platform.ts +34 -0
  38. package/apps/shared/keyboard/registry.ts +65 -0
  39. package/apps/shared/keyboard/targets.ts +79 -0
  40. package/apps/shared/keyboard/useShortcut.ts +50 -0
  41. package/apps/shared/keyboard/useShortcutProps.ts +17 -0
  42. package/apps/shared/mail/folderCounts.ts +302 -0
  43. package/apps/shared/mail/listSnapshots.ts +87 -0
  44. package/apps/shared/mail/messageReadState.ts +85 -0
  45. package/apps/shared/mail/newMailNotifications.ts +183 -0
  46. package/apps/shared/mail/useMailConnection.ts +150 -0
  47. package/apps/shared/mail/useMailLiveUpdates.ts +232 -224
  48. package/apps/shared/mail/useMarkMessageRead.ts +47 -0
  49. package/apps/shared/mail/useNewMailNotifications.ts +163 -0
  50. package/apps/shared/mail/useUnreadTitle.ts +42 -0
  51. package/apps/shared/navigation/AppRouter.tsx +300 -0
  52. package/apps/shared/navigation/appHrefs.ts +23 -0
  53. package/apps/shared/navigation/frameContext.tsx +35 -0
  54. package/apps/shared/navigation/idle.ts +45 -0
  55. package/apps/shared/navigation/routerContext.tsx +83 -0
  56. package/apps/shared/navigation/routes.ts +72 -0
  57. package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
  58. package/apps/shared/styles/app.css +28 -10
  59. package/apps/www/_routedPage.tsx +24 -0
  60. package/apps/www/_routes.ts +35 -0
  61. package/apps/www/calendar/index.tsx +55 -19
  62. package/apps/www/contacts/[uid].tsx +112 -107
  63. package/apps/www/contacts/index.tsx +584 -567
  64. package/apps/www/index.tsx +2280 -1917
  65. package/apps/www/messages/[uid].tsx +106 -101
  66. package/apps/www/settings/auto-reply/index.tsx +4 -1
  67. package/apps/www/settings/encryption/index.tsx +1252 -1249
  68. package/apps/www/settings/filters/[uid].tsx +4 -1
  69. package/apps/www/settings/filters/index.tsx +102 -99
  70. package/apps/www/settings/filters/new/index.tsx +138 -133
  71. package/apps/www/settings/labels/index.tsx +204 -201
  72. package/apps/www/settings/privacy/index.tsx +4 -1
  73. package/apps/www/settings/read-receipts/index.tsx +4 -1
  74. package/apps/www/settings/sharing/index.tsx +277 -274
  75. package/apps/www/settings/signatures/[uid].tsx +170 -167
  76. package/apps/www/settings/signatures/index.tsx +88 -85
  77. package/apps/www/settings/signatures/new/index.tsx +134 -129
  78. package/apps/www/tasks/index.tsx +18 -2
  79. package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
  80. package/dist/apps/shared/auth/accountUrl.js +8 -0
  81. package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
  82. package/dist/apps/shared/auth/adminAccess.js +89 -0
  83. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +1 -1
  84. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
  85. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
  86. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
  87. package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
  88. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
  89. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
  90. package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
  91. package/dist/apps/shared/components/layout/AppShell.js +73 -14
  92. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
  93. package/dist/apps/shared/components/layout/MailboxProvisioning.js +3 -1
  94. package/dist/apps/shared/components/layout/UserMenu.d.ts +19 -3
  95. package/dist/apps/shared/components/layout/UserMenu.js +52 -5
  96. package/dist/apps/shared/components/mail/ConversationList.js +4 -11
  97. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  98. package/dist/apps/shared/components/mail/ConversationThreadPane.js +52 -27
  99. package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
  100. package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
  101. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
  102. package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
  103. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
  104. package/dist/apps/shared/components/mail/MessageDetailPane.js +83 -34
  105. package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
  106. package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
  107. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
  108. package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
  109. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
  110. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
  111. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +98 -22
  112. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
  113. package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
  114. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
  115. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
  116. package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
  117. package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
  118. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
  119. package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
  120. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +12 -4
  121. package/dist/apps/shared/components/mail/layout/MailShell.js +76 -96
  122. package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
  123. package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
  124. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
  125. package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
  126. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
  127. package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
  128. package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
  129. package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
  130. package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
  131. package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
  132. package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
  133. package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
  134. package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
  135. package/dist/apps/shared/keyboard/dispatch.js +109 -0
  136. package/dist/apps/shared/keyboard/format.d.ts +12 -0
  137. package/dist/apps/shared/keyboard/format.js +74 -0
  138. package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
  139. package/dist/apps/shared/keyboard/keymap.js +84 -0
  140. package/dist/apps/shared/keyboard/match.d.ts +20 -0
  141. package/dist/apps/shared/keyboard/match.js +31 -0
  142. package/dist/apps/shared/keyboard/parse.d.ts +31 -0
  143. package/dist/apps/shared/keyboard/parse.js +107 -0
  144. package/dist/apps/shared/keyboard/platform.d.ts +15 -0
  145. package/dist/apps/shared/keyboard/platform.js +22 -0
  146. package/dist/apps/shared/keyboard/registry.d.ts +39 -0
  147. package/dist/apps/shared/keyboard/registry.js +33 -0
  148. package/dist/apps/shared/keyboard/targets.d.ts +14 -0
  149. package/dist/apps/shared/keyboard/targets.js +67 -0
  150. package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
  151. package/dist/apps/shared/keyboard/useShortcut.js +35 -0
  152. package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
  153. package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
  154. package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
  155. package/dist/apps/shared/mail/folderCounts.js +212 -0
  156. package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
  157. package/dist/apps/shared/mail/listSnapshots.js +43 -0
  158. package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
  159. package/dist/apps/shared/mail/messageReadState.js +63 -0
  160. package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
  161. package/dist/apps/shared/mail/newMailNotifications.js +138 -0
  162. package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
  163. package/dist/apps/shared/mail/useMailConnection.js +96 -0
  164. package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +13 -6
  165. package/dist/apps/shared/mail/useMailLiveUpdates.js +28 -26
  166. package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
  167. package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
  168. package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
  169. package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
  170. package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
  171. package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
  172. package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
  173. package/dist/apps/shared/navigation/AppRouter.js +242 -0
  174. package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
  175. package/dist/apps/shared/navigation/appHrefs.js +20 -0
  176. package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
  177. package/dist/apps/shared/navigation/frameContext.js +24 -0
  178. package/dist/apps/shared/navigation/idle.d.ts +14 -0
  179. package/dist/apps/shared/navigation/idle.js +43 -0
  180. package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
  181. package/dist/apps/shared/navigation/routerContext.js +56 -0
  182. package/dist/apps/shared/navigation/routes.d.ts +32 -0
  183. package/dist/apps/shared/navigation/routes.js +37 -0
  184. package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
  185. package/dist/apps/shared/styles/app.css +28 -10
  186. package/dist/apps/www/_routedPage.d.ts +12 -0
  187. package/dist/apps/www/_routedPage.js +19 -0
  188. package/dist/apps/www/_routes.d.ts +11 -0
  189. package/dist/apps/www/_routes.js +29 -0
  190. package/dist/apps/www/calendar/index.d.ts +2 -2
  191. package/dist/apps/www/calendar/index.js +39 -8
  192. package/dist/apps/www/contacts/[uid].d.ts +3 -9
  193. package/dist/apps/www/contacts/[uid].js +6 -2
  194. package/dist/apps/www/contacts/index.d.ts +2 -2
  195. package/dist/apps/www/contacts/index.js +17 -4
  196. package/dist/apps/www/index.d.ts +2 -2
  197. package/dist/apps/www/index.js +301 -35
  198. package/dist/apps/www/messages/[uid].d.ts +3 -7
  199. package/dist/apps/www/messages/[uid].js +7 -4
  200. package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
  201. package/dist/apps/www/settings/auto-reply/index.js +3 -1
  202. package/dist/apps/www/settings/encryption/index.d.ts +2 -2
  203. package/dist/apps/www/settings/encryption/index.js +3 -1
  204. package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
  205. package/dist/apps/www/settings/filters/[uid].js +3 -1
  206. package/dist/apps/www/settings/filters/index.d.ts +2 -2
  207. package/dist/apps/www/settings/filters/index.js +3 -1
  208. package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
  209. package/dist/apps/www/settings/filters/new/index.js +6 -2
  210. package/dist/apps/www/settings/labels/index.d.ts +2 -2
  211. package/dist/apps/www/settings/labels/index.js +3 -1
  212. package/dist/apps/www/settings/privacy/index.d.ts +2 -2
  213. package/dist/apps/www/settings/privacy/index.js +3 -1
  214. package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
  215. package/dist/apps/www/settings/read-receipts/index.js +3 -1
  216. package/dist/apps/www/settings/sharing/index.d.ts +2 -2
  217. package/dist/apps/www/settings/sharing/index.js +3 -1
  218. package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
  219. package/dist/apps/www/settings/signatures/[uid].js +3 -1
  220. package/dist/apps/www/settings/signatures/index.d.ts +2 -2
  221. package/dist/apps/www/settings/signatures/index.js +3 -1
  222. package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
  223. package/dist/apps/www/settings/signatures/new/index.js +6 -2
  224. package/dist/apps/www/tasks/index.d.ts +2 -2
  225. package/dist/apps/www/tasks/index.js +15 -3
  226. package/package.json +2 -2
@@ -1,567 +1,584 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { useEffect, useMemo, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- Contact,
9
- createContact,
10
- deleteContact,
11
- listContacts,
12
- listDeletedContacts,
13
- setContactFavorite,
14
- updateContact,
15
- } from "@rapidmx/react-shared/contacts/contactsApi.js";
16
- import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
17
- import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
18
- import { useCompose } from "../../shared/components/mail/compose/ComposeContext.js";
19
- import ContactsShell, { ContactsShellProps, useContactsShell } from "../../shared/components/contacts/layout/ContactsShell.js";
20
- import ContactsSidebar, { ContactsView } from "../../shared/components/contacts/ContactsSidebar.js";
21
- import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js";
22
- import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
23
- import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
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";
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";
32
-
33
- const INPUT_CLASS =
34
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
35
-
36
- export default function ContactsPage(props: ContactsShellProps) {
37
- return (
38
- <ContactsShell {...props}>
39
- <ContactsContent userUid={props.userUid} />
40
- </ContactsShell>
41
- );
42
- }
43
-
44
- type Mode = "view" | "edit" | "new";
45
- type SortColumn = "name" | "info";
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
-
50
- function primaryInfo(contact: Contact): string {
51
- return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
52
- }
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
-
58
- function downloadTextFile(filename: string, content: string): void {
59
- const blob = new Blob([content], { type: "text/vcard" });
60
- const url = URL.createObjectURL(blob);
61
- const a = document.createElement("a");
62
- a.href = url;
63
- a.download = filename;
64
- document.body.appendChild(a);
65
- a.click();
66
- a.remove();
67
- setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
68
- }
69
-
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);
74
- const { openCompose } = useCompose();
75
- const isMobile = useIsMobile();
76
- const [contacts, setContacts] = useState<Contact[]>([]);
77
- const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
78
- const [deletedLoading, setDeletedLoading] = useState(false);
79
- const [deletedError, setDeletedError] = useState<string | null>(null);
80
- const [loading, setLoading] = useState(true);
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);
85
- const [query, setQuery] = useState("");
86
- const [view, setView] = useState<ContactsView>({ type: "all" });
87
- const [sortColumn, setSortColumn] = useState<SortColumn>("name");
88
- const [sortDesc, setSortDesc] = useState(false);
89
- const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
90
- const [selectedUid, setSelectedUid] = useState<string | null>(null);
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);
95
-
96
- /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
97
- * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
98
- * which would otherwise silently wipe out a bulk action's own just-set failure message before the user
99
- * ever saw it, since every bulk handler calls this right after its own error-setting loop. */
100
- function reload(): Promise<void> {
101
- if (!folderUid) {
102
- setContacts([]);
103
- setTruncated(false);
104
- setLoading(false);
105
- return Promise.resolve();
106
- }
107
- setLoading(true);
108
- setError(null);
109
- return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
110
- .then((result) => {
111
- setContacts(result.items);
112
- setTruncated(result.truncated);
113
- })
114
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
115
- .finally(() => setLoading(false));
116
- }
117
-
118
- useEffect(() => {
119
- void reload();
120
- }, [folderUid]);
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
-
153
- useEffect(() => {
154
- if (view.type !== "deleted" || !folderUid) {
155
- return;
156
- }
157
- setDeletedLoading(true);
158
- setDeletedError(null);
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
- })
172
- .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
173
- .finally(() => setDeletedLoading(false));
174
- // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
175
- // convention elsewhere rather than caching a possibly-stale list across visits.
176
- }, [view, folderUid]);
177
-
178
- const viewFiltered = useMemo(() => {
179
- switch (view.type) {
180
- case "all":
181
- return contacts;
182
- case "favorites":
183
- return contacts.filter((c) => c.favorite);
184
- case "list":
185
- return contacts.filter((c) => c.contactListUid === view.uid);
186
- case "category":
187
- return contacts.filter((c) => (c.categories ?? []).includes(view.name));
188
- case "deleted":
189
- return deletedContacts;
190
- }
191
- }, [contacts, deletedContacts, view]);
192
-
193
- const filtered = useMemo(() => {
194
- const q = query.trim().toLowerCase();
195
- if (!q) {
196
- return viewFiltered;
197
- }
198
- return viewFiltered.filter(
199
- (c) => c.displayName.toLowerCase().includes(q) || c.emails.some((e) => e.address.toLowerCase().includes(q)),
200
- );
201
- }, [viewFiltered, query]);
202
-
203
- const sorted = useMemo(() => {
204
- const arr = [...filtered];
205
- arr.sort((a, b) => {
206
- const cmp = (sortColumn === "name" ? a.displayName : primaryInfo(a)).localeCompare(
207
- sortColumn === "name" ? b.displayName : primaryInfo(b),
208
- );
209
- return sortDesc ? -cmp : cmp;
210
- });
211
- return arr;
212
- }, [filtered, sortColumn, sortDesc]);
213
-
214
- const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
215
- const isDeletedView = view.type === "deleted";
216
- const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
217
- const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
218
-
219
- function handleSort(column: SortColumn) {
220
- if (column === sortColumn) {
221
- setSortDesc((prev) => !prev);
222
- } else {
223
- setSortColumn(column);
224
- setSortDesc(false);
225
- }
226
- }
227
-
228
- function toggleChecked(uid: string) {
229
- setCheckedUids((prev) => {
230
- const next = new Set(prev);
231
- if (next.has(uid)) {
232
- next.delete(uid);
233
- } else {
234
- next.add(uid);
235
- }
236
- return next;
237
- });
238
- }
239
-
240
- function toggleCheckAll() {
241
- setCheckedUids(allChecked ? new Set() : new Set(sorted.map((c) => c.uid)));
242
- }
243
-
244
- function handleSelectView(next: ContactsView) {
245
- setView(next);
246
- setCheckedUids(new Set());
247
- }
248
-
249
- function handleSelectRow(contact: Contact) {
250
- if (isMobile) {
251
- window.location.href = `/contacts/${encodeURIComponent(contact.uid)}`;
252
- return;
253
- }
254
- setSelectedUid(contact.uid);
255
- setMode("view");
256
- }
257
-
258
- function handleNew() {
259
- setSelectedUid(null);
260
- setMode("new");
261
- }
262
-
263
- function handleSaved(contact: Contact) {
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);
273
- void reload();
274
- }
275
-
276
- function handleCancel() {
277
- setMode("view");
278
- }
279
-
280
- async function handleDelete(contact: Contact) {
281
- try {
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();
285
- setSelectedUid(null);
286
- setMode("view");
287
- void reload();
288
- } catch (err) {
289
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
290
- }
291
- }
292
-
293
- async function handleBulkDelete() {
294
- setConfirmingBulkDelete(false);
295
- let bulkError: string | null = null;
296
- for (const contact of checkedContacts) {
297
- try {
298
- await deleteContact(contact.uid, contact.version);
299
- } catch (err) {
300
- bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
301
- }
302
- }
303
- clearPinnedSignerCache();
304
- setCheckedUids(new Set());
305
- await reload();
306
- setError(bulkError);
307
- }
308
-
309
- // `ContactsToolbar`'s Edit button — the only caller — is itself `disabled` unless exactly one contact
310
- // is checked, so `checkedContacts[0]` is guaranteed to exist whenever this actually runs; no length
311
- // guard needed, matching this codebase's established pattern for the same class of "always non-empty
312
- // by the time it's called" value guaranteed by a disabled control (see `ComposeToolbar.run()`).
313
- function handleToolbarEdit() {
314
- setSelectedUid(checkedContacts[0].uid);
315
- setMode("edit");
316
- }
317
-
318
- function handleEmail() {
319
- const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a): a is string => Boolean(a));
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(", ") });
323
- }
324
-
325
- async function handleToggleFavorite() {
326
- const allFavorited = checkedContacts.every((c) => c.favorite);
327
- let bulkError: string | null = null;
328
- for (const contact of checkedContacts) {
329
- try {
330
- await setContactFavorite(contact, !allFavorited);
331
- } catch (err) {
332
- bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
333
- }
334
- }
335
- clearPinnedSignerCache();
336
- await reload();
337
- setError(bulkError);
338
- }
339
-
340
- async function handleAddCategory() {
341
- const category = window.prompt("Category name");
342
- if (!category?.trim()) {
343
- return;
344
- }
345
- let bulkError: string | null = null;
346
- for (const contact of checkedContacts) {
347
- const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
348
- try {
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 });
352
- } catch (err) {
353
- bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
354
- }
355
- }
356
- clearPinnedSignerCache();
357
- await reload();
358
- setError(bulkError);
359
- }
360
-
361
- function handleExportVCard() {
362
- downloadTextFile(
363
- checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
364
- checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
365
- );
366
- }
367
-
368
- async function handleImportFile(file: File) {
369
- if (!mailboxUid || !folderUid) {
370
- return;
371
- }
372
- const text = await file.text();
373
- const parsed = parseVCards(text);
374
- let bulkError: string | null = null;
375
- for (const input of parsed) {
376
- try {
377
- await createContact({ mailboxUid, folderUid, ...input });
378
- } catch (err) {
379
- bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
380
- }
381
- }
382
- clearPinnedSignerCache();
383
- await reload();
384
- setError(bulkError);
385
- }
386
-
387
- return (
388
- <div className="flex-1 flex min-h-0">
389
- <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
390
- {/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
391
- in-place — see the pane's own comment below) is showing, so the two never compete for the
392
- same row's width. Desktop always shows both side by side, unchanged. */}
393
- <div
394
- className={[
395
- "w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
396
- mode === "new" ? "hidden md:flex" : "flex",
397
- ].join(" ")}
398
- >
399
- <ContactsToolbar
400
- selectedCount={checkedContacts.length}
401
- allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
402
- onNewContact={handleNew}
403
- onEdit={handleToolbarEdit}
404
- onDelete={() => setConfirmingBulkDelete(true)}
405
- onEmail={handleEmail}
406
- onToggleFavorite={handleToggleFavorite}
407
- onAddCategory={handleAddCategory}
408
- onExportVCard={handleExportVCard}
409
- onImportFile={handleImportFile}
410
- />
411
- <div className="p-3 border-b border-border">
412
- <input
413
- type="search"
414
- value={query}
415
- onChange={(e) => setQuery(e.target.value)}
416
- placeholder="Search contacts"
417
- aria-label="Search contacts"
418
- className={INPUT_CLASS}
419
- />
420
- </div>
421
- {error && (
422
- <div className="p-3">
423
- <Alert>{error}</Alert>
424
- </div>
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
- )}
434
- {deletedError && isDeletedView && (
435
- <div className="p-3">
436
- <Alert>{deletedError}</Alert>
437
- </div>
438
- )}
439
- {(isDeletedView ? deletedLoading : loading) ? (
440
- <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
441
- ) : sorted.length === 0 ? (
442
- <p className="p-4 text-sm text-text-muted">No contacts found.</p>
443
- ) : (
444
- <div className="flex-1 overflow-auto">
445
- <table className="w-full text-sm">
446
- <thead>
447
- <tr className="border-b border-border text-left">
448
- {!isDeletedView && (
449
- <th className="w-8 px-3 py-2">
450
- <input
451
- type="checkbox"
452
- aria-label="Select all contacts"
453
- checked={allChecked}
454
- onChange={toggleCheckAll}
455
- />
456
- </th>
457
- )}
458
- <th className="px-3 py-2">
459
- <button
460
- type="button"
461
- onClick={() => handleSort("name")}
462
- className="text-xs font-bold uppercase tracking-wide text-text-muted"
463
- >
464
- Name{sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""}
465
- </button>
466
- </th>
467
- <th className="px-3 py-2">
468
- <button
469
- type="button"
470
- onClick={() => handleSort("info")}
471
- className="text-xs font-bold uppercase tracking-wide text-text-muted"
472
- >
473
- Contact info{sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""}
474
- </button>
475
- </th>
476
- </tr>
477
- </thead>
478
- <tbody>
479
- {sorted.map((contact) => (
480
- <tr
481
- key={contact.uid}
482
- className={["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(
483
- " ",
484
- )}
485
- >
486
- {!isDeletedView && (
487
- <td className="px-3 py-2">
488
- <input
489
- type="checkbox"
490
- aria-label={`Select ${contact.displayName}`}
491
- checked={checkedUids.has(contact.uid)}
492
- onChange={() => toggleChecked(contact.uid)}
493
- />
494
- </td>
495
- )}
496
- <td className="px-3 py-2">
497
- <button type="button" onClick={() => handleSelectRow(contact)} className="flex items-center gap-2 text-left">
498
- <ContactAvatar displayName={contact.displayName} size={28} />
499
- <span className="truncate font-medium">
500
- {contact.displayName}
501
- {contact.favorite && <span aria-label="Favorite"> ★</span>}
502
- </span>
503
- </button>
504
- </td>
505
- <td className="px-3 py-2 text-text-muted truncate">{primaryInfo(contact)}</td>
506
- </tr>
507
- ))}
508
- </tbody>
509
- </table>
510
- </div>
511
- )}
512
- </div>
513
- {/* On mobile, "selected"/"edit" are unreachable (row taps navigate to /contacts/:uid instead —
514
- see handleSelectRow), so this pane only needs to show there for "new", which stays in-place
515
- on every device (an unsaved contact has no uid for a route). Always visible on desktop. */}
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
- )}
529
- {mode === "new" && mailboxUid && folderUid ? (
530
- <ContactForm
531
- mailboxUid={mailboxUid}
532
- folderUid={folderUid}
533
- mailboxes={writableMailboxes}
534
- onSaved={handleSaved}
535
- onCancel={handleCancel}
536
- />
537
- ) : mode === "edit" && selected ? (
538
- <ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
539
- ) : selected ? (
540
- <ContactDetailPane
541
- contact={selected}
542
- onEdit={() => setMode("edit")}
543
- onDelete={() => handleDelete(selected)}
544
- onKeysChanged={() => void reload()}
545
- canResolveKeys={ownsMailbox || delegateCanUpdate}
546
- />
547
- ) : (
548
- <p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
549
- )}
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>
565
- </div>
566
- );
567
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { routedPage } from "../_routedPage.js";
6
+ import { useNavigate } from "../../shared/navigation/AppRouter.js";
7
+ import React, { useEffect, useMemo, useRef, useState } from "react";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import {
10
+ Contact,
11
+ createContact,
12
+ deleteContact,
13
+ listContacts,
14
+ listDeletedContacts,
15
+ setContactFavorite,
16
+ updateContact,
17
+ } from "@rapidmx/react-shared/contacts/contactsApi.js";
18
+ import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
19
+ import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
20
+ import { useCompose } from "../../shared/components/mail/compose/ComposeContext.js";
21
+ import ContactsShell, { ContactsShellProps, useContactsShell } from "../../shared/components/contacts/layout/ContactsShell.js";
22
+ import ContactsSidebar, { ContactsView } from "../../shared/components/contacts/ContactsSidebar.js";
23
+ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js";
24
+ import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
25
+ import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
26
+ import ContactForm from "../../shared/components/contacts/ContactForm.js";
27
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
28
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
29
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
30
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
31
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
32
+ import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
33
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
34
+ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
35
+ import { useShortcut } from "../../shared/keyboard/useShortcut.js";
36
+
37
+ const INPUT_CLASS =
38
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
39
+
40
+ function ContactsPage(props: ContactsShellProps) {
41
+ return (
42
+ <ContactsShell {...props}>
43
+ <ContactsContent userUid={props.userUid} />
44
+ </ContactsShell>
45
+ );
46
+ }
47
+
48
+ type Mode = "view" | "edit" | "new";
49
+ type SortColumn = "name" | "info";
50
+
51
+ /** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
52
+ const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
53
+
54
+ function primaryInfo(contact: Contact): string {
55
+ return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
56
+ }
57
+
58
+ /** How long an export's object URL is kept alive after the click - some browsers are still reading the
59
+ * blob after `click()` returns, so revoking it synchronously can cancel the download. */
60
+ const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60_000;
61
+
62
+ function downloadTextFile(filename: string, content: string): void {
63
+ const blob = new Blob([content], { type: "text/vcard" });
64
+ const url = URL.createObjectURL(blob);
65
+ const a = document.createElement("a");
66
+ a.href = url;
67
+ a.download = filename;
68
+ document.body.appendChild(a);
69
+ a.click();
70
+ a.remove();
71
+ setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
72
+ }
73
+
74
+ function ContactsContent({ userUid }: { userUid?: string }) {
75
+ const { folderUid, mailboxUid, mailboxes } = useContactsShell();
76
+ // The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
77
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
78
+ const { openCompose } = useCompose();
79
+ const isMobile = useIsMobile();
80
+ const navigate = useNavigate();
81
+ const [contacts, setContacts] = useState<Contact[]>([]);
82
+ const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
83
+ const [deletedLoading, setDeletedLoading] = useState(false);
84
+ const [deletedError, setDeletedError] = useState<string | null>(null);
85
+ const [loading, setLoading] = useState(true);
86
+ const [error, setError] = useState<string | null>(null);
87
+ // Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
88
+ const [truncated, setTruncated] = useState(false);
89
+ const [deletedTruncated, setDeletedTruncated] = useState(false);
90
+ const [query, setQuery] = useState("");
91
+ const [view, setView] = useState<ContactsView>({ type: "all" });
92
+ const [sortColumn, setSortColumn] = useState<SortColumn>("name");
93
+ const [sortDesc, setSortDesc] = useState(false);
94
+ const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
95
+ const [selectedUid, setSelectedUid] = useState<string | null>(null);
96
+ const [mode, setMode] = useState<Mode>("view");
97
+ // Set after a contact is created in a different mailbox than the one this list shows.
98
+ const [savedElsewhere, setSavedElsewhere] = useState<{ mailboxUid: string; displayName: string } | null>(null);
99
+ const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
100
+
101
+ /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
102
+ * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
103
+ * which would otherwise silently wipe out a bulk action's own just-set failure message before the user
104
+ * ever saw it, since every bulk handler calls this right after its own error-setting loop. */
105
+ function reload(): Promise<void> {
106
+ if (!folderUid) {
107
+ setContacts([]);
108
+ setTruncated(false);
109
+ setLoading(false);
110
+ return Promise.resolve();
111
+ }
112
+ setLoading(true);
113
+ setError(null);
114
+ return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
115
+ .then((result) => {
116
+ setContacts(result.items);
117
+ setTruncated(result.truncated);
118
+ })
119
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
120
+ .finally(() => setLoading(false));
121
+ }
122
+
123
+ useEffect(() => {
124
+ void reload();
125
+ }, [folderUid]);
126
+
127
+ // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
128
+ // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
129
+ // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
130
+ // access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
131
+ // asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
132
+ // load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
133
+ // and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
134
+ const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
135
+ const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
136
+ // Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
137
+ const [delegateCanUpdate, setDelegateCanUpdate] = useState<boolean | undefined>(undefined);
138
+ useEffect(() => {
139
+ if (ownsMailbox) {
140
+ return;
141
+ }
142
+ let cancelled = false;
143
+ getMyMailboxAccess(mailboxUid!).then(
144
+ (access) => {
145
+ if (!cancelled) {
146
+ setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
147
+ setDelegateCanUpdate(access.canUpdate);
148
+ }
149
+ },
150
+ () => undefined,
151
+ );
152
+ return () => {
153
+ cancelled = true;
154
+ };
155
+ }, [ownsMailbox]);
156
+ const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
157
+
158
+ useEffect(() => {
159
+ if (view.type !== "deleted" || !folderUid) {
160
+ return;
161
+ }
162
+ setDeletedLoading(true);
163
+ setDeletedError(null);
164
+ listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
165
+ .then((result) => {
166
+ // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
167
+ // contacts, which must never be listed as deleted.
168
+ if (result.items.some((c) => c.deleted !== true)) {
169
+ setDeletedContacts([]);
170
+ setDeletedTruncated(false);
171
+ setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
172
+ return;
173
+ }
174
+ setDeletedContacts(result.items);
175
+ setDeletedTruncated(result.truncated);
176
+ })
177
+ .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
178
+ .finally(() => setDeletedLoading(false));
179
+ // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
180
+ // convention elsewhere rather than caching a possibly-stale list across visits.
181
+ }, [view, folderUid]);
182
+
183
+ const viewFiltered = useMemo(() => {
184
+ switch (view.type) {
185
+ case "all":
186
+ return contacts;
187
+ case "favorites":
188
+ return contacts.filter((c) => c.favorite);
189
+ case "list":
190
+ return contacts.filter((c) => c.contactListUid === view.uid);
191
+ case "category":
192
+ return contacts.filter((c) => (c.categories ?? []).includes(view.name));
193
+ case "deleted":
194
+ return deletedContacts;
195
+ }
196
+ }, [contacts, deletedContacts, view]);
197
+
198
+ const filtered = useMemo(() => {
199
+ const q = query.trim().toLowerCase();
200
+ if (!q) {
201
+ return viewFiltered;
202
+ }
203
+ return viewFiltered.filter(
204
+ (c) => c.displayName.toLowerCase().includes(q) || c.emails.some((e) => e.address.toLowerCase().includes(q)),
205
+ );
206
+ }, [viewFiltered, query]);
207
+
208
+ const sorted = useMemo(() => {
209
+ const arr = [...filtered];
210
+ arr.sort((a, b) => {
211
+ const cmp = (sortColumn === "name" ? a.displayName : primaryInfo(a)).localeCompare(
212
+ sortColumn === "name" ? b.displayName : primaryInfo(b),
213
+ );
214
+ return sortDesc ? -cmp : cmp;
215
+ });
216
+ return arr;
217
+ }, [filtered, sortColumn, sortDesc]);
218
+
219
+ const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
220
+ const isDeletedView = view.type === "deleted";
221
+ const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
222
+ const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
223
+
224
+ function handleSort(column: SortColumn) {
225
+ if (column === sortColumn) {
226
+ setSortDesc((prev) => !prev);
227
+ } else {
228
+ setSortColumn(column);
229
+ setSortDesc(false);
230
+ }
231
+ }
232
+
233
+ function toggleChecked(uid: string) {
234
+ setCheckedUids((prev) => {
235
+ const next = new Set(prev);
236
+ if (next.has(uid)) {
237
+ next.delete(uid);
238
+ } else {
239
+ next.add(uid);
240
+ }
241
+ return next;
242
+ });
243
+ }
244
+
245
+ function toggleCheckAll() {
246
+ setCheckedUids(allChecked ? new Set() : new Set(sorted.map((c) => c.uid)));
247
+ }
248
+
249
+ function handleSelectView(next: ContactsView) {
250
+ setView(next);
251
+ setCheckedUids(new Set());
252
+ }
253
+
254
+ function handleSelectRow(contact: Contact) {
255
+ if (isMobile) {
256
+ navigate(`/contacts/${encodeURIComponent(contact.uid)}`);
257
+ return;
258
+ }
259
+ setSelectedUid(contact.uid);
260
+ setMode("view");
261
+ }
262
+
263
+ function handleNew() {
264
+ setSelectedUid(null);
265
+ setMode("new");
266
+ }
267
+
268
+ // Keyboard shortcuts - the toolbar's New contact and the search box. (A dialog open silences them.)
269
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
270
+ useShortcut(SHORTCUTS.contacts.create, handleNew);
271
+ useShortcut(SHORTCUTS.contacts.search, () => {
272
+ searchInputRef.current?.focus();
273
+ searchInputRef.current?.select();
274
+ });
275
+
276
+ function handleSaved(contact: Contact) {
277
+ setMode("view");
278
+ if (contact.mailboxUid !== mailboxUid) {
279
+ // Created in another mailbox (via the form's Mailbox selector) - it won't be in this list.
280
+ setSelectedUid(null);
281
+ setSavedElsewhere({ mailboxUid: contact.mailboxUid, displayName: contact.displayName });
282
+ return;
283
+ }
284
+ setSavedElsewhere(null);
285
+ setSelectedUid(contact.uid);
286
+ void reload();
287
+ }
288
+
289
+ function handleCancel() {
290
+ setMode("view");
291
+ }
292
+
293
+ async function handleDelete(contact: Contact) {
294
+ try {
295
+ await deleteContact(contact.uid, contact.version);
296
+ // Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
297
+ clearPinnedSignerCache();
298
+ setSelectedUid(null);
299
+ setMode("view");
300
+ void reload();
301
+ } catch (err) {
302
+ setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
303
+ }
304
+ }
305
+
306
+ async function handleBulkDelete() {
307
+ setConfirmingBulkDelete(false);
308
+ let bulkError: string | null = null;
309
+ for (const contact of checkedContacts) {
310
+ try {
311
+ await deleteContact(contact.uid, contact.version);
312
+ } catch (err) {
313
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
314
+ }
315
+ }
316
+ clearPinnedSignerCache();
317
+ setCheckedUids(new Set());
318
+ await reload();
319
+ setError(bulkError);
320
+ }
321
+
322
+ // `ContactsToolbar`'s Edit button — the only caller — is itself `disabled` unless exactly one contact
323
+ // is checked, so `checkedContacts[0]` is guaranteed to exist whenever this actually runs; no length
324
+ // guard needed, matching this codebase's established pattern for the same class of "always non-empty
325
+ // by the time it's called" value guaranteed by a disabled control (see `ComposeToolbar.run()`).
326
+ function handleToolbarEdit() {
327
+ setSelectedUid(checkedContacts[0].uid);
328
+ setMode("edit");
329
+ }
330
+
331
+ function handleEmail() {
332
+ const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a): a is string => Boolean(a));
333
+ // No mailboxUid: a fresh message defaults to the caller's own mailbox, not whichever mailbox's
334
+ // contacts happen to be open - the compose window's From field can still change it.
335
+ openCompose({ to: addresses.join(", ") });
336
+ }
337
+
338
+ async function handleToggleFavorite() {
339
+ const allFavorited = checkedContacts.every((c) => c.favorite);
340
+ let bulkError: string | null = null;
341
+ for (const contact of checkedContacts) {
342
+ try {
343
+ await setContactFavorite(contact, !allFavorited);
344
+ } catch (err) {
345
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
346
+ }
347
+ }
348
+ clearPinnedSignerCache();
349
+ await reload();
350
+ setError(bulkError);
351
+ }
352
+
353
+ async function handleAddCategory() {
354
+ const category = window.prompt("Category name");
355
+ if (!category?.trim()) {
356
+ return;
357
+ }
358
+ let bulkError: string | null = null;
359
+ for (const contact of checkedContacts) {
360
+ const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
361
+ try {
362
+ // Only the changed field - sending the whole fetched contact back includes server-managed
363
+ // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
364
+ await updateContact({ uid: contact.uid, version: contact.version, categories });
365
+ } catch (err) {
366
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
367
+ }
368
+ }
369
+ clearPinnedSignerCache();
370
+ await reload();
371
+ setError(bulkError);
372
+ }
373
+
374
+ function handleExportVCard() {
375
+ downloadTextFile(
376
+ checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
377
+ checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
378
+ );
379
+ }
380
+
381
+ async function handleImportFile(file: File) {
382
+ if (!mailboxUid || !folderUid) {
383
+ return;
384
+ }
385
+ const text = await file.text();
386
+ const parsed = parseVCards(text);
387
+ let bulkError: string | null = null;
388
+ for (const input of parsed) {
389
+ try {
390
+ await createContact({ mailboxUid, folderUid, ...input });
391
+ } catch (err) {
392
+ bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
393
+ }
394
+ }
395
+ clearPinnedSignerCache();
396
+ await reload();
397
+ setError(bulkError);
398
+ }
399
+
400
+ return (
401
+ <div className="flex-1 flex min-h-0">
402
+ <ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} showDeleted={canViewDeleted} />
403
+ {/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
404
+ in-place — see the pane's own comment below) is showing, so the two never compete for the
405
+ same row's width. Desktop always shows both side by side, unchanged. */}
406
+ <div
407
+ className={[
408
+ "w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
409
+ mode === "new" ? "hidden md:flex" : "flex",
410
+ ].join(" ")}
411
+ >
412
+ <ContactsToolbar
413
+ selectedCount={checkedContacts.length}
414
+ allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
415
+ onNewContact={handleNew}
416
+ onEdit={handleToolbarEdit}
417
+ onDelete={() => setConfirmingBulkDelete(true)}
418
+ onEmail={handleEmail}
419
+ onToggleFavorite={handleToggleFavorite}
420
+ onAddCategory={handleAddCategory}
421
+ onExportVCard={handleExportVCard}
422
+ onImportFile={handleImportFile}
423
+ shortcuts
424
+ />
425
+ <div className="p-3 border-b border-border">
426
+ <input
427
+ ref={searchInputRef}
428
+ type="search"
429
+ value={query}
430
+ onChange={(e) => setQuery(e.target.value)}
431
+ placeholder="Search contacts"
432
+ aria-label="Search contacts"
433
+ className={INPUT_CLASS}
434
+ />
435
+ </div>
436
+ {error && (
437
+ <div className="p-3">
438
+ <Alert>{error}</Alert>
439
+ </div>
440
+ )}
441
+ {(isDeletedView ? deletedTruncated : truncated) && (
442
+ <div className="p-3">
443
+ <Alert>
444
+ This folder has more contacts than can be shown at once - only the first {LIST_PAGE_SIZE * MAX_LIST_PAGES} are
445
+ listed.
446
+ </Alert>
447
+ </div>
448
+ )}
449
+ {deletedError && isDeletedView && (
450
+ <div className="p-3">
451
+ <Alert>{deletedError}</Alert>
452
+ </div>
453
+ )}
454
+ {(isDeletedView ? deletedLoading : loading) ? (
455
+ <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
456
+ ) : sorted.length === 0 ? (
457
+ <p className="p-4 text-sm text-text-muted">No contacts found.</p>
458
+ ) : (
459
+ <div className="flex-1 overflow-auto">
460
+ <table className="w-full text-sm">
461
+ <thead>
462
+ <tr className="border-b border-border text-left">
463
+ {!isDeletedView && (
464
+ <th className="w-8 px-3 py-2">
465
+ <input
466
+ type="checkbox"
467
+ aria-label="Select all contacts"
468
+ checked={allChecked}
469
+ onChange={toggleCheckAll}
470
+ />
471
+ </th>
472
+ )}
473
+ <th className="px-3 py-2">
474
+ <button
475
+ type="button"
476
+ onClick={() => handleSort("name")}
477
+ className="text-xs font-bold uppercase tracking-wide text-text-muted"
478
+ >
479
+ Name{sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""}
480
+ </button>
481
+ </th>
482
+ <th className="px-3 py-2">
483
+ <button
484
+ type="button"
485
+ onClick={() => handleSort("info")}
486
+ className="text-xs font-bold uppercase tracking-wide text-text-muted"
487
+ >
488
+ Contact info{sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""}
489
+ </button>
490
+ </th>
491
+ </tr>
492
+ </thead>
493
+ <tbody>
494
+ {sorted.map((contact) => (
495
+ <tr
496
+ key={contact.uid}
497
+ className={["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(
498
+ " ",
499
+ )}
500
+ >
501
+ {!isDeletedView && (
502
+ <td className="px-3 py-2">
503
+ <input
504
+ type="checkbox"
505
+ aria-label={`Select ${contact.displayName}`}
506
+ checked={checkedUids.has(contact.uid)}
507
+ onChange={() => toggleChecked(contact.uid)}
508
+ />
509
+ </td>
510
+ )}
511
+ <td className="px-3 py-2">
512
+ <button type="button" onClick={() => handleSelectRow(contact)} className="flex items-center gap-2 text-left">
513
+ <ContactAvatar displayName={contact.displayName} size={28} />
514
+ <span className="truncate font-medium">
515
+ {contact.displayName}
516
+ {contact.favorite && <span aria-label="Favorite"> ★</span>}
517
+ </span>
518
+ </button>
519
+ </td>
520
+ <td className="px-3 py-2 text-text-muted truncate">{primaryInfo(contact)}</td>
521
+ </tr>
522
+ ))}
523
+ </tbody>
524
+ </table>
525
+ </div>
526
+ )}
527
+ </div>
528
+ {/* On mobile, "selected"/"edit" are unreachable (row taps navigate to /contacts/:uid instead —
529
+ see handleSelectRow), so this pane only needs to show there for "new", which stays in-place
530
+ on every device (an unsaved contact has no uid for a route). Always visible on desktop. */}
531
+ <div className={["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" ")}>
532
+ {savedElsewhere && mode !== "new" && (
533
+ <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
534
+ {savedElsewhere.displayName} was added to{" "}
535
+ {mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox"}.{" "}
536
+ <a
537
+ href={`/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`}
538
+ className="font-medium text-primary-dark hover:underline"
539
+ >
540
+ View that mailbox&rsquo;s contacts
541
+ </a>
542
+ </p>
543
+ )}
544
+ {mode === "new" && mailboxUid && folderUid ? (
545
+ <ContactForm
546
+ mailboxUid={mailboxUid}
547
+ folderUid={folderUid}
548
+ mailboxes={writableMailboxes}
549
+ onSaved={handleSaved}
550
+ onCancel={handleCancel}
551
+ />
552
+ ) : mode === "edit" && selected ? (
553
+ <ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
554
+ ) : selected ? (
555
+ <ContactDetailPane
556
+ contact={selected}
557
+ onEdit={() => setMode("edit")}
558
+ onDelete={() => handleDelete(selected)}
559
+ onKeysChanged={() => void reload()}
560
+ canResolveKeys={ownsMailbox || delegateCanUpdate}
561
+ />
562
+ ) : (
563
+ <p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
564
+ )}
565
+ </div>
566
+ <Modal open={confirmingBulkDelete} onClose={() => setConfirmingBulkDelete(false)} title="Delete contacts">
567
+ <p className="text-sm mb-5">
568
+ Delete {checkedContacts.length} selected {checkedContacts.length === 1 ? "contact" : "contacts"}? They
569
+ move to Deleted contacts.
570
+ </p>
571
+ <div className="flex gap-3 justify-end">
572
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setConfirmingBulkDelete(false)}>
573
+ Cancel
574
+ </Button>
575
+ <Button type="button" className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger" onClick={handleBulkDelete}>
576
+ Delete
577
+ </Button>
578
+ </div>
579
+ </Modal>
580
+ </div>
581
+ );
582
+ }
583
+
584
+ export default routedPage("/contacts", ContactsPage);