@rapidmx/web-client 0.36.0 → 0.37.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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -5,6 +5,7 @@
5
5
  import React, { FormEvent, MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { format } from "date-fns";
7
7
  import { ApiClient, ApiRequestError } from "../../../../lib/util/api.js";
8
+ import { KeysLockedError } from "../../../../lib/crypto/masterKey.js";
8
9
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
9
10
  import {
10
11
  Attendee,
@@ -24,10 +25,14 @@ import {
24
25
  updateVideoMeeting,
25
26
  } from "../../../../lib/videoconf/videoMeetingsApi.js";
26
27
  import { detachOccurrence, saveEventSeries } from "../../../../lib/calendar/calendarMutations.js";
28
+ import { isProtectedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
29
+ import type { InvitationContent } from "../../../../lib/calendar/protectedEvents.js";
30
+ import { followMeetingKeyExpiry, meetingKeyExpiry, organizerJoinLink, protectMeeting, rememberMeetingKey } from "../../../../lib/videoconf/protectedMeetings.js";
27
31
  import { toDatetimeLocal } from "../../../../lib/util/dateInput.js";
28
32
  import { deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
29
33
  import { Mailbox } from "../../../../lib/mail/mailApi.js";
30
- import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
34
+ import { useKeysLocked } from "../../zeroAccess/useKeysLocked.js";
35
+ import { CalendarOccurrence, fromEventWallClock, seriesEndDate, toEventWallClock } from "../../../../lib/calendar/recurrence.js";
31
36
  import { addDaysToKey, allDayDateKey, allDayInstant, recurrenceUntilDateKey, recurrenceUntilInstant, startWeekdayCode } from "./allDay.js";
32
37
  import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
33
38
  import EventExpandedForm from "./EventExpandedForm.js";
@@ -56,6 +61,24 @@ const MAX_MEETING_TITLE_LENGTH = 200;
56
61
  * omitted (`undefined`) field is dropped by `JSON.stringify` and would leave the stored value in place. */
57
62
  type EventFields = { [K in keyof CalendarEventInput]?: CalendarEventInput[K] | null };
58
63
 
64
+ /** Who a video meeting minted for an event with `guests` invites: every attendee with an address but the organizer. */
65
+ function meetingInviteesOf(guests: Attendee[], organizerAddress: string): VideoMeetingInvitee[] {
66
+ const organizerAddressLower = organizerAddress.toLowerCase();
67
+ return guests
68
+ .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
69
+ .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
70
+ }
71
+
72
+ /** The content a save of `fields` invites with, for the server to send a protected event's invitations with. */
73
+ function invitationContentOfFields(fields: EventFields): InvitationContent {
74
+ return {
75
+ title: fields.title as string,
76
+ location: fields.location || undefined,
77
+ description: fields.description || undefined,
78
+ descriptionHtml: fields.descriptionHtml || undefined,
79
+ };
80
+ }
81
+
59
82
  const MS_PER_MINUTE = 60_000;
60
83
  const MS_PER_HOUR = 3_600_000;
61
84
  const MS_PER_DAY = 86_400_000;
@@ -261,16 +284,26 @@ export default function EventEditor({
261
284
  // `undefined` while the link is still being fetched, `null` once it is known there is none to open.
262
285
  const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
263
286
  const [videoError, setVideoError] = useState<string | null>(null);
287
+ /** Why the meeting just made isn't protected end to end - shown beside the toggle, not as an error. */
288
+ const [videoNotice, setVideoNotice] = useState<string | null>(null);
264
289
  // The event as this modal last persisted it. Set only when a save got the event stored but could not
265
290
  // finish the video-conferencing step after it: the modal then stays open on that error, and the retry
266
291
  // must update *that* record (its uid and its new version), not re-create it or reuse the stale prop.
267
292
  const [savedEvent, setSavedEvent] = useState<CalendarEvent | null>(null);
293
+ // `savedEvent` as of the last render, for sending deferred invitations when the dialog goes away (`flushDeferredInvitations()`).
294
+ const savedEventRef = useRef<CalendarEvent | null>(null);
295
+ savedEventRef.current = savedEvent;
268
296
  // Whether that stored record is a series saved with `toSeriesFields()`: its master's dates are not the form's (the occurrence's), so a retry
269
297
  // must not send them - the first save already applied any change of time.
270
298
  const savedAsSeries = useRef(false);
271
299
  // A meeting minted for this event whose link onto the event could not be saved. A retry reuses it rather than minting a second one,
272
300
  // which would send the invitees a second set of join links and leave this one orphaned.
273
301
  const mintedMeeting = useRef<{ uid: string; organizerJoinUrl: string | null } | null>(null);
302
+ // The content of a new event whose invitations its create deferred (`invitationContent: { defer: true }`) to the save that links its
303
+ // video meeting, so attendees get one invitation, with the link - until a save has sent them. That save carries this content too, for
304
+ // a protected event this device couldn't open; and should the meeting never be linked, a retry without video, or closing the dialog
305
+ // (`flushDeferredInvitations()`), sends them without it - best effort, since the dialog can also be left in ways that don't pass here.
306
+ const deferredInvitations = useRef<InvitationContent | null>(null);
274
307
  const [editScope, setEditScope] = useState<EditScope>("occurrence");
275
308
  const [error, setError] = useState<string | null>(null);
276
309
  const [saving, setSaving] = useState(false);
@@ -288,7 +321,7 @@ export default function EventEditor({
288
321
  }
289
322
  let cancelled = false;
290
323
  void getVideoMeeting(uid, client)
291
- .then((meeting) => meeting.organizerJoinUrl ?? null)
324
+ .then(organizerJoinLink)
292
325
  .catch(() => null)
293
326
  .then((url) => {
294
327
  if (!cancelled) {
@@ -300,6 +333,16 @@ export default function EventEditor({
300
333
  };
301
334
  }, [occurrence?.videoMeetingUid, client]);
302
335
 
336
+ // A protected meeting's links carry its key after `#`, opened (or made) with the organizer's mailbox keys: they go when any mailbox locks
337
+ // - this dialog can be saving to another mailbox than the event's. A meeting made here but not yet linked keeps its uid, so a retry
338
+ // doesn't make a second one; it is linked without a link to show.
339
+ useKeysLocked(() => {
340
+ setOrganizerJoinUrl((url) => (url?.includes("#") ? null : url));
341
+ if (mintedMeeting.current?.organizerJoinUrl?.includes("#")) {
342
+ mintedMeeting.current = { uid: mintedMeeting.current.uid, organizerJoinUrl: null };
343
+ }
344
+ });
345
+
303
346
  // "This event only" detaches a standalone, non-repeating copy - the series' rule doesn't apply to it.
304
347
  const editingSingleOccurrence = !!occurrence?.isRecurringOccurrence && editScope === "occurrence";
305
348
 
@@ -422,10 +465,16 @@ export default function EventEditor({
422
465
  *
423
466
  * Never throws: the event is already stored by the time this runs, so a failure here is reported inline
424
467
  * next to the checkbox and left retryable, with `videoMeetingUid` still whatever it was before.
468
+ *
469
+ * `rescheduled`: the save changed the event's times or recurrence. A protected meeting's key is discarded an hour after the end it
470
+ * was made for, so a linked meeting's key expiry then follows the saved event's (series') new end - best effort.
425
471
  */
426
- async function applyVideoConferencing(saved: CalendarEvent, guests: Attendee[]): Promise<{ event: CalendarEvent; failed: boolean }> {
427
- // Off and never on, or on and already linked (see the limitation above) - nothing to do either way.
472
+ async function applyVideoConferencing(saved: CalendarEvent, guests: Attendee[], rescheduled: boolean): Promise<{ event: CalendarEvent; failed: boolean }> {
473
+ // Off and never on, or on and already linked (see the limitation above) - nothing to do either way, but for the key expiry.
428
474
  if (videoEnabled === !!videoMeetingUid) {
475
+ if (videoEnabled && rescheduled && saved.videoMeetingUid) {
476
+ await followMeetingKeyExpiry(saved.videoMeetingUid, seriesEndDate(saved), client);
477
+ }
429
478
  return { event: saved, failed: false };
430
479
  }
431
480
  // Detaching a single occurrence (`detachOccurrence()`, react-shared's `calendarMutations.ts`)
@@ -444,28 +493,33 @@ export default function EventEditor({
444
493
  }
445
494
  try {
446
495
  if (videoEnabled) {
447
- const organizerAddressLower = saved.organizer.address.toLowerCase();
448
- const invitees: VideoMeetingInvitee[] = guests
449
- .filter((a) => !a.isOrganizer && !!a.address.trim() && a.address.trim().toLowerCase() !== organizerAddressLower)
450
- .map((a) => ({ email: a.address.trim(), displayName: a.displayName }));
496
+ const invitees: VideoMeetingInvitee[] = meetingInviteesOf(guests, saved.organizer.address);
451
497
  if (invitees.length === 0) {
452
498
  setVideoError("Add at least one attendee other than yourself to add video conferencing.");
453
499
  return { event: saved, failed: true };
454
500
  }
455
501
  if (!mintedMeeting.current) {
502
+ // Protected end to end when the organizer's mailbox has zero-access: its title and names sealed, its key in every link.
503
+ const title = saved.title.slice(0, MAX_MEETING_TITLE_LENGTH);
504
+ const secured = await protectMeeting(saved.mailboxUid, title, invitees, meetingKeyExpiry(seriesEndDate(saved)));
456
505
  const result = await createVideoMeeting(
457
506
  {
458
507
  mailboxUid: saved.mailboxUid,
459
- title: saved.title.slice(0, MAX_MEETING_TITLE_LENGTH),
508
+ ...(secured
509
+ ? { protection: secured.protection, invitees: secured.invitees }
510
+ : // Stored readable: a protected event's real title never goes there.
511
+ { title: isProtectedRecord(saved) ? "Meeting" : title, invitees }),
460
512
  visibility: "private",
461
513
  calendarEventUid: saved.uid,
462
514
  startTime: saved.startDate,
463
515
  endTime: saved.endDate,
464
- invitees,
465
516
  },
466
517
  client,
467
518
  );
468
- mintedMeeting.current = { uid: result.meeting.uid, organizerJoinUrl: result.organizerJoinUrl ?? null };
519
+ rememberMeetingKey(result.meeting.uid, secured?.fragment ?? null, saved.mailboxUid);
520
+ setVideoNotice(secured ? null : "This meeting isn't end-to-end protected because zero-access encryption is off (or locked) for your mailbox.");
521
+ const organizerUrl = result.organizerJoinUrl ?? null;
522
+ mintedMeeting.current = { uid: result.meeting.uid, organizerJoinUrl: organizerUrl && secured ? `${organizerUrl}#${secured.fragment}` : organizerUrl };
469
523
  }
470
524
  const meeting = mintedMeeting.current;
471
525
  const patched = await updateCalendarEvent(
@@ -474,10 +528,14 @@ export default function EventEditor({
474
528
  version: saved.version,
475
529
  location: VIDEO_LOCATION_PLACEHOLDER,
476
530
  videoMeetingUid: meeting.uid,
531
+ // The invitations the create deferred go out with this save.
532
+ ...(deferredInvitations.current ? { invitationContent: { ...deferredInvitations.current, location: VIDEO_LOCATION_PLACEHOLDER } } : {}),
477
533
  },
478
534
  client,
535
+ saved,
479
536
  );
480
537
  mintedMeeting.current = null;
538
+ deferredInvitations.current = null;
481
539
  setVideoMeetingUid(meeting.uid);
482
540
  setOrganizerJoinUrl(meeting.organizerJoinUrl);
483
541
  update({ location: VIDEO_LOCATION_PLACEHOLDER });
@@ -488,7 +546,7 @@ export default function EventEditor({
488
546
  // A stored event clears a field with an explicit `null` (see `EventFields`) - an omitted one would
489
547
  // leave the placeholder (and the link to the now-cancelled meeting) in place.
490
548
  const patch: EventFields = { location: restoredLocation || null, videoMeetingUid: null };
491
- const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) }, client);
549
+ const patched = await updateCalendarEvent({ uid: saved.uid, version: saved.version, ...(patch as Partial<CalendarEventInput>) }, client, saved);
492
550
  setVideoMeetingUid(undefined);
493
551
  setOrganizerJoinUrl(undefined);
494
552
  update({ location: restoredLocation });
@@ -583,6 +641,9 @@ export default function EventEditor({
583
641
  autoReplyMessage: values.autoReplyEnabled ? values.autoReplyMessage : cleared,
584
642
  };
585
643
 
644
+ // A save that will link a new video meeting to the event right after. A new event's invitations wait for that save (restapi sends a
645
+ // protected one's at save time), so they go out once, with the link and its keys.
646
+ const willLinkMeeting = videoEnabled && !videoMeetingUid && meetingInviteesOf(merged.attendees, effectiveOrganizerAddress).length > 0;
586
647
  setSaving(true);
587
648
  try {
588
649
  let saved: CalendarEvent;
@@ -601,7 +662,20 @@ export default function EventEditor({
601
662
  delete retryFields.timezone;
602
663
  delete retryFields.allDay;
603
664
  }
604
- saved = await updateCalendarEvent({ uid: savedEvent.uid, version: savedEvent.version, ...(retryFields as Partial<CalendarEventInput>) }, client);
665
+ // A create's deferred invitations still wait for the meeting's link; without one to link, this save sends them.
666
+ const deferred = deferredInvitations.current;
667
+ if (deferred) {
668
+ deferredInvitations.current = invitationContentOfFields(fields);
669
+ retryFields.invitationContent = willLinkMeeting ? { defer: true } : deferredInvitations.current;
670
+ }
671
+ saved = await updateCalendarEvent(
672
+ { uid: savedEvent.uid, version: savedEvent.version, ...(retryFields as Partial<CalendarEventInput>) },
673
+ client,
674
+ savedEvent,
675
+ );
676
+ if (deferred && !willLinkMeeting) {
677
+ deferredInvitations.current = null;
678
+ }
605
679
  } else if (!occurrence) {
606
680
  // The organizer is only ever set on create - an edit keeps the event's own organizer.
607
681
  saved = await createCalendarEvent(
@@ -610,9 +684,11 @@ export default function EventEditor({
610
684
  folderUid: values.targetFolderUid,
611
685
  ...fields,
612
686
  organizer: { address: effectiveOrganizerAddress, type: "to" },
687
+ ...(willLinkMeeting ? { invitationContent: { defer: true } } : {}),
613
688
  } as CalendarEventInput,
614
689
  client,
615
690
  );
691
+ deferredInvitations.current = willLinkMeeting ? invitationContentOfFields(fields) : null;
616
692
  } else if (editingSingleOccurrence) {
617
693
  saved = await detachOccurrence(occurrence, fields as Partial<CalendarEventInput>, client);
618
694
  } else if (occurrence.isRecurringOccurrence) {
@@ -625,9 +701,16 @@ export default function EventEditor({
625
701
  savedAsSeries.current = true;
626
702
  detachedSyncFailed = !!series.detachedOccurrenceSyncFailed;
627
703
  } else {
628
- saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) }, client);
704
+ saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...(fields as Partial<CalendarEventInput>) }, client, occurrence);
629
705
  }
630
- const video = await applyVideoConferencing(saved, merged.attendees);
706
+ // Against the record this save started from: a false positive only costs a look at the meeting.
707
+ const before = savedEvent ?? occurrence;
708
+ const rescheduled =
709
+ !!before &&
710
+ (!sameMinute(before.startDate, startDate) ||
711
+ !sameMinute(before.endDate, endDate) ||
712
+ JSON.stringify(before.recurrenceRule ?? null) !== JSON.stringify(fields.recurrenceRule ?? null));
713
+ const video = await applyVideoConferencing(saved, merged.attendees, rescheduled);
631
714
  if (video.failed) {
632
715
  // The event itself is saved - keep the modal open on the inline error so the video meeting
633
716
  // can be retried (or the toggle put back) rather than closing over it.
@@ -641,12 +724,35 @@ export default function EventEditor({
641
724
  }
642
725
  onSaved();
643
726
  } catch (err) {
644
- setError(err instanceof ApiRequestError ? err.message : "Could not save this event.");
727
+ // `KeysLockedError`: one occurrence of a protected event, detached while the mailbox is locked (nothing was written).
728
+ setError(err instanceof ApiRequestError || err instanceof KeysLockedError ? err.message : "Could not save this event.");
645
729
  } finally {
646
730
  setSaving(false);
647
731
  }
648
732
  }
649
733
 
734
+ /** Sends the invitations a create deferred when the dialog is closed before a save sent them (the video meeting couldn't be linked):
735
+ * a save of no change, carrying their content. Best effort - the event is stored either way, and a failure leaves them pending until
736
+ * the event is saved again. Only a protected event's are sent at save time; restapi's scheduling job sends any other's. */
737
+ async function flushDeferredInvitations(): Promise<void> {
738
+ const content = deferredInvitations.current;
739
+ const stored = savedEventRef.current;
740
+ deferredInvitations.current = null;
741
+ if (!content || !stored || !isProtectedRecord(stored)) {
742
+ return;
743
+ }
744
+ try {
745
+ await updateCalendarEvent({ uid: stored.uid, version: stored.version, invitationContent: content }, client, stored);
746
+ } catch {
747
+ // Left pending: the next save of the event sends them.
748
+ }
749
+ }
750
+ // However the dialog goes away - its own close button waits for this, an Escape or a click outside a new event's popover doesn't pass
751
+ // through it - deferred invitations still pending are sent.
752
+ const flushOnUnmount = useRef(flushDeferredInvitations);
753
+ flushOnUnmount.current = flushDeferredInvitations;
754
+ useEffect(() => () => void flushOnUnmount.current(), []);
755
+
650
756
  // Same scheme allow-list `calendarReminders.ts#joinMeetingUrl()` already applies to a reminder's own
651
757
  // `location` field - reused here rather than duplicated so a video-conferencing plugin (or a stray
652
758
  // manual edit of `videoMeetingUid`'s otherwise-plugin-owned join link) can't smuggle a `javascript:`/
@@ -684,6 +790,7 @@ export default function EventEditor({
684
790
  joinUrl,
685
791
  organizerJoinUrl,
686
792
  videoError,
793
+ videoNotice,
687
794
  editScope,
688
795
  setEditScope,
689
796
  showEditScope: !!occurrence?.isRecurringOccurrence,
@@ -691,7 +798,7 @@ export default function EventEditor({
691
798
  saving,
692
799
  onSubmit: (event) => void handleSubmit(event),
693
800
  // Once something was stored, leaving is a save as far as the calendar behind is concerned: it has to reload.
694
- onCancel: () => (savedEvent ? onSaved() : onCancel()),
801
+ onCancel: () => (savedEvent ? void flushDeferredInvitations().then(onSaved) : onCancel()),
695
802
  };
696
803
 
697
804
  // A new event has the tabs, whose Event face is the quick popover or the full card; an existing event is always the full card.
@@ -86,6 +86,7 @@ export function VideoConferencingRow({ c }: { c: EventFormController }) {
86
86
  {c.videoError}
87
87
  </p>
88
88
  )}
89
+ {c.videoNotice && <p className="text-xs text-text-muted mb-1">{c.videoNotice}</p>}
89
90
  {c.videoMeetingUid && (
90
91
  <div className="flex items-center gap-3 mb-1">
91
92
  <Button
@@ -14,7 +14,10 @@ import EventEditor from "./EventEditor.js";
14
14
  import EventMatchNav from "./EventMatchNav.js";
15
15
  import EventShell, { EventAnchor, EventShellVariant } from "./EventShell.js";
16
16
  import SlideIn, { SlideFrom } from "./SlideIn.js";
17
+ import { isOpenedRecord, isProtectedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
18
+ import { useOpenedRecord } from "../../zeroAccess/useOpenedRecord.js";
17
19
  import { QuickTab } from "./QuickCreateTabs.js";
20
+ import { fullyOpenedEvent } from "../../../../lib/calendar/calendarApi.js";
18
21
 
19
22
  export { VIDEO_LOCATION_PLACEHOLDER } from "./EventEditor.js";
20
23
  export type { EventAnchor } from "./EventShell.js";
@@ -101,7 +104,7 @@ export default function EventModal({
101
104
  folderColors,
102
105
  organizerAddress,
103
106
  organizerAliases,
104
- occurrence,
107
+ occurrence: listedOccurrence,
105
108
  initialStart,
106
109
  initialEnd,
107
110
  initialAllDay,
@@ -123,11 +126,25 @@ export default function EventModal({
123
126
  // Escape or the close button on a form that holds something asks before throwing it away.
124
127
  const [confirmingDiscard, setConfirmingDiscard] = useState(false);
125
128
  // An event on a calendar shared with the reader view-only cannot be changed, deleted or answered.
126
- const canWrite = useMailboxUpdateAccess(mailboxOptions?.find((option) => option.mailbox.uid === occurrence?.mailboxUid)?.mailbox);
129
+ const canWrite = useMailboxUpdateAccess(mailboxOptions?.find((option) => option.mailbox.uid === listedOccurrence?.mailboxUid)?.mailbox);
130
+ // A protected event from a list has only what the views draw opened (`EVENT_LIST_FIELDS`): the card shows and the form edits all of it, so
131
+ // the rest is opened first - a form seeded from a placeholder description would save it over the real one. Opened as a write opens it
132
+ // (`fullyOpenedEvent()`): the title opened from its sealed content stays, never replaced by an older per-field one the card, a reply and
133
+ // a save would then carry.
134
+ const partlyOpened = !!listedOccurrence && isProtectedRecord(listedOccurrence) && !isOpenedRecord(listedOccurrence);
135
+ const opening = useOpenedRecord(partlyOpened ? listedOccurrence : undefined, fullyOpenedEvent);
136
+ const occurrence: CalendarOccurrence | null = partlyOpened ? opening.record! : listedOccurrence;
127
137
 
128
138
  if (!open) {
129
139
  return null;
130
140
  }
141
+ if (partlyOpened && !opening.ready) {
142
+ return (
143
+ <EventShell variant="card" label="Event details" focusKey="opening" anchor={anchor} width={DETAILS_WIDTH} onClose={onClose}>
144
+ <p className="text-sm text-text-muted p-4">Opening this event…</p>
145
+ </EventShell>
146
+ );
147
+ }
131
148
 
132
149
  if (syncWarning) {
133
150
  return (
@@ -5,6 +5,7 @@
5
5
  import React, { FormEvent, useState } from "react";
6
6
  import { HiOutlineXMark } from "react-icons/hi2";
7
7
  import { Attendee, EventChangeRequest, requestEventChange } from "../../../../lib/calendar/calendarApi.js";
8
+ import type { InvitationContent } from "../../../../lib/calendar/protectedEvents.js";
8
9
  import { htmlToPlainText, sanitizeEventDescriptionHtml } from "../../../../lib/calendar/eventDescription.js";
9
10
  import Alert from "../../../../lib/components/feedback/Alert.js";
10
11
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -34,6 +35,8 @@ export interface ChangeSubject {
34
35
  descriptionHtml?: string | null;
35
36
  /** Everyone already invited, whatever the case, so a guest is not added twice. */
36
37
  guestAddresses: string[];
38
+ /** The event's real title, location and description, for the server to name it by when its stored copy is protected (`invitationContentOf()`). */
39
+ invitationContent?: InvitationContent;
37
40
  }
38
41
 
39
42
  export interface RequestChangeFormProps {
@@ -147,7 +150,7 @@ export default function RequestChangeForm({ subject, canChange, canInvite, onSen
147
150
 
148
151
  setSending(true);
149
152
  try {
150
- await requestEventChange(subject.uid, request, client);
153
+ await requestEventChange(subject.uid, request, client, subject.invitationContent);
151
154
  notify({
152
155
  kind: "success",
153
156
  title: "Your change was sent to the organizer",
@@ -93,6 +93,8 @@ export interface EventFormController {
93
93
  /** `undefined` while it is being fetched, `null` once it is known there is none. */
94
94
  organizerJoinUrl: string | null | undefined;
95
95
  videoError: string | null;
96
+ /** Why the video meeting isn't protected end to end, when it isn't. */
97
+ videoNotice?: string | null;
96
98
 
97
99
  // The form as a whole.
98
100
  editScope: EditScope;
@@ -37,6 +37,9 @@ import {
37
37
  scopeMailboxes,
38
38
  } from "./contactCardData.js";
39
39
  import { type Participant, contactInputFor, isExternalAddress, ownDomains, participantName } from "./participantDetails.js";
40
+ import { openSealedRecord } from "../../../../lib/zeroAccess/sealedRecords.js";
41
+ import { isSealedRecord } from "../../../../lib/zeroAccess/sealedMoves.js";
42
+ import { useKeysLocked } from "../../zeroAccess/useKeysLocked.js";
40
43
 
41
44
  type Section<T> = { status: "loading" } | { status: "error" } | { status: "ready"; data: T };
42
45
 
@@ -220,12 +223,21 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
220
223
  }
221
224
  }
222
225
 
226
+ // The contact and the upcoming events are shown opened: a lock of the mailbox whose sealed content they hold takes the card off screen,
227
+ // as the calendar page closes a protected event's card - its state would keep what the lock cleared everywhere else.
228
+ useKeysLocked((locked) => {
229
+ const shown = [stored, ...(events.status === "ready" ? events.data : [])];
230
+ if (shown.some((record) => record?.mailboxUid === locked && isSealedRecord(record))) {
231
+ onClose();
232
+ }
233
+ });
234
+
223
235
  // Not while a picture is being changed: that goes against the version the star would replace.
224
236
  const [photoBusy, setPhotoBusy] = useState(false);
225
237
 
226
238
  async function handleToggleFavorite(current: Contact) {
227
239
  try {
228
- const next = await setContactFavorite(current, !current.favorite, client);
240
+ const next = await openSealedRecord(await setContactFavorite(current, !current.favorite, client));
229
241
  if (mounted.current) {
230
242
  setChanged(next);
231
243
  }