@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
@@ -0,0 +1,38 @@
1
+ import { deriveLocalIndexKey } from "./localIndexKey.js";
2
+ import { getLocalCoverage, initLocalIndex, searchLocal } from "./localIndexRpcClient.js";
3
+ /** Ensures this mailbox's local index connection is open before it's queried - idempotent
4
+ * (`localIndexWorker.ts`'s own `init()` no-ops for an already-open mailbox), so callers never need to
5
+ * coordinate with `LocalIndexLifecycle.tsx`'s own unlock-triggered `init()` call; whichever runs first
6
+ * wins, and the other is a cheap no-op. */
7
+ async function ensureInitialized(mailboxUid, unlocked) {
8
+ const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
9
+ await initLocalIndex({ mailboxUid, indexKey });
10
+ }
11
+ export async function searchLocalIndex(mailboxUid, parsed, unlocked, limit = 50, offset = 0) {
12
+ if (!unlocked) {
13
+ return { results: [], hasMore: false };
14
+ }
15
+ try {
16
+ await ensureInitialized(mailboxUid, unlocked);
17
+ const [page, coverage] = await Promise.all([searchLocal(mailboxUid, parsed, limit, offset), getLocalCoverage(mailboxUid)]);
18
+ const results = page.hits.map((hit) => ({
19
+ entityType: "message",
20
+ entityUid: hit.entityUid,
21
+ // Negated: bm25()'s own convention is "more negative is a better match" (SQLite), the
22
+ // opposite of every other score this codebase merges (Postgres ts_rank, OpenSearch _score,
23
+ // and normalizeServerScores() itself all treat "higher is better"). Negating here, once,
24
+ // keeps that convention uniform for the caller (apps/www/index.tsx's merge layer) - it never
25
+ // needs to know this tier's underlying scoring function works backwards from the others.
26
+ score: -hit.score,
27
+ snippet: hit.snippet,
28
+ source: "local",
29
+ metadataOnly: false,
30
+ }));
31
+ return { results, coverage, hasMore: page.hasMore };
32
+ }
33
+ catch {
34
+ // See this function's own doc comment - a broken local index degrades to "nothing to contribute
35
+ // this time," never a rejected search.
36
+ return { results: [], hasMore: false };
37
+ }
38
+ }
@@ -3,12 +3,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { useEffect, useMemo, useState } from "react";
6
+ import { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { DndContext, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
8
8
  import { HiOutlineBars3 } from "react-icons/hi2";
9
- import { addDays, addMonths, addWeeks, eachDayOfInterval, endOfDay, endOfMonth, endOfWeek, format, startOfDay, startOfMonth, startOfWeek, } from "date-fns";
9
+ import { addDays, addMonths, addWeeks, eachDayOfInterval, endOfDay, endOfMonth, endOfWeek, format, parseISO, startOfDay, startOfMonth, startOfWeek, } from "date-fns";
10
10
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
11
- import { colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
12
11
  import { listCalendarEvents } from "@rapidmx/react-shared/calendar/calendarApi.js";
13
12
  import { moveOccurrence, resizeOccurrenceEnd } from "@rapidmx/react-shared/calendar/calendarMutations.js";
14
13
  import { resolveDragAction } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
@@ -17,6 +16,7 @@ import { expandAllOccurrences } from "@rapidmx/react-shared/calendar/recurrence.
17
16
  import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
18
17
  import CalendarShell, { useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
19
18
  import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
19
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
20
20
  import EventModal from "../../shared/components/calendar/EventModal.js";
21
21
  import MiniDatePicker from "@rapidmx/react-shared/components/pickers/MiniDatePicker.js";
22
22
  import MonthView from "../../shared/components/calendar/MonthView.js";
@@ -25,15 +25,12 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
25
25
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
26
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
27
27
  export default function CalendarPage(props) {
28
- return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, {}) }));
28
+ return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, { userUid: props.userUid }) }));
29
29
  }
30
30
  const VIEW_TYPES = ["month", "week", "workWeek", "day", "split"];
31
31
  const VIEW_LABELS = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
