@rapidmx/web-client 0.34.0 → 0.36.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 (148) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  3. package/apps/shared/calendar/sealedEvents.ts +80 -0
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  7. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  8. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  9. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  10. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  11. package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
  12. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  13. package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
  14. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  15. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  16. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  17. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  18. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  19. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  20. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  21. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  22. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  23. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  24. package/apps/shared/mail/decryptedMessages.ts +11 -3
  25. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  26. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  27. package/apps/shared/mail/reportNotices.ts +3 -1
  28. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  29. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  30. package/apps/shared/search/localIndexBuilder.ts +27 -1
  31. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  32. package/apps/shared/search/localIndexSchema.ts +2 -2
  33. package/apps/shared/search/localIndexTransport.ts +2 -1
  34. package/apps/shared/search/localIndexWorker.ts +6 -4
  35. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  36. package/apps/www/calendar/index.tsx +180 -58
  37. package/apps/www/contacts/index.tsx +58 -2
  38. package/apps/www/index.tsx +10 -3
  39. package/apps/www/settings/encryption/index.tsx +5 -0
  40. package/apps/www/settings/privacy/index.tsx +2 -1
  41. package/apps/www/tasks/index.tsx +42 -0
  42. package/dist/apps/escrow/matters/[uid].js +2 -2
  43. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  44. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  45. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  46. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  47. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  48. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  49. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  50. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  51. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  52. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  53. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  54. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  55. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  56. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  57. package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
  58. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  59. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  60. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
  61. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
  62. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  63. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  64. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  65. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  66. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  67. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  68. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  69. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  70. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  71. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  72. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  73. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  74. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  75. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  76. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  77. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  78. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  79. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  80. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  81. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  82. package/dist/apps/shared/mail/reportNotices.js +3 -1
  83. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  84. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  86. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  87. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  88. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  89. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  90. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  91. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  92. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  93. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  94. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  95. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  96. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  97. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  98. package/dist/apps/www/calendar/index.js +48 -22
  99. package/dist/apps/www/contacts/index.js +49 -3
  100. package/dist/apps/www/index.js +10 -3
  101. package/dist/apps/www/settings/encryption/index.js +3 -1
  102. package/dist/apps/www/settings/privacy/index.js +1 -1
  103. package/dist/apps/www/tasks/index.js +29 -1
  104. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  105. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  106. package/dist/lib/calendar/inviteApi.js +27 -5
  107. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  108. package/dist/lib/calendar/sealedInvites.js +28 -0
  109. package/dist/lib/crypto/keyExport.d.ts +54 -0
  110. package/dist/lib/crypto/keyExport.js +161 -0
  111. package/dist/lib/crypto/keySession.d.ts +45 -1
  112. package/dist/lib/crypto/keySession.js +93 -2
  113. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  114. package/dist/lib/crypto/keyvaultApi.js +32 -0
  115. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  116. package/dist/lib/crypto/messageSecurity.js +80 -4
  117. package/dist/lib/crypto/mime.d.ts +5 -0
  118. package/dist/lib/crypto/mime.js +25 -0
  119. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  120. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  121. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  122. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  123. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  124. package/dist/lib/mail/mailApi.d.ts +30 -2
  125. package/dist/lib/mail/mailApi.js +5 -1
  126. package/dist/lib/mail/pushClient.d.ts +10 -3
  127. package/dist/lib/mail/pushClient.js +18 -4
  128. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  129. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  130. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  131. package/dist/lib/tasks/tasksApi.js +3 -0
  132. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  133. package/lib/calendar/calendarApi.ts +3 -0
  134. package/lib/calendar/inviteApi.ts +28 -5
  135. package/lib/calendar/sealedInvites.ts +33 -0
  136. package/lib/crypto/keyExport.ts +229 -0
  137. package/lib/crypto/keySession.ts +117 -3
  138. package/lib/crypto/keyvaultApi.ts +125 -3
  139. package/lib/crypto/messageSecurity.ts +110 -3
  140. package/lib/crypto/mime.ts +26 -0
  141. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  142. package/lib/crypto/zeroAccessKey.ts +97 -0
  143. package/lib/mail/conversationsApi.ts +3 -0
  144. package/lib/mail/mailApi.ts +33 -3
  145. package/lib/mail/pushClient.ts +18 -4
  146. package/lib/search/zeroAccessIndexApi.ts +119 -0
  147. package/lib/tasks/tasksApi.ts +4 -0
  148. package/package.json +3 -3
@@ -13,15 +13,16 @@ import { ApiRequestError } from "../../../lib/util/api.js";
13
13
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
14
14
  import useIsMobile from "../../../lib/util/useIsMobile.js";
