@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
@@ -25,15 +25,26 @@ import { ApiRequestError } from "../../../lib/util/api.js";
25
25
  import { useApiClient } from "../../../lib/util/apiClientContext.js";
26
26
  import useIsMobile from "../../../lib/util/useIsMobile.js";
27
27
  import { CalendarEvent, getCalendarEvent, listCalendarEvents } from "../../../lib/calendar/calendarApi.js";
28
- import { getPushClient } from "../../../lib/mail/pushClient.js";
28
+ import { openSealedEvent, openSealedEvents } from "../../shared/calendar/sealedEvents.js";
29
+ import { useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
29
30
  import { moveOccurrence, resizeOccurrenceEnd } from "../../../lib/calendar/calendarMutations.js";
30
31
  import { resolveDragAction } from "../../../lib/calendar/calendarDragIds.js";
31
32
  import { createFolder } from "../../../lib/mail/mailApi.js";
32
33
  import { CalendarOccurrence, expandAllOccurrences, expandOccurrences } from "../../../lib/calendar/recurrence.js";
33
34
  import { useDayKey } from "../../../lib/calendar/useDayKey.js";
34
- import { allOccurrences, initialMatchIndex, occurrencesInRange, searchOccurrences, searchTerms, stepSequenceIndex } from "../../../lib/calendar/eventSearch.js";
35
+ import {
36
+ allOccurrences,
37
+ initialMatchIndex,
38
+ occurrencesInRange,
39
+ searchOccurrences,
40
+ searchTerms,
41
+ stepSequenceIndex,
42
+ } from "../../../lib/calendar/eventSearch.js";
35
43
  import Drawer from "../../../lib/components/overlays/Drawer.js";
36
- import CalendarShell, { CalendarShellProps, useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
44
+ import CalendarShell, {
45
+ CalendarShellProps,
46
+ useCalendarShell,
47
+ } from "../../shared/components/calendar/layout/CalendarShell.js";
37
48
  import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
38
49
  import CalendarSearchBox from "../../shared/components/calendar/CalendarSearchBox.js";
39
50
  import { ActiveOccurrenceContext } from "../../shared/components/calendar/activeOccurrence.js";
@@ -49,7 +60,13 @@ import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
49
60
  import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
50
61
  import Alert from "../../../lib/components/feedback/Alert.js";
51
62
  import Button from "../../../lib/components/buttons/Button.js";
52
- import { CALENDAR_VIEWS, CalendarView, getStoredCalendarView, isCalendarView, storeCalendarView } from "../../shared/components/calendar/calendarView.js";
63
+ import {
64
+ CALENDAR_VIEWS,
65
+ CalendarView,
66
+ getStoredCalendarView,
67
+ isCalendarView,
68
+ storeCalendarView,
69
+ } from "../../shared/components/calendar/calendarView.js";
53
70
  import { SlideFrom } from "../../shared/components/calendar/SlideIn.js";
54
71
  import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
55
72
  import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
@@ -63,6 +80,9 @@ import { notifyApiError } from "../../shared/notifications/apiErrors.js";
63
80
  import { bookingSettingsHref } from "../../shared/calendar/bookingPlugin.js";
64
81
  import { changedEventOf } from "../../shared/calendar/calendarLiveUpdates.js";
65
82
 
83
+ /** The push client channel group (`PushClient.setChannels()`) the page's calendars are subscribed under. */
84
+ const CALENDAR_CHANNEL_GROUP = "calendar";
85
+
66
86
  /** The furthest instants a `Date` can hold: the List's "range" reaches both. */
67
87
  const MAX_DATE_MS = 8.64e15;
68
88
 
@@ -75,7 +95,14 @@ function CalendarPage(props: CalendarShellProps) {
75
95
  }
76
96
 
77
97
  type ViewType = CalendarView;
78
- const VIEW_LABELS: Record<ViewType, string> = { list: "List", month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
98
+ const VIEW_LABELS: Record<ViewType, string> = {
99
+ list: "List",
100
+ month: "Month",
101
+ week: "Week",
102
+ workWeek: "Work Week",
103
+ day: "Day",
104
+ split: "Split",
105
+ };
79
106
  /** The shortcut that switches to a view (Outlook's Ctrl+Alt+1-4, the List one more); the split view has none. */
80
107
  const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
81
108
  list: SHORTCUTS.calendar.list,
@@ -86,7 +113,15 @@ const VIEW_SHORTCUTS: Partial<Record<ViewType, ShortcutDef>> = {
86
113
  };
87
114
 
88
115
  /** One of the view switcher's buttons, with its shortcut in the tooltip. */
89
- function ViewButton({ view, current, onSelect }: { view: ViewType; current: ViewType; onSelect: (view: ViewType) => void }) {
116
+ function ViewButton({
117
+ view,
118
+ current,
119
+ onSelect,
120
+ }: {
121
+ view: ViewType;
122
+ current: ViewType;
123
+ onSelect: (view: ViewType) => void;
124
+ }) {
90
125
  const shortcut = VIEW_SHORTCUTS[view];
91
126
  const hint = useShortcutProps(VIEW_LABELS[view], shortcut ?? SHORTCUTS.calendar.day, !!shortcut);
92
127
  return (
@@ -121,7 +156,8 @@ interface ModalState {
121
156
  }
122
157
 
123
158
  function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHref?: string }) {
124
- const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } = useCalendarShell();
159
+ const { mailboxUid, folderUid, calendarFolders, mailboxCalendars, mailboxes, reloadFolders, colorFor } =
160
+ useCalendarShell();
125
161
  const client = useApiClient();
126
162
  // `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
127
163
  // scroll gesture on a touch device. `MouseSensor` (a small `distance` — desktop drags still start
@@ -130,7 +166,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
130
166
  // dnd-kit's own documented pattern for this exact conflict.
131
167
  const sensors = useSensors(
132
168
  useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
133
- useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
169
+ useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })
134
170
  );
135
171
 
136
172
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -191,7 +227,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
191
227
  const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
192
228
  const folderColors = useMemo(
193
229
  () => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorFor(f)])),