32
- function CalendarContent() {
33
- const { mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders } = useCalendarShell();
34
- // `CalendarShell` only ever renders this component once `mailboxUid` is set, and always to a value
35
- // drawn from `mailboxes` itself (see its own resolution logic) — the lookup below always succeeds.
36
- const organizerAddress = mailboxes.find((mb) => mb.uid === mailboxUid).primarySmtpAddress;
32
+ function CalendarContent({ userUid }) {
33
+ const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } = useCalendarShell();
37
34
  // `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
38
35
  // scroll gesture on a touch device. `MouseSensor` (a small `distance` — desktop drags still start
39
36
  // immediately on a deliberate movement, no change from before) + `TouchSensor` (a `delay`+`tolerance`
@@ -59,13 +56,15 @@ function CalendarContent() {
59
56
  setView(requestedView);
60
57
  }
61
58
  const requestedDate = params.get("date");
62
- const parsedDate = requestedDate ? new Date(requestedDate) : null;
59
+ // `parseISO()`, not `new Date()` - `?date=2026-06-16` is a local calendar day, but `new Date()` reads a
60
+ // date-only string as UTC midnight, i.e. the previous day anywhere west of UTC.
61
+ const parsedDate = requestedDate ? parseISO(requestedDate) : null;
63
62
  if (parsedDate && !isNaN(parsedDate.getTime())) {
64
63
  setViewDate(startOfDay(parsedDate));
65
64
  }
66
65
  }, []);
67
66
  const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