15
15
  import { getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
16
- import { getPushClient } from "../../../lib/mail/pushClient.js";
16
+ import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
17
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
17
18
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
18
19
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
19
20
  import { createFolder } from "../../../lib/mail/mailApi.js";
20
21
  import { expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
21
22
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
22
- import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
23
+ import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex, } from "../../../lib/calendar/eventSearch.js";
23
24
  import Drawer from "../../../lib/components/overlays/Drawer.js";
24
- import CalendarShell, { useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
25
+ import CalendarShell, { useCalendarShell, } from "../../shared/components/calendar/layout/CalendarShell.js";
25
26
  import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
26
27
  import CalendarSearchBox from "../../shared/components/calendar/CalendarSearchBox.js";
27
28
  import { ActiveOccurrenceContext } from "../../shared/components/calendar/activeOccurrence.js";
@@ -37,7 +38,7 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
37
38
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
38
39
  import Alert from "../../../lib/components/feedback/Alert.js";
39
40
  import Button from "../../../lib/components/buttons/Button.js";
40
- import { CALENDAR_VIEWS, getStoredCalendarView, isCalendarView, storeCalendarView } from "../../shared/components/calendar/calendarView.js";
41
+ import { CALENDAR_VIEWS, getStoredCalendarView, isCalendarView, storeCalendarView, } from "../../shared/components/calendar/calendarView.js";
41
42
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
42
43
  import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
43
44
  import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
@@ -49,12 +50,21 @@ import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
49
50
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
50
51
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
51
52
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
53
+ /** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
54
+ const CALENDAR_CHANNEL_GROUP = "calendar";
52
55
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
53
56
  const MAX_DATE_MS = 8.64e15;
54
57
  function CalendarPage(props) {
55
58
  return (_jsx(CalendarShell, { ...props, children: _jsx(CalendarContent, { userUid: props.userUid, bookingHref: bookingSettingsHref(props.pluginNav) }) }));
56
59
  }
57
- const VIEW_LABELS = { list: "List", month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
60
+ const VIEW_LABELS = {
61
+ list: "List",
62
+ month: "Month",
63
+ week: "Week",
64
+ workWeek: "Work Week",
65
+ day: "Day",
66
+ split: "Split",
67
+ };
58
68
  /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4, the List one more); the split view has none. */
59
69
  const VIEW_SHORTCUTS = {
60
70
  list: SHORTCUTS.calendar.list,
@@ -64,7 +74,7 @@ const VIEW_SHORTCUTS = {
64
74
  month: SHORTCUTS.calendar.month,
65
75
  };
66
76
  /** One of the view switcher's buttons, with its shortcut in the tooltip. */
67
- function ViewButton({ view, current, onSelect }) {
77
+ function ViewButton({ view, current, onSelect, }) {
68
78
  const shortcut = VIEW_SHORTCUTS[view];
69
79
  const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
70
80
  return (_jsx("button", { type: "button", onClick: () => onSelect(view), ...hint, className: [
@@ -196,7 +206,7 @@ function CalendarContent({ userUid, bookingHref }) {
196
206
  }
197
207
  setLoading(true);
198
208
  setError(null);
199
- void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client)))
209
+ void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client).then(openSealedEvents)))
200
210
  .then((results) => {
201
211
  if (seq !== reloadSeqRef.current) {
202
212
  return;
@@ -235,7 +245,10 @@ function CalendarContent({ userUid, bookingHref }) {
235
245
  }
236
246
  // Keyed on stable string summaries (not the array/set objects themselves, which are new references
237
247
  // every render) so this only re-fires when the actual set of folders to fetch changes.
238
- const calendarFolderUidsKey = calendarFolders.map((f) => f.uid).sort().join(",");
248
+ const calendarFolderUidsKey = calendarFolders
249
+ .map((f) => f.uid)
250
+ .sort()
251
+ .join(",");
239
252
  const checkedFolderUidsKey = Array.from(checkedFolderUids).sort().join(",");
240
253
  useEffect(reload, [calendarFolderUidsKey, checkedFolderUidsKey]);
241
254
  // An event that somebody creates, changes or deletes is announced on the push connection - a private or confidential one as its busy block, whatever the
@@ -244,17 +257,17 @@ function CalendarContent({ userUid, bookingHref }) {
244
257
  // fetch changes nothing: the next load shows the event as it is.
245
258
  const checkedFolderUidsRef = useRef(checkedFolderUids);
246
259
  checkedFolderUidsRef.current = checkedFolderUids;
247
- useEffect(() => getPushClient().onEvent((event) => {
248
- const change = changedEventOf(event);
249
- if (!change) {
250
- return;
251
- }
260
+ // Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
261
+ // one needs no new subscription - while it is open.
262
+ useFolderLiveUpdates(CALENDAR_CHANNEL_GROUP, calendarFolders.map((f) => f.uid), changedEventOf, (change) => {
252
263
  const { uid } = change;
253
264
  if (change.deleted) {
254
265
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
255
266
  return;
256
267
  }
257
- void getCalendarEvent(uid, client).then((fresh) => {
268
+ void getCalendarEvent(uid, client)
269
+ .then(openSealedEvent)
270
+ .then((fresh) => {
258
271
  if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
259
272
  setEvents((previous) => previous.some((existing) => existing.uid === fresh.uid)
260
273
  ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
@@ -265,7 +278,7 @@ function CalendarContent({ userUid, bookingHref }) {
265
278
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
266
279
  }
267
280
  });
268
- }), [client]);
281
+ });
269
282
  // Search: `searchText` is the box, `query` what it held once typing paused (or Enter was pressed) and is what is searched for. A search covers every
270
283
  // event of the checked calendars whatever the view shows (see `searchOccurrences()`); while one runs the views show only its matches, and
271
284
  // `activeKey` is the match being pointed at (Previous/Next match step through `matches`).
@@ -305,7 +318,9 @@ function CalendarContent({ userUid, bookingHref }) {
305
318
  if (view === "list") {
306
319
  return sequence;
307
320
  }
308
- return searching ? occurrencesInRange(matches, rangeStart, rangeEnd) : expandAllOccurrences(events, rangeStart, rangeEnd);
321
+ return searching
322
+ ? occurrencesInRange(matches, rangeStart, rangeEnd)
323
+ : expandAllOccurrences(events, rangeStart, rangeEnd);
309
324
  }, [events, matches, searching, view, sequence, rangeStart, rangeEnd]);
310
325
  /** Points at `match`, moving the view to the day it is on unless the view already shows it. */
311
326
  function showMatch(match) {
@@ -354,7 +369,9 @@ function CalendarContent({ userUid, bookingHref }) {
354
369
  searchedQueryRef.current = query;
355
370
  // The List holds every match: it starts at the first from the day being viewed on rather than the first of all.
356
371
  const from = startOfDay(viewDate);
357
- showMatch(matches[view === "list" ? initialMatchIndex(matches, from, rangeEnd, from) : initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
372
+ showMatch(matches[view === "list"
373
+ ? initialMatchIndex(matches, from, rangeEnd, from)
374
+ : initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
358
375
  }
359
376
  }, [matches, query, searching]);
360
377
  const splitColumns = useMemo(() => calendarFolders
@@ -407,7 +424,9 @@ function CalendarContent({ userUid, bookingHref }) {
407
424
  // Brings the match being pointed at into sight (the views scroll their own hours; the page may too).
408
425
  useEffect(() => {
409
426
  const marked = Array.from(swipe.ref.current.querySelectorAll("[data-occurrence-key]"));
410
- marked.find((el) => el.dataset.occurrenceKey === activeKey)?.scrollIntoView({ block: "nearest", inline: "nearest" });
427
+ marked
428
+ .find((el) => el.dataset.occurrenceKey === activeKey)
429
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
411
430
  }, [activeKey, view === "list" ? null : viewDate, view, loading]);
412
431
  // The List scrolls to `viewDate` when this changes (Today, a day picked in the mini calendar), even when it is the date it already has.
413
432
  const [jumpNonce, setJumpNonce] = useState(0);
@@ -461,10 +480,12 @@ function CalendarContent({ userUid, bookingHref }) {
461
480
  const requestedStart = new Date(params.get("start") ?? "");
462
481
  navigateTo("/calendar", { replace: true });
463
482
  getCalendarEvent(eventUid, client)
483
+ .then(openSealedEvent)
464
484
  .then((event) => {
465
485
  const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
466
486
  const candidates = expandOccurrences(event, new Date(from.getTime() - 86400000), new Date(from.getTime() + 86400000));
467
- const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
487
+ const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ??
488
+ candidates[0];
468
489
  if (match) {
469
490
  setViewDate(occurrenceDay(match));
470
491
  openEvent(match);
@@ -535,12 +556,17 @@ function CalendarContent({ userUid, bookingHref }) {
535
556
  const writableMailboxes = useWritableMailboxes(mailboxes, userUid, modalMailboxUid);
536
557
  const mailboxOptions = useMemo(() => mailboxCalendars
537
558
  .filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
538
- .map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })), [mailboxCalendars, writableMailboxes]);
559
+ .map((mc) => ({
560
+ mailbox: mc.mailbox,
561
+ calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })),
562
+ })), [mailboxCalendars, writableMailboxes]);
539
563
  const sidebarContent = (_jsxs(_Fragment, { children: [_jsx(MiniDatePicker, { selected: viewDate, onSelect: (date) => {
540
564
  setViewDate(startOfDay(date));
541
565
  setJumpNonce((n) => n + 1);
542
566
  } }), _jsx(CalendarListSidebar, { mailboxCalendars: mailboxCalendars, checkedFolderUids: checkedFolderUids, onToggle: toggleCalendar, onAddCalendar: handleAddCalendar, colorFor: colorFor })] }));
543
- return (_jsxs("div", { className: "flex-1 min-w-0 flex min-h-0", children: [_jsx("div", { className: "hidden lg: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", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "lg: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" }) }), !isMobile && (_jsx(Button, { type: "button", onClick: (e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below")), className: "!w-auto shrink-0", ...newEventHint, children: "+ New event" })), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", ...previousHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, ...todayHint, 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", ...nextHint, 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(CalendarSearchBox, { value: searchText, onChange: setSearchText, onSubmit: submitSearch, onClear: clearSearch, onPrevious: () => stepMatch(-1), onNext: () => stepMatch(1), active: searching, count: matches.length, position: activeIndex, inputRef: searchInputRef }), _jsx("div", { className: "flex flex-wrap gap-1", children: CALENDAR_VIEWS.map((v) => (_jsx(ViewButton, { view: v, current: view, onSelect: setView }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), _jsx("div", { ref: swipe.ref, className: "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden", ...swipe.handlers, style: swipe.style, children: _jsx("div", { "data-swipe-phase": swipe.phase, className: "flex-1 min-w-0 flex flex-col min-h-0", style: swipe.contentStyle, children: _jsx("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", style: wheelSlide.style, children: loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(ActiveOccurrenceContext.Provider, { value: searching ? activeKey : null, children: _jsx(DndContext, { sensors: sensors, onDragStart: () => setDragging(true), onDragEnd: handleDragEnd, onDragCancel: () => setDragging(false), children: view === "list" ? (_jsx(ListView, { occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, focusDate: viewDate, jumpNonce: jumpNonce, step: listStep })) : view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor, true) })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor) })) }) })) }) }) }), 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, folderColors: folderColors, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, initialAllDay: modal.initialAllDay, anchor: modal.anchor, bookingHref: bookingHref, matchNav: modal.occurrence && sequence.length > 0
567
+ return (_jsxs("div", { className: "flex-1 min-w-0 flex min-h-0", children: [_jsx("div", { className: "hidden lg: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", fullScreen: true, children: _jsx("div", { className: "flex flex-col", children: sidebarContent }) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-2 p-3 border-b border-border shrink-0", children: [_jsx("button", { type: "button", className: "lg: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" }) }), !isMobile && (_jsx(Button, { type: "button", onClick: (e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below")), className: "!w-auto shrink-0", ...newEventHint, children: "+ New event" })), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => shiftView(-1), "aria-label": "Previous", ...previousHint, className: "w-7 h-7 text-sm rounded-sm hover:bg-surface-alt", children: "\u2039" }), _jsx("button", { type: "button", onClick: goToday, ...todayHint, 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", ...nextHint, 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(CalendarSearchBox, { value: searchText, onChange: setSearchText, onSubmit: submitSearch, onClear: clearSearch, onPrevious: () => stepMatch(-1), onNext: () => stepMatch(1), active: searching, count: matches.length, position: activeIndex, inputRef: searchInputRef }), _jsx("div", { className: "flex flex-wrap gap-1", children: CALENDAR_VIEWS.map((v) => (_jsx(ViewButton, { view: v, current: view, onSelect: setView }, v))) })] }), error && (_jsx("div", { className: "p-3", children: _jsx(Alert, { children: error }) })), _jsx("div", { ref: swipe.ref, className: "flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden", ...swipe.handlers, style: swipe.style, children: _jsx("div", { "data-swipe-phase": swipe.phase, className: "flex-1 min-w-0 flex flex-col min-h-0", style: swipe.contentStyle, children: _jsx("div", { className: "flex-1 min-w-0 flex flex-col min-h-0", style: wheelSlide.style, children: loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : (_jsx(ActiveOccurrenceContext.Provider, { value: searching ? activeKey : null, children: _jsx(DndContext, { sensors: sensors, onDragStart: () => setDragging(true), onDragEnd: handleDragEnd, onDragCancel: () => setDragging(false), children: view === "list" ? (_jsx(ListView, { occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, focusDate: viewDate, jumpNonce: jumpNonce, step: listStep })) : view === "month" ? (_jsx(MonthView, { viewDate: viewDate, occurrences: occurrences, folderColors: folderColors, onSelectDay: handleSelectDay, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor, true) })) : view === "split" ? (_jsx(SplitDayView, { day: viewDate, columns: splitColumns, occurrences: occurrences, onSelectEvent: openEvent, onSelectSlot: (start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor) })) : (_jsx(TimeGridView, { days: days, occurrences: occurrences, folderColors: folderColors, onSelectEvent: openEvent, onSelectSlot: (start, end, anchor) => openNewEvent(start, end, undefined, anchor) })) }) })) }) }) }), modal && modalMailbox && (_jsx(EventModal, { open: true, onClose: closeModal, mailboxUid: modalMailbox.uid, folderUid: modalFolderUid, calendars: calendarFolders
568
+ .filter((f) => f.mailboxUid === modalMailbox.uid)
569
+ .map((f) => ({ uid: f.uid, name: f.name })), mailboxOptions: mailboxOptions, folderColors: folderColors, organizerAddress: modalMailbox.primarySmtpAddress, organizerAliases: modalMailbox.aliasAddresses, occurrence: modal.occurrence, initialStart: modal.initialStart, initialEnd: modal.initialEnd, initialAllDay: modal.initialAllDay, anchor: modal.anchor, bookingHref: bookingHref, matchNav: modal.occurrence && sequence.length > 0
544
570
  ? {
545
571
  onPrevious: () => stepMatch(-1),
546
572
  onNext: () => stepMatch(1),
@@ -549,7 +575,7 @@ function CalendarContent({ userUid, bookingHref }) {
549
575
  noun: searching ? "match" : "event",
550
576
  slide,
551
577
  }
552
- : undefined, onSaved: handleSaved, onDeleted: handleDeleted })), isMobile && mailboxUid && folderUid && !modal && _jsx(FloatingActionButton, { label: "New event", icon: HiOutlinePlus, onClick: () => openNewEvent() })] })] }));
578
+ : undefined, onSaved: handleSaved, onDeleted: handleDeleted })), isMobile && mailboxUid && folderUid && !modal && (_jsx(FloatingActionButton, { label: "New event", icon: HiOutlinePlus, onClick: () => openNewEvent() }))] })] }));
553
579
  }