194
- [calendarFolders, colorFor],
230
+ [calendarFolders, colorFor]
195
231
  );
196
232
 
197
233
  function toggleCalendar(toggledUid: string) {
@@ -254,7 +290,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
254
290
  }
255
291
  setLoading(true);
256
292
  setError(null);
257
- void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client)))
293
+ void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid, client).then(openSealedEvents)))
258
294
  .then((results) => {
259
295
  if (seq !== reloadSeqRef.current) {
260
296
  return;
@@ -292,7 +328,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
292
328
 
293
329
  // Keyed on stable string summaries (not the array/set objects themselves, which are new references
294
330
  // every render) so this only re-fires when the actual set of folders to fetch changes.
295
- const calendarFolderUidsKey = calendarFolders.map((f) => f.uid).sort().join(",");
331
+ const calendarFolderUidsKey = calendarFolders
332
+ .map((f) => f.uid)
333
+ .sort()
334
+ .join(",");
296
335
  const checkedFolderUidsKey = Array.from(checkedFolderUids).sort().join(",");
297
336
  useEffect(reload, [calendarFolderUidsKey, checkedFolderUidsKey]);
298
337
 
@@ -302,25 +341,27 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
302
341
  // fetch changes nothing: the next load shows the event as it is.
303
342
  const checkedFolderUidsRef = useRef(checkedFolderUids);
304
343
  checkedFolderUidsRef.current = checkedFolderUids;
305
- useEffect(
306
- () =>
307
- getPushClient().onEvent((event) => {
308
- const change = changedEventOf(event);
309
- if (!change) {
310
- return;
311
- }
312
- const { uid } = change;
313
- if (change.deleted) {
314
- setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
315
- return;
316
- }
317
- void getCalendarEvent(uid, client).then(
344
+ // Those events are published on the calendar's own channel, so the page subscribes to every calendar it lists - checked or not, so checking
345
+ // one needs no new subscription - while it is open.
346
+ useFolderLiveUpdates(
347
+ CALENDAR_CHANNEL_GROUP,
348
+ calendarFolders.map((f) => f.uid),
349
+ changedEventOf,
350
+ (change) => {
351
+ const { uid } = change;
352
+ if (change.deleted) {
353
+ setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
354
+ return;
355
+ }
356
+ void getCalendarEvent(uid, client)
357
+ .then(openSealedEvent)
358
+ .then(
318
359
  (fresh) => {
319
360
  if (checkedFolderUidsRef.current.has(fresh.folderUid)) {
320
361
  setEvents((previous) =>
321
362
  previous.some((existing) => existing.uid === fresh.uid)
322
363
  ? previous.map((existing) => (existing.uid === fresh.uid ? fresh : existing))
323
- : [...previous, fresh],
364
+ : [...previous, fresh]
324
365
  );
325
366
  }
326
367
  },
@@ -328,10 +369,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
328
369
  if (err instanceof ApiRequestError && err.status === 404) {
329
370
  setEvents((previous) => previous.filter((existing) => existing.uid !== uid));
330
371
  }
331
- },
372
+ }
332
373
  );
333
- }),
334
- [client],
374
+ }
335
375
  );
336
376
 
337
377
  // 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
@@ -359,7 +399,10 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
359
399
 
360
400
  // What an open event card steps through, in order: the matches while a search runs, else every event of the checked calendars (bounded as a
361
401
  // search is). The List holds only what is from today on, so that is all its card steps through. Worked out only when something needs it.
362
- const everything = useMemo(() => (view === "list" || modal?.occurrence ? allOccurrences(events) : []), [events, view, !!modal?.occurrence]);
402
+ const everything = useMemo(
403
+ () => (view === "list" || modal?.occurrence ? allOccurrences(events) : []),
404
+ [events, view, !!modal?.occurrence]
405
+ );
363
406
  const sequence = useMemo(() => {
364
407
  if (searching) {
365
408
  return matches;
@@ -375,7 +418,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
375
418
  if (view === "list") {
376
419
  return sequence;
377
420
  }
378
- return searching ? occurrencesInRange(matches, rangeStart, rangeEnd) : expandAllOccurrences(events, rangeStart, rangeEnd);
421
+ return searching
422
+ ? occurrencesInRange(matches, rangeStart, rangeEnd)
423
+ : expandAllOccurrences(events, rangeStart, rangeEnd);
379
424
  }, [events, matches, searching, view, sequence, rangeStart, rangeEnd]);
380
425
 
381
426
  /** Points at `match`, moving the view to the day it is on unless the view already shows it. */
@@ -395,7 +440,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
395
440
  // calendar do not.
396
441
  const stepFrom = modal?.occurrence;
397
442
  const stepIndex = (direction: 1 | -1) =>
398
- stepSequenceIndex(sequence, stepFrom?.occurrenceKey ?? activeKey, stepFrom ? new Date(stepFrom.startDate) : undefined, direction, searching);
443
+ stepSequenceIndex(
444
+ sequence,
445
+ stepFrom?.occurrenceKey ?? activeKey,
446
+ stepFrom ? new Date(stepFrom.startDate) : undefined,
447
+ direction,
448
+ searching
449
+ );
399
450
 
400
451
  function stepMatch(direction: 1 | -1) {
401
452
  const next = sequence[stepIndex(direction)];
@@ -431,17 +482,22 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
431
482
  searchedQueryRef.current = query;
432
483
  // The List holds every match: it starts at the first from the day being viewed on rather than the first of all.
433
484
  const from = startOfDay(viewDate);
434
- showMatch(matches[view === "list" ? initialMatchIndex(matches, from, rangeEnd, from) : initialMatchIndex(matches, rangeStart, rangeEnd, from)]);
485
+ showMatch(
486
+ matches[
487
+ view === "list"
488
+ ? initialMatchIndex(matches, from, rangeEnd, from)
489
+ : initialMatchIndex(matches, rangeStart, rangeEnd, from)
490
+ ]
491
+ );
435
492
  }
436
493
  }, [matches, query, searching]);
437
494
 
438
-
439
495
  const splitColumns = useMemo(
440
496
  () =>
441
497
  calendarFolders
442
498
  .filter((f) => checkedFolderUids.has(f.uid))
443
499
  .map((f) => ({ folderUid: f.uid, name: f.name, color: colorFor(f) })),
444
- [calendarFolders, checkedFolderUidsKey, colorFor],
500
+ [calendarFolders, checkedFolderUidsKey, colorFor]
445
501
  );
446
502
 
447
503
  function shiftView(direction: 1 | -1) {
@@ -455,7 +511,7 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
455
511
  ? addMonths(d, direction)
456
512
  : view === "week" || view === "workWeek"
457
513
  ? addWeeks(d, direction)
458
- : addDays(d, direction),
514
+ : addDays(d, direction)
459
515
  );
460
516
  }
461
517
 
@@ -474,7 +530,8 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
474
530
  const wheelSlide = useEnterSlide();
475
531
  /** Where the scroller of the page a wheel step arrived at starts: its top after going on, its bottom after going back. */
476
532
  const arrivalEdge = useRef<WheelEdge | null>(null);
477
- const scrollerOf = (): HTMLElement | null => swipe.ref.current?.querySelector<HTMLElement>("[data-calendar-scroller]") ?? null;
533
+ const scrollerOf = (): HTMLElement | null =>
534
+ swipe.ref.current?.querySelector<HTMLElement>("[data-calendar-scroller]") ?? null;
478
535
  useWheelPaging(swipe.ref, {
479
536
  enabled: !isMobile && !dragging && !modal && view !== "list",
480
537
  getScroller: () => (view === "month" ? null : scrollerOf()),
@@ -496,7 +553,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
496
553
  // Brings the match being pointed at into sight (the views scroll their own hours; the page may too).
497
554
  useEffect(() => {
498
555
  const marked = Array.from(swipe.ref.current!.querySelectorAll<HTMLElement>("[data-occurrence-key]"));
499
- marked.find((el) => el.dataset.occurrenceKey === activeKey)?.scrollIntoView({ block: "nearest", inline: "nearest" });
556
+ marked
557
+ .find((el) => el.dataset.occurrenceKey === activeKey)
558
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
500
559
  }, [activeKey, view === "list" ? null : viewDate, view, loading]);
501
560
 
502
561
  // 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.
@@ -512,7 +571,13 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
512
571
  setViewDate(startOfDay(date));
513
572
  }
514
573
 
515
- function openNewEvent(start?: Date, end?: Date, targetFolderUid?: string, anchor?: EventAnchor, initialAllDay?: boolean) {
574
+ function openNewEvent(
575
+ start?: Date,
576
+ end?: Date,
577
+ targetFolderUid?: string,
578
+ anchor?: EventAnchor,
579
+ initialAllDay?: boolean
580
+ ) {
516
581
  if (!mailboxUid || !folderUid) {
517
582
  return;
518
583
  }
@@ -556,10 +621,17 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
556
621
  const requestedStart = new Date(params.get("start") ?? "");
557
622
  navigateTo("/calendar", { replace: true });
558
623
  getCalendarEvent(eventUid, client)
624
+ .then(openSealedEvent)
559
625
  .then((event) => {
560
626
  const from = isNaN(requestedStart.getTime()) ? new Date(event.startDate) : requestedStart;
561
- const candidates = expandOccurrences(event, new Date(from.getTime() - 86_400_000), new Date(from.getTime() + 86_400_000));
562
- const match = candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ?? candidates[0];
627
+ const candidates = expandOccurrences(
628
+ event,
629
+ new Date(from.getTime() - 86_400_000),
630
+ new Date(from.getTime() + 86_400_000)
631
+ );
632
+ const match =
633
+ candidates.find((occurrence) => new Date(occurrence.startDate).getTime() === from.getTime()) ??
634
+ candidates[0];
563
635
  if (match) {
564
636
  setViewDate(occurrenceDay(match));
565
637
  openEvent(match);
@@ -623,9 +695,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
623
695
  ? "Upcoming events"
624
696
  : view === "month"
625
697
  ? format(viewDate, "MMMM yyyy")
626
- : view === "week" || view === "workWeek"
627
- ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
628
- : format(viewDate, "EEEE, MMMM d, yyyy");
698
+ : view === "week" || view === "workWeek"
699
+ ? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
700
+ : format(viewDate, "EEEE, MMMM d, yyyy");
629
701
 
630
702
  const modalFolderUid = modal ? (modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid) : undefined;
631
703
  const modalMailboxUid = calendarFolders.find((f) => f.uid === modalFolderUid)?.mailboxUid ?? mailboxUid;
@@ -636,9 +708,14 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
636
708
  const mailboxOptions = useMemo(
637
709
  () =>
638
710
  mailboxCalendars
639
- .filter((mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid))
640
- .map((mc) => ({ mailbox: mc.mailbox, calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })) })),
641
- [mailboxCalendars, writableMailboxes],
711
+ .filter(
712
+ (mc) => mc.calendarFolders.length > 0 && writableMailboxes.some((mb) => mb.uid === mc.mailbox.uid)
713
+ )
714
+ .map((mc) => ({
715
+ mailbox: mc.mailbox,
716
+ calendars: mc.calendarFolders.map((f) => ({ uid: f.uid, name: f.name })),
717
+ })),
718
+ [mailboxCalendars, writableMailboxes]
642
719
  );
643
720
 
644
721
  const sidebarContent = (
@@ -662,7 +739,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
662
739
 
663
740
  return (
664
741
  <div className="flex-1 min-w-0 flex min-h-0">
665
- <div className="hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto">{sidebarContent}</div>
742
+ <div className="hidden lg:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto">
743
+ {sidebarContent}
744
+ </div>
666
745
  <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Calendars" fullScreen>
667
746
  <div className="flex flex-col">{sidebarContent}</div>
668
747
  </Drawer>
@@ -679,18 +758,42 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
679
758
  </button>
680
759
  {/* On a phone New event is the floating button at the bottom of the page instead, as in Mail. */}
681
760
  {!isMobile && (
682
- <Button type="button" onClick={(e) => openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below"))} className="!w-auto shrink-0" {...newEventHint}>
761
+ <Button
762
+ type="button"
763
+ onClick={(e) =>
764
+ openNewEvent(undefined, undefined, undefined, anchorOf(e.currentTarget, "below"))
765
+ }
766
+ className="!w-auto shrink-0"
767
+ {...newEventHint}
768
+ >
683
769
  + New event
684
770
  </Button>
685
771
  )}
686
772
  <div className="flex items-center gap-1">
687
- <button type="button" onClick={() => shiftView(-1)} aria-label="Previous" {...previousHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
773
+ <button
774
+ type="button"
775
+ onClick={() => shiftView(-1)}
776
+ aria-label="Previous"
777
+ {...previousHint}
778
+ className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt"
779
+ >
688
780
  &lsaquo;
689
781
  </button>
690
- <button type="button" onClick={goToday} {...todayHint} className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt">
782
+ <button
783
+ type="button"
784
+ onClick={goToday}
785
+ {...todayHint}
786
+ className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt"
787
+ >
691
788
  Today
692
789
  </button>
693
- <button type="button" onClick={() => shiftView(1)} aria-label="Next" {...nextHint} className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
790
+ <button
791
+ type="button"
792
+ onClick={() => shiftView(1)}
793
+ aria-label="Next"
794
+ {...nextHint}
795
+ className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt"
796
+ >
694
797
  &rsaquo;
695
798
  </button>
696
799
  </div>
@@ -720,8 +823,17 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
720
823
  </div>
721
824
  )}
722
825
 
723
- <div ref={swipe.ref} className="flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden" {...swipe.handlers} style={swipe.style}>
724
- <div data-swipe-phase={swipe.phase} className="flex-1 min-w-0 flex flex-col min-h-0" style={swipe.contentStyle}>
826
+ <div
827
+ ref={swipe.ref}
828
+ className="flex-1 min-w-0 flex flex-col min-h-0 overflow-hidden"
829
+ {...swipe.handlers}
830
+ style={swipe.style}
831
+ >
832
+ <div
833
+ data-swipe-phase={swipe.phase}
834
+ className="flex-1 min-w-0 flex flex-col min-h-0"
835
+ style={swipe.contentStyle}
836
+ >
725
837
  <div className="flex-1 min-w-0 flex flex-col min-h-0" style={wheelSlide.style}>
726
838
  {loading ? (
727
839
  <p className="p-4 text-sm text-text-muted">Loading&hellip;</p>
@@ -749,7 +861,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
749
861
  folderColors={folderColors}
750
862
  onSelectDay={handleSelectDay}
751
863
  onSelectEvent={openEvent}
752
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor, true)}
864
+ onSelectSlot={(start, end, anchor) =>
865
+ openNewEvent(start, end, undefined, anchor, true)
866
+ }
753
867
  />
754
868
  ) : view === "split" ? (
755
869
  <SplitDayView
@@ -757,7 +871,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
757
871
  columns={splitColumns}
758
872
  occurrences={occurrences}
759
873
  onSelectEvent={openEvent}
760
- onSelectSlot={(start, end, targetFolderUid, anchor) => openNewEvent(start, end, targetFolderUid, anchor)}
874
+ onSelectSlot={(start, end, targetFolderUid, anchor) =>
875
+ openNewEvent(start, end, targetFolderUid, anchor)
876
+ }
761
877
  />
762
878
  ) : (
763
879
  <TimeGridView
@@ -765,7 +881,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
765
881
  occurrences={occurrences}
766
882
  folderColors={folderColors}
767
883
  onSelectEvent={openEvent}
768
- onSelectSlot={(start, end, anchor) => openNewEvent(start, end, undefined, anchor)}
884
+ onSelectSlot={(start, end, anchor) =>
885
+ openNewEvent(start, end, undefined, anchor)
886
+ }
769
887
  />
770
888
  )}
771
889
  </DndContext>
@@ -785,7 +903,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
785
903
  onClose={closeModal}
786
904
  mailboxUid={modalMailbox.uid}
787
905
  folderUid={modalFolderUid!}
788
- calendars={calendarFolders.filter((f) => f.mailboxUid === modalMailbox.uid).map((f) => ({ uid: f.uid, name: f.name }))}
906
+ calendars={calendarFolders
907
+ .filter((f) => f.mailboxUid === modalMailbox.uid)
908
+ .map((f) => ({ uid: f.uid, name: f.name }))}
789
909
  mailboxOptions={mailboxOptions}
790
910
  folderColors={folderColors}
791
911
  organizerAddress={modalMailbox.primarySmtpAddress}
@@ -814,7 +934,9 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
814
934
  )}
815
935
  {/* The phone layout's New event: the old toolbar button's action (its quick form is a bottom sheet there, so no anchor), offered
816
936
  only where that button could act - a calendar to put the event in - and not while the editor or its sheet is open. */}
817
- {isMobile && mailboxUid && folderUid && !modal && <FloatingActionButton label="New event" icon={HiOutlinePlus} onClick={() => openNewEvent()} />}
937
+ {isMobile && mailboxUid && folderUid && !modal && (
938
+ <FloatingActionButton label="New event" icon={HiOutlinePlus} onClick={() => openNewEvent()} />
939
+ )}
818
940
  </div>
819
941
  </div>
820
942
  );
@@ -10,6 +10,7 @@ import {
10
10
  Contact,
11
11
  createContact,
12
12
  deleteContact,
13
+ getContact,
13
14
  listContacts,
14
15
  ensureSuggestedContacts,
15
16
  listContactsInFolders,
@@ -42,6 +43,18 @@ import { SHORTCUTS } from "../../shared/keyboard/keymap.js";
42
43
  import { useShortcut } from "../../shared/keyboard/useShortcut.js";
43
44
  import { notifyApiError } from "../../shared/notifications/apiErrors.js";
44
45
  import { notify } from "../../shared/notifications/store.js";
46
+ import { changedItemOf, useFolderLiveUpdates } from "../../shared/live/useFolderLiveUpdates.js";
47
+
48
+ /** The published model class name of a contact on either database (`ContactMongo`, `ContactSQL`) - not a `ContactList`'s. */
49
+ const CONTACT_MODEL = /^Contact(Mongo|SQL)$/;
50
+
51
+ /** The push client channel group (`PushClient.setChannels()`) the page's contacts folders are subscribed under. */
52
+ const CONTACTS_CHANNEL_GROUP = "contacts";
53
+
54
+ /** `list` with `contact` in place of the one with its uid, or added when it has none. */
55
+ function upsertContact(list: Contact[], contact: Contact): Contact[] {
56
+ return list.some((existing) => existing.uid === contact.uid) ? list.map((existing) => (existing.uid === contact.uid ? contact : existing)) : [...list, contact];
57
+ }
45
58
 
46
59
  const INPUT_CLASS =
47
60
  "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
@@ -166,6 +179,45 @@ function ContactsContent({ userUid }: { userUid?: string }) {
166
179
  };
167
180
  }, [mailboxUid]);
