@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.
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +6 -3
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +61 -4
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- 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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 ===
|
|
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
|
}
|