554
580
  export default CalendarPage;
555
581
  /** The tab's title: `Brand: Calendar` (see `pageTitle()`). */
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useNavigate } from "../../shared/navigation/index.js";
8
8
  import { useEffect, useMemo, useRef, useState } from "react";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createContact, deleteContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
10
+ import { createContact, deleteContact, getContact, listContacts, ensureSuggestedContacts, listContactsInFolders, listDeletedContacts, setContactFavorite, updateContact, } from "../../../lib/contacts/contactsApi.js";
11
11
  import { VCARD_MAX_CHARS, VCARD_MAX_CONTACTS, contactsToVCardFile, contactToVCard, parseVCards } from "../../../lib/contacts/vcard.js";
12
12
  import { withoutDuplicates } from "../../shared/components/contacts/participantDetails.js";
13
13
  import useIsMobile from "../../../lib/util/useIsMobile.js";
@@ -33,6 +33,15 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
33
33
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
34
34
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
35
35
  import { notify } from "../../shared/notifications/store.js";
36
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
37
+ /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
38
+ const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
39
+ /** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
40
+ const CONTACTS_CHANNEL_GROUP = "contacts";
41
+ /** `list` with `contact` in place of the one with its uid, or added when it has none. */
42
+ function upsertContact(list, contact) {
43
+ return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
44
+ }
36
45
  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";