68
- const folderColors = useMemo(() => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorForFolder(f)])), [calendarFolders]);
67
+ const folderColors = useMemo(() => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorFor(f)])), [calendarFolders, colorFor]);
69
68
  function toggleCalendar(toggledUid) {
70
69
  setCheckedFolderUidsState((prev) => {
71
70
  const next = new Set(prev ?? calendarFolders.map((f) => f.uid));
@@ -78,9 +77,8 @@ function CalendarContent() {
78
77
  return next;
79
78
  });
80
79
  }
81
- async function handleAddCalendar(name, color) {
82
- // `CalendarContent` only renders once `mailboxUid` is resolved — same invariant as `organizerAddress` above.
83
- const created = await createFolder({ mailboxUid: mailboxUid, name, type: "calendar", color });
80
+ async function handleAddCalendar(targetMailboxUid, name, color) {
81
+ const created = await createFolder({ mailboxUid: targetMailboxUid, name, type: "calendar", color });
84
82
  setCheckedFolderUidsState((prev) => new Set([...(prev ?? calendarFolders.map((f) => f.uid)), created.uid]));
85
83
  reloadFolders();
86
84
  }
@@ -105,7 +103,13 @@ function CalendarContent() {
105
103
  const end = endOfDay(viewDate);
106
104
  return { rangeStart: start, rangeEnd: end, days: [start] };
107
105
  }, [view, viewDate]);
106
+ // Only the most recent reload() may apply its results - toggling calendars (or saving/dragging an event)
107
+ // while an earlier, slower fetch is still in flight must not let that stale response overwrite newer
108
+ // events. (No date range is passed: `listCalendarEvents()` deliberately fetches the whole calendar and
109
+ // filters client-side - see its own doc comment on the server-side range query it works around.)
110
+ const reloadSeqRef = useRef(0);
108
111
  function reload() {
112
+ const seq = ++reloadSeqRef.current;
109
113
  const targets = calendarFolders.filter((f) => checkedFolderUids.has(f.uid));
110
114
  if (targets.length === 0) {
111
115
  setEvents([]);
@@ -116,6 +120,9 @@ function CalendarContent() {
116
120
  setError(null);
117
121
  void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid)))
118
122
  .then((results) => {
123
+ if (seq !== reloadSeqRef.current) {
124
+ return;
125
+ }
119
126
  const loaded = [];
120
127
  const failures = [];
121
128
  results.forEach((result, i) => {
@@ -142,7 +149,11 @@ function CalendarContent() {
142
149
  setError(`Could not load events for: ${failures.map((f) => f.name).join(", ")}.`);
143
150
  }
144
151
  })
145
- .finally(() => setLoading(false));
152
+ .finally(() => {
153
+ if (seq === reloadSeqRef.current) {
154
+ setLoading(false);
155
+ }
156
+ });
146
157
  }
147
158
  // Keyed on stable string summaries (not the array/set objects themselves, which are new references
148
159
  // every render) so this only re-fires when the actual set of folders to fetch changes.
@@ -152,7 +163,7 @@ function CalendarContent() {
152
163
  const occurrences = useMemo(() => expandAllOccurrences(events, rangeStart, rangeEnd), [events, rangeStart, rangeEnd]);
153
164
  const splitColumns = useMemo(() => calendarFolders
154
165
  .filter((f) => checkedFolderUids.has(f.uid))
155
- .map((f) => ({ folderUid: f.uid, name: f.name, color: colorForFolder(f) })), [calendarFolders, checkedFolderUidsKey]);
166
+ .map((f) => ({ folderUid: f.uid, name: f.name, color: colorFor(f) })), [calendarFolders, checkedFolderUidsKey, colorFor]);
156
167
  function shiftView(direction) {
157
168
  setViewDate((d) => view === "month"
158
169
  ? addMonths(d, direction)
@@ -217,12 +228,21 @@ function CalendarContent() {
217
228
  : view === "week" || view === "workWeek"
218
229
  ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
219
230
  : format(viewDate, "EEEE, MMMM d, yyyy");
220
- const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => setViewDate(startOfDay(date)) }), _jsx(CalendarListSidebar, { calendars: calendarFolders, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar })] }));
231
+ const modalFolderUid = modal ? (modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid) : undefined;
232
+ const modalMailboxUid = calendarFolders.find((f) => f.uid === modalFolderUid)?.mailboxUid ?? mailboxUid;
233
+ const modalMailbox = mailboxes.find((mb) => mb.uid === modalMailboxUid);
234
+ // Mailboxes a new event can be created in - only those with at least one calendar that the caller can
235
+ // write to (a view-only share is left out; see writableMailboxes.ts).
236
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, modalMailboxUid);
237
+ const mailboxOptions = useMemo(() => mailboxCalendars
238
+ .filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
239
+ .map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })), [mailboxCalendars, writableMailboxes]);
240
+ const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => setViewDate(startOfDay(date)) }), _jsx(CalendarListSidebar, { mailboxCalendars: mailboxCalendars, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar, colorFor: colorFor })] }));
221
241
  return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx("div", { className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto", children: sidebarContent }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Calendars", children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex items-center gap-3 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "md:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0", "aria-label": "Open calendars", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(Button, { type: "button", onClick: () => openNewEvent(), className: "!w-auto shrink-0", children: "+ New event" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, className: "px-2 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "Today" }), _jsx("button", { type: "button", onClick: () => shiftView(1), "aria-label": "Next", className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u203A" })] }), _jsx("h1", { className: "hidden md:block text-lg font-bold tracking-tight", children: title }), _jsx("div", { className: "ml-auto flex gap-1 overflow-x-auto", children: VIEW_TYPES.map((v) => (_jsx("button", { type: "button", onClick: () => setView(v), className: [
222
242
  "px-3 h-7 text-sm rounded-sm whitespace-nowrap",
223
243
  view === v ? "bg-primary text-white" : "hover:bg-surface-alt",
224
244
  (v === "workWeek" || v === "split") && "hidden md:inline-block",
225
245
  ]
226
246
  .filter(Boolean)
227
- .join(" "), children: VIEW_LABELS[v] }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), modal && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: mailboxUid, folderUid: modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid, calendars: calendarFolders.map((f) => ({ uid: f.uid, name: f.name })), organizerAddress: organizerAddress, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, onSaved: handleSaved, onDeleted: handleDeleted }))] })] }));
247
+ .join(" "), children: VIEW_LABELS[v] }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), actionError && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: actionError }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: openNewEvent })) })), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders.filter((f) => f.mailboxUid === modalMailbox.uid).map((f) => ({ uid: f.uid, name: f.name })), mailboxOptions: mailboxOptions, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, onSaved: handleSaved, onDeleted: handleDeleted }))] })] }));
228
248
  }