168
181
 
182
+ // 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
183
+ // 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
184
+ // gone (a delete, or a 404) leaves both. A failed fetch changes nothing: the next load shows it as it is.
185
+ useFolderLiveUpdates(
186
+ CONTACTS_CHANNEL_GROUP,
187
+ [folderUid, suggestedFolderUid].filter((uid): uid is string => !!uid),
188
+ (event) => changedItemOf(event, CONTACT_MODEL),
189
+ (change) => {
190
+ const remove = (uid: string) => {
191
+ setContacts((previous) => previous.filter((existing) => existing.uid !== uid));
192
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== uid));
193
+ };
194
+ if (change.deleted) {
195
+ remove(change.uid);
196
+ return;
197
+ }
198
+ void getContact(change.uid, client).then(
199
+ (fresh) => {
200
+ if (fresh.deleted === true) {
201
+ remove(fresh.uid);
202
+ } else if (fresh.folderUid === folderUid) {
203
+ setContacts((previous) => upsertContact(previous, fresh));
204
+ setSuggestedContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
205
+ } else if (fresh.folderUid === suggestedFolderUid) {
206
+ setSuggestedContacts((previous) => upsertContact(previous, fresh));
207
+ setContacts((previous) => previous.filter((existing) => existing.uid !== fresh.uid));
208
+ } else {
209
+ remove(fresh.uid);
210
+ }
211
+ },
212
+ (err: unknown) => {
213
+ if (err instanceof ApiRequestError && err.status === 404) {
214
+ remove(change.uid);
215
+ }
216
+ },
217
+ );
218
+ },
219
+ );
220
+
169
221
  // restapi only honors `?deleted=true` for a caller with both delete and update rights on the contacts *folder*