37
46
  function ContactsPage(props) {
38
47
  return (_jsx(ContactsShell, { ...props, children: _jsx(ContactsContent, { userUid: props.userUid }) }));
@@ -137,6 +146,39 @@ function ContactsContent({ userUid }) {
137
146
  cancelled = true;
138
147
  };
139
148
  }, [mailboxUid]);
149
+ // A contact created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by
150
+ // its uid and filed in the list of the folder it is in now (the mailbox's contacts, or its suggested contacts), and taken out of the other; one that is
151
+ // gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
152
+ useFolderLiveUpdates(CONTACTS_CHANNEL_GROUP, [folderUid, suggestedFolderUid].filter((uid) => !!uid), (event) => changedItemOf(event, CONTACT_MODEL), (change) => {
153
+ const remove = (uid) => {
154
+ setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
155
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
156
+ };
157
+ if (change.deleted) {
158
+ remove(change.uid);
159
+ return;
160
+ }
161
+ void getContact(change.uid, client).then((fresh) => {
162
+ if (fresh.deleted === true) {
163
+ remove(fresh.uid);
164
+ }
165
+ else if (fresh.folderUid === folderUid) {
166
+ setContacts((previous) => upsertContact(previous, fresh));
167
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
168
+ }
169
+ else if (fresh.folderUid === suggestedFolderUid) {
170
+ setSuggestedContacts((previous) => upsertContact(previous, fresh));
171
+ setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
172
+ }
173
+ else {
174
+ remove(fresh.uid);
175
+ }
176
+ }, (err) => {
177
+ if (err instanceof ApiRequestError && err.status === 404) {
178
+ remove(change.uid);
179
+ }
180
+ });
181
+ });
140
182
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
141
183
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
142
184
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -194,6 +236,9 @@ function ContactsContent({ userUid }) {
194
236
  return contacts;
195
237
  case "favorites":
196
238
  return contacts.filter((c) => c.favorite);
239
+ case "everyone":
240
+ // The mailbox's own contacts and its suggested ones in one list (a person is in only one of them: a suggestion that is accepted is moved).
241
+ return [...contacts, ...suggestedContacts];
197
242
  case "suggested":
198
243
  return suggestedContacts;
199
244
  case "list":
@@ -377,7 +422,8 @@ function ContactsContent({ userUid }) {
377
422
  });