@@ -10,6 +10,7 @@ import ContactsShell from "../../shared/components/contacts/layout/ContactsShell
10
10
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
11
11
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
12
12
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
13
14
  /**
14
15
  * Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
15
16
  * existing inline detail pane and never navigates here; see that file's `handleSelectRow`. There is no
@@ -23,6 +24,9 @@ function ContactDetailContent({ uid }) {
23
24
  const [contact, setContact] = useState(null);
24
25
  const [loading, setLoading] = useState(true);
25
26
  const [error, setError] = useState(null);
27
+ // Kept apart from the load `error` - a failed delete must leave the contact on screen (with the
28
+ // reason) rather than replacing the whole page with an error as if it had never loaded.
29
+ const [deleteError, setDeleteError] = useState(null);
26
30
  const [mode, setMode] = useState("view");
27
31
  useEffect(() => {
28
32
  setLoading(true);
@@ -37,14 +41,21 @@ function ContactDetailContent({ uid }) {
37
41
  // rendered once `contact` is already resolved (see the `error || !contact` guard above it), so this
38
42
  // never runs with a stale/absent contact — no redundant null check needed here.
39
43
  async function handleDelete(contact) {
44
+ setDeleteError(null);
40
45
  try {
41
46
  await deleteContact(contact.uid, contact.version);
47
+ // A deleted contact's pinned signing keys must stop vouching for signatures.
48
+ clearPinnedSignerCache();
42
49
  window.location.href = "/contacts";
43
50
  }
44
51
  catch (err) {
45
- setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
52
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
46
53
  }
47
54
  }
55
+ // After a key change was resolved (or found stale): re-read the contact, keeping the one shown if that fails.
56
+ function handleKeysChanged() {
57
+ getContact(uid).then(setContact, () => undefined);
58
+ }
48
59
  if (loading) {
49
60
  return _jsx("p", { className: "p-8 text-sm text-text-muted", children: "Loading\u2026" });
50
61
  }
@@ -58,5 +69,5 @@ function ContactDetailContent({ uid }) {
58
69
  setMode("view");
59
70
  }, onCancel: () => setMode("view") })] }));
60
71
  }
61
- return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref }) }));
72
+ return (_jsxs("div", { className: "p-6", children: [deleteError && _jsx(Alert, { children: deleteError }), _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged })] }));
62
73
  }
@@ -15,25 +15,40 @@ import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js
15
15
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
16
16
  import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
17
17
  import ContactForm from "../../shared/components/contacts/ContactForm.js";
18
+ import { useWritableMailboxes } from "../../shared/components/mail/writableMailboxes.js";
19
+ import { getMyMailboxAccess } from "@rapidmx/react-shared/mail/mailboxAccessApi.js";
18
20
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
21
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
22
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
23
+ import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "../../shared/mail/listAllPages.js";
24
+ import { clearPinnedSignerCache } from "../../shared/components/mail/pinnedSigners.js";
19
25
  const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
20
26
  export default function ContactsPage(props) {
21
- return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, {}) }));
27
+ return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
22
28
  }
29
+ /** Shown in the Deleted view when the server answered `?deleted=true` with live contacts (see `canViewDeleted`). */
30
+ const DELETED_NOT_PERMITTED_MESSAGE = "You don't have permission to view deleted contacts in this folder.";
23
31
  function primaryInfo(contact) {
24
32
  return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
25
33
  }
34
+ /** How long an export's object URL is kept alive after the click - some browsers are still reading the
35
+ * blob after `click()` returns, so revoking it synchronously can cancel the download. */
36
+ const VCARD_DOWNLOAD_URL_LIFETIME_MS = 60000;
26
37
  function downloadTextFile(filename, content) {
27
38
  const blob = new Blob([content], { type: "text/vcard" });
28
39
  const url = URL.createObjectURL(blob);
29
40
  const a = document.createElement("a");
30
41
  a.href = url;
31
42
  a.download = filename;
43
+ document.body.appendChild(a);
32
44
  a.click();
33
- URL.revokeObjectURL(url);
45
+ a.remove();
46
+ setTimeout(() => URL.revokeObjectURL(url), VCARD_DOWNLOAD_URL_LIFETIME_MS);
34
47
  }