170
222
  // (its ACL, which inherits the mailbox's but can carry its own records) - anyone else silently gets the *live*
171
223
  // contacts back, which the Deleted view would then present as deleted. There is no client API for the caller's
@@ -228,6 +280,9 @@ function ContactsContent({ userUid }: { userUid?: string }) {
228
280
  return contacts;
229
281
  case "favorites":
230
282
  return contacts.filter((c) => c.favorite);
283
+ case "everyone":
284
+ // 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).
285
+ return [...contacts, ...suggestedContacts];
231
286
  case "suggested":
232
287
  return suggestedContacts;
233
288
  case "list":
@@ -428,7 +483,8 @@ function ContactsContent({ userUid }: { userUid?: string }) {
428
483
 
429
484
  // 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).
430
485
  const handleAddToContacts = singleFlight(async () => {
431
- for (const contact of checkedContacts) {
486
+ // In the All contacts view the selection can hold contacts that already are the mailbox's own: only the suggested ones are added.
487
+ for (const contact of checkedContacts.filter((checked) => checked.folderUid === suggestedFolderUid)) {
432
488
  try {
433
489
  await createContact(
434
490
  {
@@ -540,7 +596,7 @@ function ContactsContent({ userUid }: { userUid?: string }) {
540
596
  onToggleFavorite={handleToggleFavorite}
541
597
  onAddCategory={handleAddCategory}
542
598
  onExportVCard={handleExportVCard}
543
- onAddToContacts={view.type === "suggested" ? handleAddToContacts : undefined}
599
+ onAddToContacts={view.type === "suggested" || view.type === "everyone" ? handleAddToContacts : undefined}
544
600
  onImportFile={handleImportFile}
545
601
  shortcuts
546
602
  hideNew={isMobile}
@@ -160,11 +160,18 @@ async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys)
160
160
  DECRYPT_CONCURRENCY,
161
161
  async (message): Promise<[string, DecryptedRow] | null> => {
162
162
  try {
163
- const rawMime = await getMessageRawContent(message.uid);
164
163
  // Loaded here, on first use: the S/MIME code (PKI.js, ASN.1, X.509) is over half a megabyte and an inbox with
165
164
  // no encrypted mail in it never needs it.
166
- const { evaluateMessageSecurity } = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
167
- const security = await evaluateMessageSecurity(rawMime, unlocked);
165
+ const securityApi = await (securityModule ??= import("../../lib/crypto/messageSecurity.js"));
166
+ // A message sealed at rest carries its subject and preview sealed on the row: no download needed.
167
+ const summary = message.zeroAccess ? await securityApi.openZeroAccessSummary(message.zeroAccess.sealedSummary, unlocked.zeroAccessKeys) : undefined;
168
+ if (summary) {
169
+ const preview = summary.preview.slice(0, LIST_PREVIEW_MAX_LENGTH);
170
+ rememberDecrypted(message.uid, message.mailboxUid, { subject: summary.subject, preview });
171
+ return [message.uid, { subject: summary.subject, preview }];
172
+ }
173
+ const rawMime = await getMessageRawContent(message.uid);
174
+ const security = await securityApi.evaluateMessageSecurity(rawMime, unlocked);
168
175
  if (!security.subject && !security.html) {
169
176
  markDecryptFailed(message.uid);
170
177
  return null;
@@ -51,6 +51,8 @@ import { notifyApiError } from "../../../shared/notifications/apiErrors.js";
51
51
  import Alert from "../../../../lib/components/feedback/Alert.js";
52
52
  import Button from "../../../../lib/components/buttons/Button.js";
53
53
  import Modal from "../../../../lib/components/overlays/Modal.js";
54
+ import ZeroAccessSetting from "../../../shared/components/settings/ZeroAccessSetting.js";
55
+ import KeyExportSetting from "../../../shared/components/settings/KeyExportSetting.js";
54
56
  import SigningCertificateCard, { SigningCertificateMode } from "../../../shared/components/settings/SigningCertificateCard.js";
55
57
  import { readStoredSignEnrollment, storeSignEnrollment } from "../../../shared/signing/enrollmentStorage.js";
56
58
  import {
@@ -975,6 +977,9 @@ function EncryptionContent({ canManageKeys }: { canManageKeys: boolean }) {
975
977
  )}
976
978
  </div>
977
979
 
980
+ <ZeroAccessSetting mailbox={mailbox} canManage={canManageKeys} />
981
+ <KeyExportSetting mailbox={mailbox} canManage={canManageKeys} />
982
+
978
983
  <div>
979
984
  <h2 className="text-sm font-semibold mb-2">Digital signatures</h2>
980
985
  {activeSigningKey && signingStatus !== "pending" && (
@@ -174,7 +174,8 @@ function ExportSection({ mailboxUid, mailboxLabel }: { mailboxUid?: string; mail
174
174
  <p className="text-xs text-text-muted mb-3">
175
175
  Requests a copy of <strong>{mailboxLabel}</strong>&rsquo;s messages, contacts, calendar, tasks, and notes. This runs
176
176
  in the background and can take a few minutes — check back here for a download link once it&rsquo;s
177
- ready.
177
+ ready. Mail stored with zero-access encryption is exported encrypted: open it in a mail app that has your
178
+ exported keys (Settings &rsaquo; Encryption).
178
179
  </p>
179
180
 
180
181
  <form onSubmit={handleCreate} className="flex items-end gap-3 mb-5">