378
423
  // A suggested contact becomes one of the mailbox's own: a copy in its contacts folder, and the suggestion removed (the person is not suggested again).
379
424
  const handleAddToContacts = singleFlight(async () => {
380
- for (const contact of checkedContacts) {
425
+ // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
426
+ for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
381
427
  try {
382
428
  await createContact({
383
429
  mailboxUid: contact.mailboxUid,
@@ -453,7 +499,7 @@ function ContactsContent({ userUid }) {
453
499
  return (_jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex flex-col lg:flex-row md:flex-none md:h-[calc(100dvh_-_var(--rr-header-h,4rem))] md:overflow-hidden", children: [_jsx(ContactsSidebar, { mailboxUid: mailboxUid, contacts: contacts, active: view, onSelect: handleSelectView, showDeleted: canViewDeleted, suggestedCount: suggestedFolderUid ? suggestedContacts.length : undefined }), _jsxs("div", { className: "flex-1 min-w-0 min-h-0 flex", children: [_jsxs("div", { className: [
454
500
  "w-full md:w-[clamp(16rem,32vw,26rem)] min-w-0 md:shrink-0 md:border-r border-border md:flex flex-col min-h-0",
455
501
  mode === "new" ? "hidden md:flex" : "flex",
456
- ].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, onAddToContacts: view.type === "suggested" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, 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 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate 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: "block max-w-full truncate 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",
502
+ ].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, onAddToContacts: view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined, onImportFile: handleImportFile, shortcuts: true, hideNew: isMobile }), _jsx("div", { className: "p-3 border-b border-border", children: _jsx("input", { ref: searchInputRef, 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 min-h-0 overflow-auto", children: _jsxs("table", { className: "w-full table-fixed text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left", children: [!isDeletedView && (_jsx("th", { className: "w-10 px-3 py-2", children: _jsx("input", { type: "checkbox", "aria-label": "Select all contacts", checked: allChecked, onChange: toggleCheckAll }) })), _jsx("th", { className: "w-[58%] px-3 py-2", children: _jsxs("button", { type: "button", onClick: () => handleSort("name"), className: "block max-w-full truncate 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: "block max-w-full truncate 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",
457
503
  // A deleted contact has no pane to show (it can't be restored or opened), and its route would answer 404.
458
504
  disabled: isDeletedView, onClick: () => handleSelectRow(contact), className: "flex w-full min-w-0 items-center gap-2 text-left disabled:cursor-default", children: [_jsx(ContactPhotoAvatar, { lazy: true, displayName: contact.displayName, size: 28, contact: contact, email: contact.emails[0]?.address }), _jsxs("span", { className: "min-w-0 truncate font-medium", children: [contact.displayName, contact.favorite && _jsx("span", { "aria-label": "Favorite", children: " \u2605" })] })] }) }), _jsx("td", { className: "max-w-0 truncate px-3 py-2 text-text-muted", children: primaryInfo(contact) })] }, contact.uid))) })] }) }))] }), _jsxs("div", { className: ["flex-1 min-w-0 min-h-0 flex-col md:flex", mode === "new" ? "flex" : "hidden"].join(" "), children: [savedElsewhere && mode !== "new" && (_jsxs("p", { role: "status", className: "m-6 mb-0 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)}`, "data-router-shallow": "", "data-router-prefetch": "false", 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("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsx(ContactDetailPane, { contact: selected, onEdit: () => setMode("edit"), onDelete: () => handleDelete(selected), canWrite: mailboxWritable, onKeysChanged: () => void reload(), onChanged: () => void reload(), canResolveKeys: ownsMailbox || delegateCanUpdate }) })) : (_jsx("div", { className: "flex flex-1 items-center justify-center p-6 text-center", children: _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" })] })] }), isMobile && mode !== "new" && _jsx(FloatingActionButton, { label: "New contact", icon: HiOutlineUserPlus, onClick: handleNew })] }));
459
505
  }
@@ -100,11 +100,18 @@ async function decryptEncryptedRows(messages, unlocked) {
100
100
  // A few at a time: each is a download and a public-key decryption.
101
101
  const entries = await mapWithConcurrency(encrypted, DECRYPT_CONCURRENCY, async (message) => {
102
102
  try {
103
- const rawMime = await getMessageRawContent(message.uid);
104
103
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
105
104
  // no encrypted mail in it never needs it.
106
- const { evaluateMessageSecurity } = await (securityModule ?? (securityModule = import("../../lib/crypto/messageSecurity.js")));
107
- const security = await evaluateMessageSecurity(rawMime, unlocked);
105
+ const securityApi = await (securityModule ?? (securityModule = import("../../lib/crypto/messageSecurity.js")));
106
+ // A message sealed at rest carries its subject and preview sealed on the row: no download needed.
107
+ const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
108
+ if (summary) {
109
+ const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
110
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
111
+ return [message.uid, { subject: summary.subject, preview }];
112
+ }
113
+ const rawMime = await getMessageRawContent(message.uid);
114
+ const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
108
115
  if (!security.subject && !security.html) {
109
116
  markDecryptFailed(message.uid);
110
117
  return null;
@@ -25,6 +25,8 @@ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
25
25
  import Alert from "../../../../lib/components/feedback/Alert.js";
26
26
  import Button from "../../../../lib/components/buttons/Button.js";
27
27
  import Modal from "../../../../lib/components/overlays/Modal.js";
28
+ import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
29
+ import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
28
30
  import SigningCertificateCard from "../../../shared/components/settings/SigningCertificateCard.js";
29
31
  import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
30
32
  import { checkEnrollmentNow, forgetEnrollment, recordEnrollmentResult, useEnrollmentSnapshot, watchCurrentEnrollment, watchEnrollment, } from "../../../shared/signing/enrollmentTracker.js";
@@ -799,7 +801,7 @@ function EncryptionContent({ canManageKeys }) {
799
801
  : null;
800
802
  const certificateEnrollment = certificateMode === "issued" && enrollmentResult?.status !== "issued" ? null : certificateMode === "expired" ? null : enrollmentResult;
801
803
  return (_jsx("div", { className: "flex-1 min-w-0 overflow-y-auto p-6", children: _jsxs("div", { className: "max-w-xl flex flex-col gap-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-lg font-bold tracking-tight mb-1", children: "Encryption" }), _jsxs("p", { className: "text-sm text-text-muted", children: ["Manage how ", mailbox.displayName, " unlocks its encryption keys on this and other devices."] })] }), loadError && _jsx(Alert, { children: loadError }), actionError && _jsx(Alert, { children: actionError }), !canManageKeys && (_jsx("p", { className: "text-sm text-text-muted", children: "Only this mailbox\u2019s owner, signed in as themselves, can change its encryption keys and unlock methods." })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Encryption keys" }), displayedKeys.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1 text-sm", children: displayedKeys.map((key) => (_jsxs("li", { className: "font-mono text-xs", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", key.fingerprint, key.revokedAt &&
802
- (key.revocationReason === "superseded" ? (_jsx("span", { className: "text-text-muted", children: " (superseded)" })) : (_jsx("span", { className: "text-danger", children: " (revoked)" })))] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey && signingStatus !== "pending" && (_jsx("p", { className: ["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" "), children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })), certificateMode && (_jsxs("div", { className: "flex flex-col gap-2", children: [certificateMode === "failed" && signingError && _jsx(Alert, { children: signingError }), _jsx(SigningCertificateCard, { mode: certificateMode, enrollment: certificateEnrollment, snapshot: enrollment, info: signingInfo, address: mailbox.primarySmtpAddress, keyNotAfter: certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter, canRequest: canManageKeys, requesting: signingStatus === "enrolling", onCheck: canManageKeys ? () => void checkEnrollmentNow(mailboxUid) : undefined, onRequest: handleEnrollSigning })] })), !activeSigningKey && !certificateMode && !canManageKeys ? (_jsx("p", { className: "text-sm text-text-muted", children: "Not enabled." })) : activeSigningKey || certificateMode ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (_jsx("p", { className: "text-xs text-text-muted", children: beforeRequestText(signingInfo, mailbox.primarySmtpAddress) })), healthWarning(signingInfo) && _jsx(Alert, { children: healthWarning(signingInfo) }), enrollment?.gone && _jsx(Alert, { children: STALE_REQUEST_TEXT }), signingError && _jsx(Alert, { children: signingError }), signingInfo?.backend !== "none" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus !== "idle", onClick: handleEnrollSigning, children: "Enable digital signatures" }))] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("p", { className: "text-xs text-text-muted", children: ["Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox\u2019s encrypted mail until you complete this step.", " ", "This does not weaken protection against anyone else."] }), canManageKeys && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" }))] })), _jsxs(Modal, { open: pendingEscrow !== null, onClose: () => setPendingEscrow(null), title: "Confirm escrow certificate", children: [_jsx("p", { className: "text-sm mb-3", children: "Anyone holding the private key for this certificate will be able to recover this mailbox\u2019s encrypted mail. Confirm it is the escrow certificate your organization expects before continuing." }), _jsxs("dl", { className: "text-sm mb-5 flex flex-col gap-1", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "break-all", children: pendingEscrow?.escrowScopeId })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate fingerprint" }), _jsx("dd", { className: "font-mono break-all", children: pendingEscrow?.publicKey.fingerprint })] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingEscrow(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleConfirmEscrow(pendingEscrow), children: "Trust this certificate" })] })] })] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
804
+ (key.revocationReason === "superseded" ? (_jsx("span", { className: "text-text-muted", children: " (superseded)" })) : (_jsx("span", { className: "text-danger", children: " (revoked)" })))] }, key.fingerprint))) })) : (_jsx("p", { className: "text-sm text-text-muted", children: "No keys enrolled yet." }))] }), _jsx(ZeroAccessSetting, { mailbox: mailbox, canManage: canManageKeys }), _jsx(KeyExportSetting, { mailbox: mailbox, canManage: canManageKeys }), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Digital signatures" }), activeSigningKey && signingStatus !== "pending" && (_jsx("p", { className: ["text-sm text-text-muted", certificateMode ? "mb-3" : ""].join(" "), children: "Enabled \u2014 outgoing mail from this mailbox is signed with a publicly-trusted certificate." })), certificateMode && (_jsxs("div", { className: "flex flex-col gap-2", children: [certificateMode === "failed" && signingError && _jsx(Alert, { children: signingError }), _jsx(SigningCertificateCard, { mode: certificateMode, enrollment: certificateEnrollment, snapshot: enrollment, info: signingInfo, address: mailbox.primarySmtpAddress, keyNotAfter: certificateMode === "expired" ? expiredSigningKey?.notAfter : activeSigningKey?.notAfter, canRequest: canManageKeys, requesting: signingStatus === "enrolling", onCheck: canManageKeys ? () => void checkEnrollmentNow(mailboxUid) : undefined, onRequest: handleEnrollSigning })] })), !activeSigningKey && !certificateMode && !canManageKeys ? (_jsx("p", { className: "text-sm text-text-muted", children: "Not enabled." })) : activeSigningKey || certificateMode ? null : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-xs text-text-muted", children: "Lets recipients verify that mail from this mailbox is genuinely from you. Optional \u2014 encryption already works without it." }), beforeRequestText(signingInfo, mailbox.primarySmtpAddress) && (_jsx("p", { className: "text-xs text-text-muted", children: beforeRequestText(signingInfo, mailbox.primarySmtpAddress) })), healthWarning(signingInfo) && _jsx(Alert, { children: healthWarning(signingInfo) }), enrollment?.gone && _jsx(Alert, { children: STALE_REQUEST_TEXT }), signingError && _jsx(Alert, { children: signingError }), signingInfo?.backend !== "none" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: signingStatus === "enrolling", disabled: signingStatus !== "idle", onClick: handleEnrollSigning, children: "Enable digital signatures" }))] }))] }), mailbox.escrowScopeId && (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Escrow" }), escrowError && _jsx(Alert, { children: escrowError }), hasEscrowWrap ? (_jsx("p", { className: "text-sm text-text-muted", children: "This mailbox is under legal/compliance escrow \u2014 an authorized holder in your organization can recover its encrypted mail if needed. This does not weaken protection against anyone else." })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("p", { className: "text-xs text-text-muted", children: ["Your organization has assigned this mailbox to an escrow scope, but nothing has been protected yet \u2014 an authorized holder cannot recover this mailbox\u2019s encrypted mail until you complete this step.", " ", "This does not weaken protection against anyone else."] }), canManageKeys && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: wrappingEscrow, disabled: wrappingEscrow, onClick: handleWrapEscrow, children: "Add escrow protection" }))] })), _jsxs(Modal, { open: pendingEscrow !== null, onClose: () => setPendingEscrow(null), title: "Confirm escrow certificate", children: [_jsx("p", { className: "text-sm mb-3", children: "Anyone holding the private key for this certificate will be able to recover this mailbox\u2019s encrypted mail. Confirm it is the escrow certificate your organization expects before continuing." }), _jsxs("dl", { className: "text-sm mb-5 flex flex-col gap-1", children: [_jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Escrow scope" }), _jsx("dd", { className: "break-all", children: pendingEscrow?.escrowScopeId })] }), _jsxs("div", { children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate fingerprint" }), _jsx("dd", { className: "font-mono break-all", children: pendingEscrow?.publicKey.fingerprint })] })] }), _jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setPendingEscrow(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => void handleConfirmEscrow(pendingEscrow), children: "Trust this certificate" })] })] })] })), _jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Unlock methods" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Removing a method here stops it from being usable to unlock this mailbox going forward, but it is ", _jsx("strong", { children: "not" }), " full revocation \u2014 anyone who already captured a wrapped copy and knows its secret could still use it. For real revocation (e.g. after a lost device), rotate your keys entirely below instead."] }), vault && vault.masterKeyWraps.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-2", children: [...passwordWraps, ...recoveryWraps, ...otherWraps].map((wrap) => {
803
805
  const key = `${wrap.method}:${wrap.methodId ?? ""}`;
804
806
  return (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsx("span", { children: METHOD_LABELS[wrap.method] ?? wrap.method }), wrap.method !== "escrow" &&
805
807
  canManageKeys &&
@@ -108,7 +108,7 @@ function ExportSection({ mailboxUid, mailboxLabel }) {
108
108
  setCreating(false);
109
109
  }
110
110
  }
111
- return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export my data" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Requests a copy of ", _jsx("strong", { children: mailboxLabel }), "\u2019s messages, contacts, calendar, tasks, and notes. This runs in the background and can take a few minutes \u2014 check back here for a download link once it\u2019s ready."] }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-5", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating, children: "Request export" })] }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.format.toUpperCase(), " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
111
+ return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "Export my data" }), _jsxs("p", { className: "text-xs text-text-muted mb-3", children: ["Requests a copy of ", _jsx("strong", { children: mailboxLabel }), "\u2019s messages, contacts, calendar, tasks, and notes. This runs in the background and can take a few minutes \u2014 check back here for a download link once it\u2019s ready. Mail stored with zero-access encryption is exported encrypted: open it in a mail app that has your exported keys (Settings \u203A Encryption)."] }), _jsxs("form", { onSubmit: handleCreate, className: "flex items-end gap-3 mb-5", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Format" }), _jsxs("select", { "aria-label": "Export format", className: "text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: format, onChange: (e) => setFormat(e.target.value), children: [_jsx("option", { value: "json", children: "JSON" }), _jsx("option", { value: "mbox", children: "Mbox" })] })] }), _jsx(Button, { type: "submit", variant: "secondary", className: "!w-auto", loading: creating, disabled: creating, children: "Request export" })] }), loadError && _jsx(Alert, { children: loadError }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : requests.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No export requests yet." })) : (_jsx("ul", { className: "flex flex-col gap-2", children: requests.map((request) => (_jsxs("li", { className: "flex items-center justify-between gap-3 text-sm py-1.5 px-3 bg-surface-alt rounded-sm", children: [_jsxs("span", { children: [request.format.toUpperCase(), " \u00B7 ", new Date(request.dateCreated).toLocaleString(), request.status === "failed" && request.errorMessage && (_jsxs("span", { className: "text-danger", children: [" \u2014 ", request.errorMessage] }))] }), _jsxs("span", { className: "flex items-center gap-3", children: [_jsx("span", { className: `text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill ${statusBadgeClass(request.status)}`, children: statusLabel(request.status) }), request.status === "ready" && (_jsx("a", { href: exportRequestDownloadUrl(request.uid), className: "text-primary-dark hover:underline font-medium", children: "Download" }))] })] }, request.uid))) }))] }));
112
112
  }