35
- function ContactsContent() {
36
- const { folderUid, mailboxUid } = useContactsShell();
48
+ function ContactsContent({ userUid }) {
49
+ const { folderUid, mailboxUid, mailboxes } = useContactsShell();
50
+ // The new-contact form's Mailbox choices - view-only shares are left out (see writableMailboxes.ts).
51
+ const writableMailboxes = useWritableMailboxes(mailboxes, userUid, mailboxUid);
37
52
  const { openCompose } = useCompose();
38
53
  const isMobile = useIsMobile();
39
54
  const [contacts, setContacts] = useState([]);
@@ -42,6 +57,9 @@ function ContactsContent() {
42
57
  const [deletedError, setDeletedError] = useState(null);
43
58
  const [loading, setLoading] = useState(true);
44
59
  const [error, setError] = useState(null);
60
+ // Set when `listAllPages()` stopped at its page cap - the list shown isn't every contact.
61
+ const [truncated, setTruncated] = useState(false);
62
+ const [deletedTruncated, setDeletedTruncated] = useState(false);
45
63
  const [query, setQuery] = useState("");
46
64
  const [view, setView] = useState({ type: "all" });
47
65
  const [sortColumn, setSortColumn] = useState("name");
@@ -49,6 +67,9 @@ function ContactsContent() {
49
67
  const [checkedUids, setCheckedUids] = useState(new Set());
50
68
  const [selectedUid, setSelectedUid] = useState(null);
51
69
  const [mode, setMode] = useState("view");
70
+ // Set after a contact is created in a different mailbox than the one this list shows.
71
+ const [savedElsewhere, setSavedElsewhere] = useState(null);
72
+ const [confirmingBulkDelete, setConfirmingBulkDelete] = useState(false);
52
73
  /** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
53
74
  * own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
54
75
  * which would otherwise silently wipe out a bulk action's own just-set failure message before the user
@@ -56,27 +77,69 @@ function ContactsContent() {
56
77
  function reload() {
57
78
  if (!folderUid) {
58
79
  setContacts([]);
80
+ setTruncated(false);
59
81
  setLoading(false);
60
82
  return Promise.resolve();
61
83
  }
62
84
  setLoading(true);
63
85
  setError(null);
64
- return listContacts(folderUid, { limit: 500 })
65
- .then(setContacts)
86
+ return listAllPages((page) => listContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
87
+ .then((result) => {
88
+ setContacts(result.items);
89
+ setTruncated(result.truncated);
90
+ })
66
91
  .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
67
92
  .finally(() => setLoading(false));
68
93
  }
69
94
  useEffect(() => {
70
95
  void reload();
71
96
  }, [folderUid]);
97
+ // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
98
+ // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
99
+ // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
100
+ // access to a folder, so the mailbox-level check below is only a first gate (an owner always passes; anyone else
101
+ // asks the server; unknown - still loading, or the check failed - hides the view), and the Deleted view's own
102
+ // load below detects the server ignoring the filter. `ContactsShell` only renders this with a resolved mailbox,
103
+ // and switching mailboxes is a full page load, so `mailboxUid` never changes under this component.
104
+ const ownsMailbox = mailboxes.some((mb) => mb.uid === mailboxUid && mb.ownerUserUid !== undefined && mb.ownerUserUid === userUid);
105
+ const [delegateCanViewDeleted, setDelegateCanViewDeleted] = useState(false);
106
+ // Resolving a contact's key change needs update rights on the mailbox - `undefined` while unknown.
107
+ const [delegateCanUpdate, setDelegateCanUpdate] = useState(undefined);
108
+ useEffect(() => {
109
+ if (ownsMailbox) {
110
+ return;
111
+ }
112
+ let cancelled = false;
113
+ getMyMailboxAccess(mailboxUid).then((access) => {
114
+ if (!cancelled) {
115
+ setDelegateCanViewDeleted(access.canDelete && access.canUpdate);
116
+ setDelegateCanUpdate(access.canUpdate);
117
+ }
118
+ }, () => undefined);
119
+ return () => {
120
+ cancelled = true;
121
+ };
122
+ }, [ownsMailbox]);
123
+ const canViewDeleted = ownsMailbox || delegateCanViewDeleted;
72
124
  useEffect(() => {
73
125
  if (view.type !== "deleted" || !folderUid) {
74
126
  return;
75
127
  }
76
128
  setDeletedLoading(true);
77
129
  setDeletedError(null);
78
- listDeletedContacts(folderUid, { limit: 500 })
79
- .then(setDeletedContacts)
130
+ listAllPages((page) => listDeletedContacts(folderUid, { limit: LIST_PAGE_SIZE, page }))
131
+ .then((result) => {
132
+ // A server that dropped the `deleted` filter (no delete+update right on this folder) answers with live
133
+ // contacts, which must never be listed as deleted.
134
+ if (result.items.some((c) => c.deleted !== true)) {
135
+ setDeletedContacts([]);
136
+ setDeletedTruncated(false);
137
+ setDeletedError(DELETED_NOT_PERMITTED_MESSAGE);
138
+ return;
139
+ }
140
+ setDeletedContacts(result.items);
141
+ setDeletedTruncated(result.truncated);
142
+ })
80
143
  .catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
81
144
  .finally(() => setDeletedLoading(false));
82
145
  // Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
@@ -156,8 +219,15 @@ function ContactsContent() {
156
219
  setMode("new");
157
220
  }
158
221
  function handleSaved(contact) {
159
- setSelectedUid(contact.uid);
160
222
  setMode("view");
223
+ if (contact.mailboxUid !== mailboxUid) {
224
+ // Created in another mailbox (via the form's Mailbox selector) - it won't be in this list.
225
+ setSelectedUid(null);
226
+ setSavedElsewhere({ mailboxUid: contact.mailboxUid, displayName: contact.displayName });
227
+ return;
228
+ }
229
+ setSavedElsewhere(null);
230
+ setSelectedUid(contact.uid);
161
231
  void reload();
162
232
  }
163
233
  function handleCancel() {
@@ -166,6 +236,8 @@ function ContactsContent() {
166
236
  async function handleDelete(contact) {
167
237
  try {
168
238
  await deleteContact(contact.uid, contact.version);
239
+ // Trusted signer pins come from contacts - a deleted contact's keys must stop vouching for signatures.
240
+ clearPinnedSignerCache();
169
241
  setSelectedUid(null);
170
242
  setMode("view");
171
243
  void reload();
@@ -175,6 +247,7 @@ function ContactsContent() {
175
247
  }
176
248
  }
177
249
  async function handleBulkDelete() {
250
+ setConfirmingBulkDelete(false);
178
251
  let bulkError = null;
179
252
  for (const contact of checkedContacts) {
180
253
  try {
@@ -184,6 +257,7 @@ function ContactsContent() {
184
257
  bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
185
258
  }
186
259
  }
260
+ clearPinnedSignerCache();
187
261
  setCheckedUids(new Set());
188
262
  await reload();
189
263
  setError(bulkError);
@@ -198,9 +272,9 @@ function ContactsContent() {
198
272
  }
199
273
  function handleEmail() {
200
274
  const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a) => Boolean(a));
201
- // `ContactsShell` only ever renders this component once `mailboxUid` is resolved (see its own
202
- // invariant comment) — same non-null pattern as `organizerAddress` in the calendar page.
203
- openCompose({ mailboxUid: mailboxUid, to: addresses.join(", ") });
275
+ // No mailboxUid: a fresh message defaults to the caller's own mailbox, not whichever mailbox's
276
+ // contacts happen to be open - the compose window's From field can still change it.
277
+ openCompose({ to: addresses.join(", ") });
204
278
  }
205
279
  async function handleToggleFavorite() {
206
280
  const allFavorited = checkedContacts.every((c) => c.favorite);
@@ -213,6 +287,7 @@ function ContactsContent() {
213
287
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
214
288
  }
215
289
  }
290
+ clearPinnedSignerCache();
216
291
  await reload();
217
292
  setError(bulkError);
218
293
  }
@@ -225,12 +300,15 @@ function ContactsContent() {
225
300
  for (const contact of checkedContacts) {
226
301
  const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
227
302
  try {
228
- await updateContact({ ...contact, categories });
303
+ // Only the changed field - sending the whole fetched contact back includes server-managed
304
+ // fields restapi rejects, and would overwrite any concurrent edit to the other fields.
305
+ await updateContact({ uid: contact.uid, version: contact.version, categories });
229
306
  }
230
307
  catch (err) {
231
308
  bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
232
309
  }
233
310
  }
311
+ clearPinnedSignerCache();
234
312
  await reload();
235
313
  setError(bulkError);
236
314
  }
@@ -252,11 +330,12 @@ function ContactsContent() {
252
330
  bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
253
331
  }
254
332
  }
333
+ clearPinnedSignerCache();
255
334
  await reload();
256
335
  setError(bulkError);
257
336
  }
258
- return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView }), _jsxs("div", { className: [
337
+ return (_jsxs("div", { className: "flex-1 flex min-h-0", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted }), _jsxs("div", { className: [
259
338
  "w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
260
339
  mode === "new" ? "hidden md:flex" : "flex",
261
- ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: handleBulkDelete, onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsx("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." })) })] }));
340
+ ].join(" "), children: [_jsx(ContactsToolbar, { selectedCount: checkedContacts.length, allSelectedFavorited: checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite), onNewContact: handleNew, onEdit: handleToolbarEdit, onDelete: () => setConfirmingBulkDelete(true), onEmail: handleEmail, onToggleFavorite: handleToggleFavorite, onAddCategory: handleAddCategory, onExportVCard: handleExportVCard, onImportFile: handleImportFile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: "Search contacts", "aria-label": "Search contacts", className: INPUT_CLASS }) }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), (isDeletedView ? deletedTruncated : truncated) && (_jsx("div", { className: "p-3", children: _jsxs(Alert, { children: ["This folder has more contacts than can be shown at once - only the first ", LIST_PAGE_SIZE * MAX_LIST_PAGES, " are listed."] }) })), deletedError && isDeletedView && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: deletedError }) })), (isDeletedView ? deletedLoading : loading) ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : sorted.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "No contacts found." })) : (_jsx("div", { className: "flex-1 overflow-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-8 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Name", sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""] }) }), _jsx("th", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("info"), className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: ["Contact info", sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""] }) })] }) }), _jsx("tbody", { children: sorted.map((contact) => (_jsxs("tr", { className: ["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(" "), children: [!isDeletedView && (_jsx("td", { className: "px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": `Select ${contact.displayName}`, checked: checkedUids.has(contact.uid), onChange: () => toggleChecked(contact.uid) }) })), _jsx("td", { className: "px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSelectRow(contact), className: "flex items-center gap-2 text-left", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 28 }), _jsxs("span", { className: "truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "px-3 py-2 text-text-muted truncate", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text", children: [savedElsewhere.displayName, " was added to", " ", mailboxes.find((mb) => mb.uid === savedElsewhere.mailboxUid)?.displayName ?? "another mailbox", ".", " ", _jsx("a", { href: `/contacts?mailboxUid=${encodeURIComponent(savedElsewhere.mailboxUid)}`, className: "font-medium text-primary-dark hover:underline", children: "View that mailbox\u2019s contacts" })] })), mode === "new" && mailboxUid && folderUid ? (_jsx(ContactForm, { mailboxUid: mailboxUid, folderUid: folderUid, mailboxes: writableMailboxes, onSaved: handleSaved, onCancel: handleCancel })) : mode === "edit" && selected ? (_jsx(ContactForm, { contact: selected, onSaved: handleSaved, onCancel: handleCancel })) : selected ? (_jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), onKeysChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate })) : (_jsx("p", { className: "text-sm text-text-muted", children: "Select a contact, or create a new one." }))] }), _jsxs(Modal, { open: confirmingBulkDelete, onClose: () => setConfirmingBulkDelete(false), title: "Delete contacts", children: [_jsxs("p", { className: "text-sm mb-5", children: ["Delete ", checkedContacts.length, " selected ", checkedContacts.length === 1 ? "contact" : "contacts", "? They move to Deleted contacts."] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingBulkDelete(false), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", onClick: handleBulkDelete, children: "Delete" })] })] })] }));
262
341
  }