@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
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useDraggable, useDroppable } from "@dnd-kit/core";
3
- import { addMinutes, format, isSameDay, isToday, startOfDay } from "date-fns";
3
+ import { addDays, addMinutes, format, isToday, startOfDay } from "date-fns";
4
4
  import { eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
5
+ import { occursOnDay, startsOnDay } from "./allDay.js";
5
6
  const HOUR_HEIGHT_PX = 48;
6
7
  const SLOT_MINUTES = 30;
7
8
  const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
@@ -13,20 +14,37 @@ export default function TimeGridView({ days, occurrences, folderColors, onSelect
13
14
  const allDayEvents = occurrences.filter((occ) => occ.allDay);
14
15
  const timedEvents = occurrences.filter((occ) => !occ.allDay);
15
16
  return (_jsxs("div", { className: "flex-1 flex flex-col min-h-0 overflow-y-auto", children: [_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: ["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" "), children: format(day, "EEE d") }, day.toISOString())))] }), allDayEvents.length > 0 && (_jsxs("div", { className: "flex border-b border-border shrink-0", children: [_jsx("div", { className: "w-14 shrink-0" }), days.map((day) => (_jsx("div", { className: "flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border", children: allDayEvents
16
- .filter((occ) => isSameDay(new Date(occ.startDate), day))
17
- .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => isSameDay(new Date(occ.startDate), day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
17
+ .filter((occ) => occursOnDay(occ, day))
18
+ .map((occ) => (_jsx("button", { type: "button", onClick: () => onSelectEvent(occ), style: { backgroundColor: folderColors[occ.folderUid], color: "#fff" }, className: "text-xs text-left truncate rounded-sm px-1.5 py-0.5", children: occ.title }, occ.occurrenceKey))) }, day.toISOString())))] })), _jsxs("div", { className: "flex flex-1", children: [_jsx("div", { className: "w-14 shrink-0", children: HOURS.map((hour) => (_jsx("div", { style: { height: HOUR_HEIGHT_PX }, className: "text-[10px] text-text-muted text-right pr-1.5 -mt-1.5", children: hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha") }, hour))) }), days.map((day) => (_jsx(DayColumn, { day: day, occurrences: timedEvents.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] })] }));
18
19
  }
19
20
  function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }) {
20
21
  const dayStart = startOfDay(day);
21
22
  return (_jsxs("div", { className: ["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" "), children: [Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
22
23
  const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
23
24
  return _jsx(TimeSlot, { start: slotStart, onSelectSlot: onSelectSlot }, i);
24
- }), occurrences.map((occurrence) => (_jsx(EventBlock, { occurrence: occurrence, color: folderColors[occurrence.folderUid], dayStart: dayStart, onSelect: onSelectEvent }, occurrence.occurrenceKey)))] }));
25
+ }), occurrences.map((occurrence) => startsOnDay(occurrence, day) ? (_jsx(EventBlock, { occurrence: occurrence, color: folderColors[occurrence.folderUid], dayStart: dayStart, onSelect: onSelectEvent }, occurrence.occurrenceKey)) : (_jsx(ContinuationBlock, { occurrence: occurrence, color: folderColors[occurrence.folderUid], dayStart: dayStart, onSelect: onSelectEvent }, occurrence.occurrenceKey)))] }));
25
26
  }