113
113
  function ImportSection({ mailboxUid }) {
114
114
  const client = useApiClient();
@@ -7,7 +7,7 @@ import { pageTitle } from "../../shared/navigation/pageTitle.js";
7
7
  import { useEffect, useMemo, useRef, useState } from "react";
8
8
  import { endOfWeek, isAfter, isBefore, isToday, startOfDay } from "date-fns";
9
9
  import { ApiRequestError } from "../../../lib/util/api.js";
10
- import { createTask, deleteTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
10
+ import { createTask, deleteTask, getTask, listTasks, setTaskCompleted, setTaskMyDay, } from "../../../lib/tasks/tasksApi.js";
11
11
  import { listFlaggedMessages } from "../../../lib/mail/flaggedMessages.js";
12
12
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
13
13
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
@@ -25,6 +25,11 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
25
25
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
26
26
  import { useShortcutProps } from "../../shared/keyboard/useShortcutProps.js";
27
27
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
28
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
29
+ /** The published model class name of a task on either database (`TaskMongo`, `TaskSQL`) - not a `TaskList`'s. */
30
+ const TASK_MODEL = /^Task(Mongo|SQL)$/;
31
+ /** The push client channel group (`PushClient.setChannels()`) the page's tasks folder is subscribed under. */
32
+ const TASKS_CHANNEL_GROUP = "tasks";
28
33
  const INPUT_CLASS = "text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
29
34
  function TasksPage(props) {
30
35
  return (_jsx(TasksShell, { ...props, children: _jsx(TasksContent, {}) }));
@@ -121,6 +126,29 @@ function TasksContent() {
121
126
  useEffect(() => {
122
127
  void reload();
123
128
  }, [folderUid]);
129
+ // A task created, changed or deleted anywhere - another tab, a phone over ActiveSync - is announced on its folder's channel. It is fetched again by its
130
+ // uid and replaces (or joins) the list while it is still in this folder; one that is gone (a delete, a 404, or moved to another folder) leaves it. A
131
+ // failed fetch changes nothing: the next load shows it as it is.
132
+ useFolderLiveUpdates(TASKS_CHANNEL_GROUP, folderUid ? [folderUid] : [], (event) => changedItemOf(event, TASK_MODEL), (change) => {
133
+ const remove = () => setTasks((previous) => previous.filter((existing) => existing.uid !== change.uid));
134
+ if (change.deleted) {
135
+ remove();
136
+ return;
137
+ }
138
+ void getTask(change.uid, client).then((fresh) => {
139
+ if (fresh.folderUid !== folderUid) {
140
+ remove();
141
+ return;
142
+ }
143
+ setTasks((previous) => previous.some((existing) => existing.uid === fresh.uid)
144
+ ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
145
+ : [...previous, fresh]);
146
+ }, (err) => {
147
+ if (err instanceof ApiRequestError && err.status === 404) {
148
+ remove();
149
+ }
150
+ });
151
+ });
124
152
  useEffect(() => {
125
153
  setTargetMailboxUid(undefined);
126
154
  setAddedElsewhere(null);
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { ApiClient } from "../util/api.js";
11
11
  import { ListParams, RequestListParams } from "../util/apiQuery.js";
12
+ import type { PublicKey, WrappedPrivateKey } from "../crypto/keyvaultApi.js";
12
13
  export type { ListParams, RequestListParams };
13
14
  export interface EscrowAccessRequestApproval {
14
15
  holderUserUid: string;
@@ -54,6 +55,11 @@ export interface MasterKeyWrap {
54
55
  * tooling, per `specs/end-to-end_encryption.md`'s "this server never holds the scope's private key". */
55
56
  export interface EscrowAccessMaterial {
56
57
  masterKeyWraps: MasterKeyWrap[];
58
+ /** The mailbox's wrapped private keys that read mail (zero-access and encryption keys, never signing), sealed under its master key. Absent
59
+ * from a server that predates releasing them. */
60
+ wrappedKeys?: WrappedPrivateKey[];
61
+ /** The certificate of each of `wrappedKeys`, matched by fingerprint. */
62
+ certificates?: PublicKey[];
57
63
  }
58
64
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
59
65
  * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
@@ -112,6 +112,12 @@ export interface CalendarEvent {
112
112
  * `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
113
113
  * the description, if any, is plain text. */
114
114
  descriptionHtml?: string | null;
115
+ /** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
116
+ * sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
117
+ sealedContent?: {
118
+ keyFingerprint: string;
119
+ sealed: string;
120
+ };
115
121
  /** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
116
122
  visibility?: EventVisibility | null;
117
123
  /** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
@@ -8,6 +8,13 @@
8
8
  * iTIP REPLY to the organizer. The client only reads the state and asks for a change; every call returns the invitation as
9
9
  * it now stands, ready to draw. */
10
10
  import { ApiRequestError, withClient } from "../util/api.js";
11
+ import { sealedInviteCalendar } from "./sealedInvites.js";
12
+ /** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
13
+ * `ics`, or nothing for a message the server reads itself. */
14
+ function sealedSource(messageUid) {
15
+ const ics = sealedInviteCalendar(messageUid);
16
+ return ics !== undefined ? { ics } : {};
17
+ }
11
18
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
12
19
  * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
13
20
  * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
@@ -16,8 +23,15 @@ import { ApiRequestError, withClient } from "../util/api.js";
16
23
  * common, expected answer for a message that merely has an attachment - and rejects for any other failure.
17
24
  */
18
25
  export async function getMessageInvite(messageUid, client) {
26
+ const source = sealedSource(messageUid);
19
27
  try {
20
- return await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
28
+ // A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
29
+ return source.ics !== undefined
30
+ ? await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
31
+ method: "POST",
32
+ body: JSON.stringify(source),
33
+ })
34
+ : await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
21
35
  }
22
36
  catch (err) {
23
37
  if (err instanceof ApiRequestError && err.status === 404) {
@@ -34,21 +48,29 @@ export async function getMessageInvite(messageUid, client) {
34
48
  export function respondToMessageInvite(messageUid, responseStatus, client) {
35
49
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
36
50
  method: "POST",
37
- body: JSON.stringify({ responseStatus }),
51
+ body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
38
52
  });
39
53
  }
40
54
  /** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
41
55
  export function removeMessageInvite(messageUid, client) {
42
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, { method: "POST" });
56
+ const source = sealedSource(messageUid);
57
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
58
+ method: "POST",
59
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
60
+ });
43
61
  }
44
62
  /** Proposes a different time to the organizer of a `REQUEST` (an iTIP `COUNTER` sent by the server). Resolves with the invitation as it now stands. */
45
63
  export function proposeNewTime(messageUid, proposal, client) {
46
64
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
47
65
  method: "POST",
48
- body: JSON.stringify(proposal),
66
+ body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
49
67
  });
50
68
  }
51
69
  /** Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes: the server moves the event and sends the attendees the update. Resolves with the invitation as it now stands. */
52
70
  export function acceptProposal(messageUid, client) {
53
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, { method: "POST" });
71
+ const source = sealedSource(messageUid);
72
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
73
+ method: "POST",
74
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
75
+ });
54
76
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
3
+ * the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
4
+ * messages read most recently, and cleared on sign-out (`clearSealedInvites()`).
5
+ */
6
+ /** Remembers the decrypted calendar file of zero-access message `messageUid`. */
7
+ export declare function rememberSealedInvite(messageUid: string, ics: string): void;
8
+ /** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
9
+ export declare function sealedInviteCalendar(messageUid: string): string | undefined;
10
+ /** Forgets every remembered calendar file (sign-out). */
11
+ export declare function clearSealedInvites(): void;