26
27
  function TimeSlot({ start, onSelectSlot }) {
27
28
  const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
28
29
  return (_jsx("button", { ref: setNodeRef, type: "button", onClick: () => onSelectSlot(start, addMinutes(start, SLOT_MINUTES)), style: { height: HOUR_HEIGHT_PX / 2 }, className: ["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" "), "aria-label": `New event at ${format(start, "h:mm a, MMM d")}` }));
29
30
  }
31
+ /** A block's position within its day column. A multi-day event is drawn in every day column it
32
+ * overlaps, clipped to that day. */
33
+ function blockGeometry(occurrence, dayStart) {
34
+ const visibleStart = Math.max(new Date(occurrence.startDate).getTime(), dayStart.getTime());
35
+ const visibleEnd = Math.min(new Date(occurrence.endDate).getTime(), addDays(dayStart, 1).getTime());
36
+ return {
37
+ top: ((visibleStart - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX,
38
+ height: Math.max(((visibleEnd - visibleStart) / 60000 / 60) * HOUR_HEIGHT_PX, 16),
39
+ };
40
+ }
41
+ /** A multi-day event's block in a day column after its first: clickable, but not draggable/resizable
42
+ * (its drag ids are already taken by the start day's block). */
43
+ function ContinuationBlock({ occurrence, color, dayStart, onSelect, }) {
44
+ const isFree = occurrence.busyStatus === "free";
45
+ const { top, height } = blockGeometry(occurrence, dayStart);
46
+ return (_jsx("div", { onClick: () => onSelect(occurrence), style: { position: "absolute", top, height, left: 2, right: 2, ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }) }, className: ["rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer", isFree ? "bg-surface-alt text-text-muted" : ""].join(" "), children: _jsx("div", { className: "font-medium truncate", children: occurrence.title }) }));
47
+ }
30
48
  function EventBlock({ occurrence, color, dayStart, onSelect, }) {
31
49
  const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
32
50
  const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
@@ -34,13 +52,11 @@ function EventBlock({ occurrence, color, dayStart, onSelect, }) {
34
52
  });
35
53
  const isFree = occurrence.busyStatus === "free";
36
54
  const start = new Date(occurrence.startDate);
37
- const end = new Date(occurrence.endDate);
38
- const top = ((start.getTime() - dayStart.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX;
55
+ const { top, height } = blockGeometry(occurrence, dayStart);
39
56
  // The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
40
57
  // affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
41
58
  // `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
42
59
  // continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
43
- const height = Math.max(((end.getTime() - start.getTime()) / 60000 / 60) * HOUR_HEIGHT_PX, 16);
44
60
  return (_jsxs("div", { ref: setNodeRef, onClick: () => onSelect(occurrence), style: {
45
61
  position: "absolute",
46
62
  top,
@@ -0,0 +1,111 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { format } from "date-fns";
6
+ import { toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
7
+ const MS_PER_DAY = 86400000;
8
+ /**
9
+ * All-day events are stored date-only: `startDate` is UTC midnight of the first day and `endDate` is UTC
10
+ * midnight of the day *after* the last day (exclusive, like iCalendar's `DTEND;VALUE=DATE`). That keeps
11
+ * an all-day event on the same calendar date for every viewer, whatever their timezone.
12
+ *
13
+ * Older events were saved as the creator's *local* midnight converted to UTC (e.g. `...T22:00Z` for a
14
+ * UTC+2 creator), so reading one rounds the instant to the nearest UTC midnight rather than slicing the
15
+ * ISO string — which gives the intended date for both formats.
16
+ */
17
+ export function allDayDateKey(iso) {
18
+ const rounded = Math.round(new Date(iso).getTime() / MS_PER_DAY) * MS_PER_DAY;
19
+ return new Date(rounded).toISOString().slice(0, 10);
20
+ }
21
+ /** `YYYY-MM-DD` -> the stored UTC-midnight instant for that date. */
22
+ export function allDayInstant(dateKey) {
23
+ return `${dateKey}T00:00:00.000Z`;
24
+ }
25
+ /** Adds `days` to a `YYYY-MM-DD` date key. */
26
+ export function addDaysToKey(dateKey, days) {
27
+ return new Date(new Date(allDayInstant(dateKey)).getTime() + days * MS_PER_DAY).toISOString().slice(0, 10);
28
+ }
29
+ /** The local calendar date `day` (a local-midnight `Date` from a grid view) as a `YYYY-MM-DD` key. */
30
+ export function localDateKey(day) {
31
+ return format(day, "yyyy-MM-dd");
32
+ }
33
+ /**
34
+ * A recurrence rule's inclusive "Ends on" date -> its stored `until` instant: the end of that day in the
35
+ * frame the series expands in. A timed series expands in its own (normally the viewer's) timezone, so that's
36
+ * the end of the local day - `new Date("YYYY-MM-DD")` would be UTC midnight, the previous day west of UTC.
37
+ * An all-day series expands in UTC (react-shared's `expandOccurrences`), so it's the end of the UTC day: the
38
+ * local end of day is already the next UTC day west of UTC and would add one more occurrence.
39
+ */
40
+ export function recurrenceUntilInstant(dateKey, allDay) {
41
+ if (allDay) {
42
+ return `${dateKey}T23:59:59.999Z`;
43
+ }
44
+ const [y, m, d] = dateKey.split("-").map(Number);
45
+ return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
46
+ }
47
+ /** The stored form of an all-day series' "Ends on" date - the last millisecond of that UTC day. */
48
+ const ALL_DAY_UNTIL_PATTERN = /^(\d{4}-\d{2}-\d{2})T23:59:59\.999Z$/;
49
+ /** A bare UTC midnight - how the oldest editor (and date-only `UNTIL` values) stored the chosen date itself. */
50
+ const UTC_MIDNIGHT_PATTERN = /^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.000)?Z$/;
51
+ /**
52
+ * Inverse of `recurrenceUntilInstant()`. An all-day series' value is recognized explicitly in its stored
53
+ * `T23:59:59.999Z` form (and as a bare UTC midnight, the oldest form). Anything else is a legacy value stored
54
+ * as the creator's *local* end of day, which can land anywhere from just before 10:00Z on the chosen date (UTC+14)
55
+ * to just before 12:00Z the day after (UTC-12) - a 26-hour spread no UTC-only rounding can resolve (UTC+14 and UTC-10
56
+ * creators' values are exactly 24h apart for the same date), so it reads back on the local calendar, the
57
+ * frame it was written in.
58
+ */
59
+ export function recurrenceUntilDateKey(until, allDay) {
60
+ if (allDay) {
61
+ const stored = ALL_DAY_UNTIL_PATTERN.exec(until) ?? UTC_MIDNIGHT_PATTERN.exec(until);
62
+ if (stored) {
63
+ return stored[1];
64
+ }
65
+ }
66
+ return format(new Date(until), "yyyy-MM-dd");
67
+ }
68
+ /** `Date#getUTCDay()` index -> RFC 5545 weekday code. */
69
+ const JS_WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
70
+ /** The weekday an event starts on, in the frame its series expands in: the UTC date for an all-day event
71
+ * (`start`'s date part), the event's own timezone otherwise (`start` being a local `datetime-local` value).
72
+ * `undefined` while the start input is empty/invalid. */
73
+ export function startWeekdayCode(start, allDay, timezone) {
74
+ const ms = allDay ? new Date(allDayInstant(start.slice(0, 10))).getTime() : new Date(start).getTime();
75
+ if (Number.isNaN(ms)) {
76
+ return undefined;
77
+ }
78
+ return JS_WEEKDAY_CODES[new Date(toEventWallClock(ms, timezone, allDay)).getUTCDay()];
79
+ }
80
+ /**
81
+ * Whether `occurrence` should be shown on the grid's local calendar day `day`. All-day events cover every
82
+ * date from their start date up to (not including) their exclusive end date — at least their start date.
83
+ * Timed events cover every local day their [start, end) interval overlaps, so a multi-day event shows on
84
+ * each day rather than only on its start day.
85
+ */
86
+ export function occursOnDay(occurrence, day) {
87
+ if (occurrence.allDay) {
88
+ const key = localDateKey(day);
89
+ const startKey = allDayDateKey(occurrence.startDate);
90
+ const endKey = allDayDateKey(occurrence.endDate);
91
+ return key === startKey || (key > startKey && key < endKey);
92
+ }
93
+ const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
94
+ const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
95
+ const start = new Date(occurrence.startDate);
96
+ const end = new Date(occurrence.endDate);
97
+ if (start.getTime() === end.getTime()) {
98
+ return start >= dayStart && start < dayEnd;
99
+ }
100
+ return start < dayEnd && end > dayStart;
101
+ }
102
+ /**
103
+ * Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
104
+ * draggable — a drag id must be unique, so the continuation copies on later days are display-only.
105
+ */
106
+ export function startsOnDay(occurrence, day) {
107
+ if (occurrence.allDay) {
108
+ return allDayDateKey(occurrence.startDate) === localDateKey(day);
109
+ }
110
+ return localDateKey(new Date(occurrence.startDate)) === localDateKey(day);
111
+ }
@@ -1,39 +1,41 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  ///////////////////////////////////////////////////////////////////////////////
3
3
  // Copyright (C) 2026 Jean-Philippe Steinmetz
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
- import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
- import { HiOutlineBars3 } from "react-icons/hi2";
6
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
8
7
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
- import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
8
+ import { accentColorForMailbox, colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
10
9
  import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
11
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
11
  import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
13
12
  import AppShell from "../../layout/AppShell.js";
14
13
  import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
15
- const CalendarShellContext = createContext({ calendarFolders: [], mailboxes: [], reloadFolders: () => undefined });
16
- /** Reads the mailbox/folder the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
14
+ const CalendarShellContext = createContext({
15
+ calendarFolders: [],
16
+ mailboxCalendars: [],
17
+ mailboxes: [],
18
+ reloadFolders: () => undefined,
19
+ colorFor: (folder) => colorForFolder(folder),
20
+ });
21
+ /** Reads the calendars the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
17
22
  export function useCalendarShell() {
18
23
  return useContext(CalendarShellContext);
19
24
  }
20
25
  /**
21
- * The Calendar app's shell — structurally identical to `ContactsShell`/`TasksShell` (a mailbox has
22
- * exactly one well-known `calendar` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s
23
- * eager provisioning in `@rapidmx/restapi`), just bound to a different folder type. Unlike Contacts/
24
- * Tasks, the actual view being looked at (month/week/day, and which one) is *not* shell state — it
25
- * lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between views/dates must
26
- * be instant (no full page reload) and this framework has no client-side router to make a URL-driven
27
- * approach for that free.
26
+ * The Calendar app's shell. Fetches every accessible mailbox's calendar folders in parallel so shared
27
+ * mailboxes' calendars show alongside the caller's own, each mailbox color-coded (see `colorFor`) - there
28
+ * is no mailbox switcher. The actual view being looked at (month/week/day, and which one) is *not* shell
29
+ * state — it lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between
30
+ * views/dates must be instant (no full page reload) and this framework has no client-side router.
28
31
  */
29
32
  export default function CalendarShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
30
33
  const [status, setStatus] = useState("checking");
31
34
  const [error, setError] = useState(null);
32
35
  const [mailboxes, setMailboxes] = useState([]);
33
- const [folders, setFolders] = useState([]);
34
- const [folderError, setFolderError] = useState(null);
36
+ const [mailboxCalendars, setMailboxCalendars] = useState([]);
35
37
  const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
36
- const [drawerOpen, setDrawerOpen] = useState(false);
38
+ const [folderRefreshToken, setFolderRefreshToken] = useState(0);
37
39
  useEffect(() => {
38
40
  setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
39
41
  }, []);
@@ -51,46 +53,52 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
51
53
  setStatus("error");
52
54
  });
53
55
  }, [userUid]);
56
+ const ownMailboxUid = mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
54
57
  const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
55
- mailboxes[0]?.uid;
56
- const [folderRefreshToken, setFolderRefreshToken] = useState(0);
58
+ ownMailboxUid;
57
59
  useEffect(() => {
58
- if (!mailboxUid) {
59
- setFolders([]);
60
+ if (mailboxes.length === 0) {
61
+ setMailboxCalendars([]);
60
62
  return;
61
63
  }
62
- setFolderError(null);
63
- listFolders(mailboxUid)
64
- .then(setFolders)
65
- .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder."));
66
- }, [mailboxUid, folderRefreshToken]);
67
- const calendarFolders = useMemo(() => folders.filter((f) => f.type === "calendar"), [folders]);
68
- const folderUid = calendarFolders[0]?.uid;
69
- const contextValue = useMemo(() => ({ mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders: () => setFolderRefreshToken((t) => t + 1) }), [mailboxUid, folderUid, calendarFolders, mailboxes]);
64
+ let cancelled = false;
65
+ void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
66
+ .then((folders) => ({ mailbox, calendarFolders: folders.filter((f) => f.type === "calendar") }))
67
+ .catch((err) => ({
68
+ mailbox,
69
+ calendarFolders: [],
70
+ error: err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder.",
71
+ })))).then((result) => {
72
+ if (!cancelled) {
73
+ setMailboxCalendars(result);
74
+ }
75
+ });
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ }, [mailboxes, folderRefreshToken]);
80
+ const calendarFolders = useMemo(() => mailboxCalendars.flatMap((mc) => mc.calendarFolders), [mailboxCalendars]);
81
+ const folderUid = calendarFolders.find((f) => f.mailboxUid === mailboxUid)?.uid;
82
+ const colorFor = useCallback((folder) => colorForFolder(folder, folder.mailboxUid === ownMailboxUid ? undefined : accentColorForMailbox(folder.mailboxUid)), [ownMailboxUid]);
83
+ const reloadFolders = useCallback(() => setFolderRefreshToken((t) => t + 1), []);
84
+ const contextValue = useMemo(() => ({ mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor }), [mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor]);
70
85
  // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
71
86
  // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
72
- if (userUid && status === "ready" && !mailboxUid) {
87
+ if (userUid && status === "ready" && mailboxes.length === 0) {
73
88
  return _jsx(MailboxProvisioning, {});
74
89
  }
75
90
  let inner = null;
76
91
  if (userUid && status === "checking") {
77
92
  // Renders immediately (no network round trip needed) so switching into Calendar never shows a
78
- // blank pane while `listMailboxes()` is in flight — the actual sidebar (mini date-picker +
79
- // calendar list) lives in `apps/www/calendar/index.tsx`'s own content, so this mimics its rough
80
- // shape rather than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
93
+ // blank pane while `listMailboxes()` is in flight — mimics the rough shape of the real sidebar
94
+ // (mini date-picker + calendar list) that `apps/www/calendar/index.tsx` renders.
81
95
  inner = (_jsxs("div", { className: "w-56 shrink-0 bg-surface border-r border-border p-3 flex flex-col gap-4", children: [_jsx(Skeleton, { height: "h-40", className: "rounded-md" }), _jsx(SkeletonList, { count: 4 })] }));
82
96
  }
83
97
  else if (userUid && status === "error") {
84
98
  inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
85
99
  }
86
100
  else if (userUid && status === "ready") {
87
- // A function, not a plain JSX constant — see MailShell/ContactsShell's identical comment: it's
88
- // rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
89
- // `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
90
- const mailboxSwitcher = (idPrefix) => mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-calendar-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-calendar-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
91
- window.location.href = `/calendar?mailboxUid=${encodeURIComponent(e.target.value)}`;
92
- }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] }));
93
- inner = (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsx("div", { className: "hidden md:block w-56 shrink-0 bg-surface border-r border-border p-3", children: mailboxSwitcher("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsxs("div", { className: "flex flex-col gap-3", children: [mailboxSwitcher("mobile"), folderError && _jsx(Alert, { children: folderError })] }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), folderError && (_jsx("div", { className: "hidden md:block p-3", children: _jsx(Alert, { children: folderError }) })), _jsx(CalendarShellContext.Provider, { value: contextValue, children: children })] })] }));
101
+ inner = (_jsx("div", { className: "flex-1 min-w-0 flex flex-col", children: _jsx(CalendarShellContext.Provider, { value: contextValue, children: children }) }));
94
102
  }
95
103
  return (_jsx(AppShell, { active: "calendar", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
96
104
  }
@@ -1,20 +1,58 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
2
7
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
3
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
4
8
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
9
+ import KeyChangeReview from "./KeyChangeReview.js";
10
+ import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
11
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
5
12
  /** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
6
13
  * the spec's own use case ("compare this over the phone") is materially easier with a grouped string
7
14
  * than one 64-character run. */
8
15
  function formatFingerprint(fingerprint) {
9
16
  return fingerprint.match(/.{1,4}/g)?.join(" ") ?? fingerprint;
10
17
  }
18
+ /** A revoked key's reason: a superseded key was routinely replaced, a revoked one must not be trusted. */
19
+ function RevocationBadge({ label }) {
20
+ if (!label) {
21
+ return null;
22
+ }
23
+ return _jsxs("span", { className: label === "superseded" ? "text-text-muted" : "text-danger", children: [" (", label, ")"] });
24
+ }
11
25
  /**
12
26
  * A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
13
27
  * always visible alongside the contact list) and the mobile detail route
14
28
  * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
15
29
  */
16
- export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }) {
17
- return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) || contact.encryptPreference || contact.keyConflict ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), contact.keyConflict && (_jsx("div", { className: "mb-2", children: _jsxs(Alert, { children: [_jsxs("p", { className: "mb-2", children: ["A different encryption key was observed for this contact on", " ", new Date(contact.keyConflict.observedAt).toLocaleDateString(), ". The previously verified key below is still the one in use \u2014 this is routine after a device change or reinstall, but it\u2019s also what a real attack looks like, so verify the new fingerprint with ", contact.displayName, " ", "directly (e.g. by phone) before trusting it. There is no automatic way to accept or reject this yet."] }), _jsxs("p", { className: "font-mono text-xs", children: ["Newly observed: ", formatFingerprint(contact.keyConflict.observedFingerprint)] })] }) })), contact.encryptPreference && (_jsx("div", { className: "mb-1", children: contact.encryptPreference.preferEncrypt === "mutual"
30
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }) {
31
+ // The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
32
+ const [keyNotice, setKeyNotice] = useState(null);
33
+ const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
34
+ const keyConflicts = contact.keyConflicts ?? [];
35
+ const previousKeys = contact.previousKeys ?? [];
36
+ // Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
37
+ function refresh(text) {
38
+ setKeyNotice({ contactUid: contact.uid, text });
39
+ clearPinnedSignerCache();
40
+ onKeysChanged?.();
41
+ }
42
+ return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
43
+ contact.encryptPreference ||
44
+ keyConflicts.length > 0 ||
45
+ previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
46
+ const pinned = contact.keys?.find((key) => key.useType === conflict.useType);
47
+ const kind = conflict.useType === "sign" ? "signing" : "encryption";
48
+ return (_jsxs("section", { "aria-label": `${conflict.useType === "sign" ? "Signing" : "Encryption"} key change`, className: "mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsxs("h2", { className: "font-semibold", children: ["A different ", kind, " key was seen for ", contact.displayName] }), _jsx("p", { children: "The current key stays in use until you decide what to do with the new one." }), _jsx(KeyChangeReview, { mailboxUid: contact.mailboxUid, address: contact.emails[0]?.address, useType: conflict.useType, ownerName: contact.displayName, current: pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }, proposed: {
49
+ fingerprint: conflict.observedKey.fingerprint,
50
+ observedAt: conflict.observedAt,
51
+ source: conflict.source,
52
+ }, canReject: true, canResolve: canResolveKeys, onResolved: (action) => refresh(action === "accept"
53
+ ? `The new ${kind} key is now trusted for ${contact.displayName}.`
54
+ : `You kept the current ${kind} key for ${contact.displayName}.`), onPinnedKeyChanged: () => refresh(KEY_CHANGE_STALE_MESSAGE) })] }, conflict.useType));
55
+ }), contact.encryptPreference && (_jsx("div", { className: "mb-1", children: contact.encryptPreference.preferEncrypt === "mutual"
18
56
  ? "This contact also encrypts to you — messages to them can be encrypted."
19
- : "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), key.revokedAt && _jsx("span", { className: "text-danger", children: " (revoked)" })] }, key.fingerprint)))] })) : null] })] }));
57
+ : "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), _jsx(RevocationBadge, { label: revocationLabel(key) })] }, key.fingerprint))), previousKeys.length > 0 && (_jsxs("div", { className: "mt-3", children: [_jsx("div", { className: "text-text-muted text-xs font-bold mb-1", children: "Key history" }), _jsx("ul", { "aria-label": "Key history", children: previousKeys.map((key) => (_jsxs("li", { className: "text-xs py-0.5", children: [_jsxs("span", { className: "font-mono", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint)] }), _jsx(RevocationBadge, { label: revocationLabel(key) }), _jsxs("span", { className: "text-text-muted", children: [" ", "\u00B7", " ", key.replacement === "automatic" ? "Renewed automatically" : "Replaced by you", " on", " ", formatDate(key.replacedAt)] })] }, `${key.useType}:${key.fingerprint}:${key.replacedAt}`))) })] }))] })) : null] })] }));
20
58
  }
@@ -9,6 +9,8 @@ import { createContact, updateContact, } from "@rapidmx/react-shared/contacts/co
9
9
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
10
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
11
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
12
+ import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
13
+ import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
12
14
  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";
13
15
  const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
14
16
  const ADDRESS_KINDS = ["home", "work", "other"];
@@ -17,7 +19,8 @@ const ADDRESS_KINDS = ["home", "work", "other"];
17
19
  * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
18
20
  * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
19
21
  */
20
- export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }) {
22
+ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes, onSaved, onCancel }) {
23
+ const [targetMailboxUid, setTargetMailboxUid] = useState(mailboxUid);
21
24
  const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
22
25
  const [givenName, setGivenName] = useState(contact?.givenName ?? "");
23
26
  const [surname, setSurname] = useState(contact?.surname ?? "");
@@ -28,7 +31,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
28
31
  const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
29
32
  const [emails, setEmails] = useState(contact?.emails ?? []);
30
33
  const [phones, setPhones] = useState(contact?.phones ?? []);
31
- const [address, setAddress] = useState(contact?.addresses[0] ?? null);
34
+ // Every stored address is kept and editable - editing a contact must never silently drop addresses
35
+ // beyond the first.
36
+ const [addresses, setAddresses] = useState(contact?.addresses ?? []);
32
37
  const [error, setError] = useState(null);
33
38
  const [saving, setSaving] = useState(false);
34
39
  function updateEmail(index, patch) {
@@ -43,6 +48,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
43
48
  function removePhone(index) {
44
49
  setPhones((prev) => prev.filter((_, i) => i !== index));
45
50
  }
51
+ function updateAddress(index, patch) {
52
+ setAddresses((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
53
+ }
54
+ function removeAddress(index) {
55
+ setAddresses((prev) => prev.filter((_, i) => i !== index));
56
+ }
46
57
  async function handleSubmit(e) {
47
58
  e.preventDefault();
48
59
  setError(null);
@@ -56,22 +67,44 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
56
67
  .split(",")
57
68
  .map((c) => c.trim())
58
69
  .filter(Boolean);
70
+ // An update merges only the fields in its body, so a field the user cleared is sent as an
71
+ // explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
72
+ const cleared = contact ? null : undefined;
59
73
  const input = {
60
74
  displayName: displayName.trim(),
61
- givenName: givenName.trim() || undefined,
62
- surname: surname.trim() || undefined,
63
- company: company.trim() || undefined,
64
- jobTitle: jobTitle.trim() || undefined,
65
- notes: notes.trim() || undefined,
75
+ givenName: givenName.trim() || cleared,
76
+ surname: surname.trim() || cleared,
77
+ company: company.trim() || cleared,
78
+ jobTitle: jobTitle.trim() || cleared,
79
+ notes: notes.trim() || cleared,
66
80
  favorite,
67
81
  categories,
68
82
  emails,
69
83
  phones,
70
- addresses: address ? [address] : [],
84
+ addresses,
71
85
  };
72
- const saved = contact
73
- ? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
74
- : await createContact({ mailboxUid: mailboxUid, folderUid: folderUid, ...input });
86
+ let saved;
87
+ if (contact) {
88
+ saved = await updateContact({
89
+ uid: contact.uid,
90
+ version: contact.version,
91
+ ...input,
92
+ });
93
+ }
94
+ else {
95
+ const targetFolderUid = targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid, "contacts");
96
+ if (!targetFolderUid) {
97
+ setError("That mailbox has no Contacts folder.");
98
+ return;
99
+ }
100
+ saved = await createContact({
101
+ mailboxUid: targetMailboxUid,
102
+ folderUid: targetFolderUid,
103
+ ...input,
104
+ });
105
+ }
106
+ // A message opened later in this page load must see the changed contact's signing keys.
107
+ clearPinnedSignerCache();
75
108
  onSaved(saved);
76
109
  }
77
110
  catch (err) {
@@ -81,5 +114,5 @@ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, o
81
114
  setSaving(false);
82
115
  }
83
116
  }
84
- return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: address ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => setAddress({ ...address, street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => setAddress({ ...address, city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => setAddress({ ...address, state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => setAddress({ ...address, postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => setAddress({ ...address, country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => setAddress({ ...address, type: e.target.value }), "aria-label": "Address type", children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => setAddress(null), className: "self-start text-xs font-medium text-danger hover:underline", children: "Remove address" })] })) : (_jsx("button", { type: "button", onClick: () => setAddress({ type: "home" }), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
117
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
85
118
  }
@@ -43,7 +43,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
43
43
  * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
44
44
  * `ContactsShellContext` itself minimal (mailbox/folder resolution only).
45
45
  */
46
- export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken }) {
46
+ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }) {
47
47
  const [lists, setLists] = useState([]);
48
48
  const [listsError, setListsError] = useState(null);
49
49
  const [addingList, setAddingList] = useState(false);
@@ -100,6 +100,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
100
100
  // A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
101
101
  // `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
102
102
  // behind the `idPrefix`).
103
- const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
103
+ const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
104
104
  return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
105
105
  }