@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
|
@@ -26,21 +26,49 @@ import { fromBase64, toBase64 } from "../../../lib/crypto/encoding.js";
|
|
|
26
26
|
import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "../../../lib/crypto/smime.js";
|
|
27
27
|
import { getSearchIndexManifest, getSearchIndexSegment, listSearchIndexEntries, putSearchIndexSegment, } from "../../../lib/search/zeroAccessIndexApi.js";
|
|
28
28
|
import { ApiRequestError } from "../../../lib/util/api.js";
|
|
29
|
-
import {
|
|
29
|
+
import { clientPlatform, isConstrainedConnection } from "../../../lib/util/clientPlatform.js";
|
|
30
|
+
import { getMessage } from "../../../lib/mail/mailApi.js";
|
|
31
|
+
import { getIndexedVersions, getLocalCoverage, indexLocalEntities, moveLocalEntity } from "./localIndexRpcClient.js";
|
|
32
|
+
import { messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
|
|
33
|
+
import { forgetZeroAccessSync, zeroAccessSyncEpoch, zeroAccessSyncStateKey } from "./zeroAccessSyncState.js";
|
|
34
|
+
export { forgetZeroAccessSync };
|
|
30
35
|
/** The segment format this client writes and reads. */
|
|
31
36
|
export const SEGMENT_SCHEMA_VERSION = 1;
|
|
32
37
|
/** How far before its last pass the live tail asks again, so an entry committed late is never skipped. */
|
|
33
38
|
export const TAIL_OVERLAP_MS = 10 * 60 * 1000;
|
|
34
39
|
/** At most this many weeks are compacted per pass, so a large backlog is worked through over several passes. */
|
|
35
40
|
export const MAX_COMPACTIONS_PER_PASS = 4;
|
|
41
|
+
/** How long after a week's `compactableAt` each platform takes it (restapi `specs/search.md` §8 *Who compacts*): a computer first,
|
|
42
|
+
* a phone only when nothing else has for a day. */
|
|
43
|
+
export const COMPACTION_DELAY_MS = { desktop: 0, web: 6 * 3600000, mobile: 24 * 3600000 };
|
|
44
|
+
/** A mobile client declines a week whose pending entries and current segment together are larger than this. */
|
|
45
|
+
export const MOBILE_COMPACTION_MAX_BYTES = 5 * 1024 * 1024;
|
|
46
|
+
/**
|
|
47
|
+
* Whether this client should compact `week` now: it is compactable, has pending entries, and this platform's delay after its
|
|
48
|
+
* `compactableAt` has passed. A mobile client also declines on a cellular or data-saver connection, and a week larger than
|
|
49
|
+
* `MOBILE_COMPACTION_MAX_BYTES` with `segmentBytes` (its current segment's size) - or of unknown size, or from a server that
|
|
50
|
+
* doesn't say when it became compactable.
|
|
51
|
+
*/
|
|
52
|
+
export function shouldCompact(week, segmentBytes, platform = clientPlatform(), now = Date.now(), constrained = isConstrainedConnection()) {
|
|
53
|
+
if (!week.compactable || week.pendingEntries === 0) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const since = week.compactableAt ? Date.parse(week.compactableAt) : NaN;
|
|
57
|
+
if (platform === "mobile") {
|
|
58
|
+
if (constrained || Number.isNaN(since) || week.pendingBytes === undefined || week.pendingBytes + segmentBytes > MOBILE_COMPACTION_MAX_BYTES) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A server that doesn't say when the week became compactable: compactable is all there is to go on.
|
|
63
|
+
return Number.isNaN(since) || since + COMPACTION_DELAY_MS[platform] <= now;
|
|
64
|
+
}
|
|
36
65
|
/** Entities handed to the worker per call. */
|
|
37
66
|
const IMPORT_BATCH = 200;
|
|
38
67
|
/** How many times `uploadClientDocuments()` re-reads a week's client segment after another device replaced it first. */
|
|
39
68
|
export const CLIENT_UPLOAD_ATTEMPTS = 3;
|
|
40
|
-
const stateKey = (mailboxUid) => `rapidmx:za-index-sync:${mailboxUid}`;
|
|
41
69
|
function loadState(mailboxUid) {
|
|
42
70
|
try {
|
|
43
|
-
const raw = localStorage.getItem(
|
|
71
|
+
const raw = localStorage.getItem(zeroAccessSyncStateKey(mailboxUid));
|
|
44
72
|
const parsed = raw ? JSON.parse(raw) : undefined;
|
|
45
73
|
if (parsed && typeof parsed === "object" && typeof parsed.segments === "object") {
|
|
46
74
|
return parsed;
|
|
@@ -51,21 +79,17 @@ function loadState(mailboxUid) {
|
|
|
51
79
|
}
|
|
52
80
|
return { segments: {} };
|
|
53
81
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
82
|
+
/** Remembers `state` - unless the mailbox's local index was destroyed since the pass began (`epoch`, `zeroAccessSyncEpoch()` then): what
|
|
83
|
+
* the pass imported went with it, and a new index must not be taken for it. */
|
|
84
|
+
function saveState(mailboxUid, state, epoch) {
|
|
85
|
+
if (zeroAccessSyncEpoch(mailboxUid) !== epoch) {
|
|
86
|
+
return;
|
|
57
87
|
}
|
|
58
|
-
catch {
|
|
59
|
-
// Not remembered: the next pass imports again, which is idempotent (entities are upserted).
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/** Forgets what a pass remembered for `mailboxUid` (e.g. when its local index is destroyed). */
|
|
63
|
-
export function forgetZeroAccessSync(mailboxUid) {
|
|
64
88
|
try {
|
|
65
|
-
localStorage.
|
|
89
|
+
localStorage.setItem(zeroAccessSyncStateKey(mailboxUid), JSON.stringify(state));
|
|
66
90
|
}
|
|
67
91
|
catch {
|
|
68
|
-
//
|
|
92
|
+
// Not remembered: the next pass imports again, which is idempotent (entities are upserted).
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
95
|
async function transform(bytes, stream) {
|
|
@@ -87,8 +111,67 @@ function isDocument(value) {
|
|
|
87
111
|
const doc = value;
|
|
88
112
|
return !!doc && doc.v === 1 && typeof doc.messageUid === "string" && typeof doc.subject === "string" && typeof doc.body === "string";
|
|
89
113
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
114
|
+
/** At most this many messages' metadata are kept per mailbox for rows not imported yet; the oldest go first. */
|
|
115
|
+
export const MAX_REMEMBERED_METADATA = 50000;
|
|
116
|
+
/** Folder, version and flags the builder recorded for zero-access messages, by mailbox and message uid, until their row is imported. */
|
|
117
|
+
const rememberedMetadata = new Map();
|
|
118
|
+
/**
|
|
119
|
+
* Remembers zero-access message `messageUid`'s folder, version and flags for when its row is imported: the builder records them on the row
|
|
120
|
+
* as it walks, which does nothing for a row that isn't there yet (the index started empty - a new device, or after a lock - and this sync
|
|
121
|
+
* imports the rows after the build).
|
|
122
|
+
*/
|
|
123
|
+
export function rememberZeroAccessMetadata(mailboxUid, messageUid, metadata) {
|
|
124
|
+
let remembered = rememberedMetadata.get(mailboxUid);
|
|
125
|
+
if (!remembered) {
|
|
126
|
+
remembered = new Map();
|
|
127
|
+
rememberedMetadata.set(mailboxUid, remembered);
|
|
128
|
+
}
|
|
129
|
+
remembered.delete(messageUid);
|
|
130
|
+
remembered.set(messageUid, metadata);
|
|
131
|
+
if (remembered.size > MAX_REMEMBERED_METADATA) {
|
|
132
|
+
remembered.delete(remembered.keys().next().value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Records on the just-imported rows of `messageUids` what the builder remembered of them, once. */
|
|
136
|
+
async function applyRememberedMetadata(mailboxUid, messageUids) {
|
|
137
|
+
const remembered = rememberedMetadata.get(mailboxUid);
|
|
138
|
+
for (const uid of messageUids) {
|
|
139
|
+
const metadata = remembered?.get(uid);
|
|
140
|
+
if (metadata) {
|
|
141
|
+
await moveLocalEntity(mailboxUid, uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
|
|
142
|
+
remembered.delete(uid);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** At most this many live-tail rows a pass looks up (`recordTailMetadata()`); the rest get theirs from the next build pass. */
|
|
147
|
+
export const MAX_TAIL_LOOKUPS = 500;
|
|
148
|
+
/** How many of those lookups run at once. */
|
|
149
|
+
const TAIL_LOOKUP_CONCURRENCY = 4;
|
|
150
|
+
/**
|
|
151
|
+
* Gives the live tail's rows that nothing recorded yet (`docs`: rows the builder hasn't seen - mail that arrived after its walk) their
|
|
152
|
+
* folder, version and flags at import, from the message itself, so a folder or flag filter finds them before the next build pass. At
|
|
153
|
+
* most `MAX_TAIL_LOOKUPS`. Best effort: a message that is gone, moved to another mailbox or won't load is left to the builder.
|
|
154
|
+
*/
|
|
155
|
+
async function recordTailMetadata(mailboxUid, messageUids, client) {
|
|
156
|
+
const queue = messageUids.slice(0, MAX_TAIL_LOOKUPS);
|
|
157
|
+
const worker = async () => {
|
|
158
|
+
for (let uid = queue.shift(); uid !== undefined; uid = queue.shift()) {
|
|
159
|
+
try {
|
|
160
|
+
const message = await getMessage(uid, client);
|
|
161
|
+
if (message.mailboxUid === mailboxUid && message.folderUid) {
|
|
162
|
+
await moveLocalEntity(mailboxUid, uid, message.folderUid, messageRowVersion(message), messageRowFlags(message));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Left to the next build pass.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
await Promise.all(Array.from({ length: Math.min(TAIL_LOOKUP_CONCURRENCY, queue.length) }, worker));
|
|
171
|
+
}
|
|
172
|
+
/** A document as a row of the local index. Folder and flags aren't in the document - the builder records them (`localIndexBuilder.ts`,
|
|
173
|
+
* here through `rememberZeroAccessMetadata()` when the row came later; a live-tail row nothing recorded is looked up,
|
|
174
|
+
* `recordTailMetadata()`), and the upsert keeps what is already there. */
|
|
92
175
|
export function documentEntity(mailboxUid, doc) {
|
|
93
176
|
const participants = (doc.participants ?? []).join(" ");
|
|
94
177
|
const attachmentText = [...(doc.attachmentNames ?? []), ...(doc.attachmentText ?? [])].join("\n");
|
|
@@ -108,7 +191,9 @@ export function documentEntity(mailboxUid, doc) {
|
|
|
108
191
|
}
|
|
109
192
|
async function importDocuments(mailboxUid, docs) {
|
|
110
193
|
for (let i = 0; i < docs.length; i += IMPORT_BATCH) {
|
|
111
|
-
|
|
194
|
+
const batch = docs.slice(i, i + IMPORT_BATCH);
|
|
195
|
+
await indexLocalEntities(mailboxUid, batch.map((doc) => documentEntity(mailboxUid, doc)));
|
|
196
|
+
await applyRememberedMetadata(mailboxUid, batch.map((doc) => doc.messageUid));
|
|
112
197
|
}
|
|
113
198
|
}
|
|
114
199
|
/** The documents of a segment generation that pass `accept`, or `undefined` when it won't open or is a format this client doesn't read. */
|
|
@@ -174,9 +259,22 @@ export function isoWeekId(date) {
|
|
|
174
259
|
const week = Math.ceil(((day.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
175
260
|
return `${day.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
176
261
|
}
|
|
177
|
-
/**
|
|
262
|
+
/**
|
|
263
|
+
* The sync state, started over when the local index is empty (nothing remembered in it is there any more). What was stored is dropped then
|
|
264
|
+
* too: it describes an index that is gone (pruned, or discarded as corrupted, behind this module's back), and a later pass that found this
|
|
265
|
+
* index filled by the build's end-to-end mail would take it up and skip the segments it never got.
|
|
266
|
+
*/
|
|
178
267
|
async function currentState(mailboxUid) {
|
|
179
|
-
|
|
268
|
+
if ((await getLocalCoverage(mailboxUid)).indexedCount > 0) {
|
|
269
|
+
return loadState(mailboxUid);
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Nothing stored to drop.
|
|
276
|
+
}
|
|
277
|
+
return { segments: {} };
|
|
180
278
|
}
|
|
181
279
|
/**
|
|
182
280
|
* Imports the client segments of `mailboxUid` (end-to-end mail indexed by another device - see this module's comment) not yet imported in
|
|
@@ -188,8 +286,10 @@ export async function importClientSegments(mailboxUid, unlocked, client, { manif
|
|
|
188
286
|
if (!keys?.length) {
|
|
189
287
|
return 0;
|
|
190
288
|
}
|
|
191
|
-
const
|
|
289
|
+
const epoch = zeroAccessSyncEpoch(mailboxUid);
|
|
290
|
+
// Read first, while a new index is still empty: its stale state is dropped even when the manifest can't be read.
|
|
192
291
|
const state = await currentState(mailboxUid);
|
|
292
|
+
const segments = (manifest ?? (await getSearchIndexManifest(mailboxUid, client))).segments.filter((segment) => segment.origin === "client");
|
|
193
293
|
let imported = 0;
|
|
194
294
|
for (const segment of segments.sort((a, b) => b.weekId.localeCompare(a.weekId))) {
|
|
195
295
|
const key = `${segment.weekId}:${segment.origin}`;
|
|
@@ -204,7 +304,7 @@ export async function importClientSegments(mailboxUid, unlocked, client, { manif
|
|
|
204
304
|
await indexLocalEntities(mailboxUid, docs.slice(i, i + IMPORT_BATCH).map((doc) => clientDocumentEntity(mailboxUid, doc)), generation);
|
|
205
305
|
}
|
|
206
306
|
state.segments[key] = segment.etag;
|
|
207
|
-
saveState(mailboxUid, state);
|
|
307
|
+
saveState(mailboxUid, state, epoch);
|
|
208
308
|
imported++;
|
|
209
309
|
}
|
|
210
310
|
return imported;
|
|
@@ -224,8 +324,14 @@ export async function uploadClientDocuments(mailboxUid, unlocked, entities, clie
|
|
|
224
324
|
const weeks = new Map();
|
|
225
325
|
for (const entity of entities) {
|
|
226
326
|
const weekId = isoWeekId(new Date(entity.dateForSort));
|
|
227
|
-
|
|
327
|
+
let week = weeks.get(weekId);
|
|
328
|
+
if (!week) {
|
|
329
|
+
week = [];
|
|
330
|
+
weeks.set(weekId, week);
|
|
331
|
+
}
|
|
332
|
+
week.push(clientDocument(entity));
|
|
228
333
|
}
|
|
334
|
+
const epoch = zeroAccessSyncEpoch(mailboxUid);
|
|
229
335
|
const state = loadState(mailboxUid);
|
|
230
336
|
let written = 0;
|
|
231
337
|
let manifest;
|
|
@@ -255,7 +361,7 @@ export async function uploadClientDocuments(mailboxUid, unlocked, entities, clie
|
|
|
255
361
|
// This device already holds every document of the new generation only if it held the previous one.
|
|
256
362
|
if (current && state.segments[`${weekId}:client`] === current.etag) {
|
|
257
363
|
state.segments[`${weekId}:client`] = stored.etag;
|
|
258
|
-
saveState(mailboxUid, state);
|
|
364
|
+
saveState(mailboxUid, state, epoch);
|
|
259
365
|
}
|
|
260
366
|
written++;
|
|
261
367
|
break;
|
|
@@ -270,7 +376,7 @@ export async function uploadClientDocuments(mailboxUid, unlocked, entities, clie
|
|
|
270
376
|
}
|
|
271
377
|
return written;
|
|
272
378
|
}
|
|
273
|
-
/** Every pending entry of `week
|
|
379
|
+
/** Every pending entry of `week`, paged through. */
|
|
274
380
|
async function listAllEntries(mailboxUid, filter, client) {
|
|
275
381
|
const entries = [];
|
|
276
382
|
let after;
|
|
@@ -281,6 +387,22 @@ async function listAllEntries(mailboxUid, filter, client) {
|
|
|
281
387
|
} while (after);
|
|
282
388
|
return entries;
|
|
283
389
|
}
|
|
390
|
+
/** Per mailbox, the weeks a compaction found it can't fold on this device - a segment it can't read, or pending entries sealed to a key
|
|
391
|
+
* it doesn't hold (an older key a shared mailbox's member hasn't been given yet) - with how many entries were pending then, and the
|
|
392
|
+
* zero-access keys it had. Such a week stays pending, and the oldest weeks come first: without this, a few of them would take every pass's
|
|
393
|
+
* `MAX_COMPACTIONS_PER_PASS` and no other week would ever be compacted here. A week is tried again once its count changes (new entries,
|
|
394
|
+
* or another device folded it), and every week once this device's keys do. */
|
|
395
|
+
const stalled = new Map();
|
|
396
|
+
/** The weeks of `mailboxUid` this device found it can't fold with `keys` (see `stalled`). */
|
|
397
|
+
function stalledWeeks(mailboxUid, keys) {
|
|
398
|
+
const fingerprints = keys.map((key) => key.fingerprint).join(",");
|
|
399
|
+
let entry = stalled.get(mailboxUid);
|
|
400
|
+
if (entry?.keys !== fingerprints) {
|
|
401
|
+
entry = { keys: fingerprints, weeks: new Map() };
|
|
402
|
+
stalled.set(mailboxUid, entry);
|
|
403
|
+
}
|
|
404
|
+
return entry.weeks;
|
|
405
|
+
}
|
|
284
406
|
/** The documents of `entries` that open, with the uids of those entries; how many didn't. */
|
|
285
407
|
async function openEntries(entries, keys) {
|
|
286
408
|
const docs = [];
|
|
@@ -308,6 +430,7 @@ export async function syncZeroAccessIndex(mailboxUid, unlocked, client) {
|
|
|
308
430
|
if (!keys?.length) {
|
|
309
431
|
return result;
|
|
310
432
|
}
|
|
433
|
+
const epoch = zeroAccessSyncEpoch(mailboxUid);
|
|
311
434
|
const manifest = await getSearchIndexManifest(mailboxUid, client);
|
|
312
435
|
let state = await currentState(mailboxUid);
|
|
313
436
|
// Segments, newest week first.
|
|
@@ -325,34 +448,73 @@ export async function syncZeroAccessIndex(mailboxUid, unlocked, client) {
|
|
|
325
448
|
await importDocuments(mailboxUid, docs);
|
|
326
449
|
state.segments[key] = segment.etag;
|
|
327
450
|
result.segmentsImported++;
|
|
328
|
-
saveState(mailboxUid, state);
|
|
451
|
+
saveState(mailboxUid, state, epoch);
|
|
329
452
|
}
|
|
330
453
|
// End-to-end mail other devices indexed.
|
|
331
|
-
saveState(mailboxUid, state);
|
|
454
|
+
saveState(mailboxUid, state, epoch);
|
|
332
455
|
result.clientSegmentsImported = await importClientSegments(mailboxUid, unlocked, client, { manifest });
|
|
333
456
|
state = loadState(mailboxUid);
|
|
334
|
-
// The live tail
|
|
335
|
-
|
|
457
|
+
// The live tail, a page at a time: without a `since` (a new or emptied local index) it is every pending entry of the mailbox, which
|
|
458
|
+
// after zero-access is turned on can be every message it has - never held, opened, all at once. Each page imported is remembered
|
|
459
|
+
// (`tailCursor`), so a pass that ends early is taken up where it stopped rather than from the start.
|
|
336
460
|
const since = state.tailSince ? new Date(new Date(state.tailSince).getTime() - TAIL_OVERLAP_MS).toISOString() : undefined;
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
461
|
+
const resumed = state.tailCursor?.since === since ? state.tailCursor : undefined;
|
|
462
|
+
const tailStart = resumed?.start ?? new Date().toISOString();
|
|
463
|
+
// Rows nothing has recorded a folder and flags for: not on this device with a version yet (the builder's mark), nor remembered by it.
|
|
464
|
+
// Asked in batches - a backlog's tail can be every message of the mailbox, past what one SQL statement may bind - and only until there
|
|
465
|
+
// are as many as a pass looks up.
|
|
466
|
+
const remembered = rememberedMetadata.get(mailboxUid);
|
|
467
|
+
const asked = new Set();
|
|
468
|
+
const unrecorded = [];
|
|
469
|
+
let after = resumed?.after;
|
|
470
|
+
do {
|
|
471
|
+
const page = await listSearchIndexEntries(mailboxUid, { ...(since ? { since } : {}), ...(after ? { after } : {}) }, client);
|
|
472
|
+
const tail = await openEntries(page.entries, keys);
|
|
473
|
+
const tailUids = [...new Set(tail.docs.map((doc) => doc.messageUid))].filter((uid) => !asked.has(uid));
|
|
474
|
+
tailUids.forEach((uid) => asked.add(uid));
|
|
475
|
+
for (let i = 0; i < tailUids.length && unrecorded.length < MAX_TAIL_LOOKUPS; i += IMPORT_BATCH) {
|
|
476
|
+
const batch = tailUids.slice(i, i + IMPORT_BATCH);
|
|
477
|
+
const recorded = await getIndexedVersions(mailboxUid, batch);
|
|
478
|
+
unrecorded.push(...batch.filter((uid) => recorded[uid] === undefined && !remembered?.has(uid)));
|
|
479
|
+
}
|
|
480
|
+
await importDocuments(mailboxUid, tail.docs);
|
|
481
|
+
result.entriesImported += tail.docs.length;
|
|
482
|
+
result.unreadable += tail.unreadable;
|
|
483
|
+
after = page.next;
|
|
484
|
+
if (after) {
|
|
485
|
+
state.tailCursor = { ...(since ? { since } : {}), after, start: tailStart };
|
|
486
|
+
saveState(mailboxUid, state, epoch);
|
|
487
|
+
}
|
|
488
|
+
} while (after);
|
|
489
|
+
await recordTailMetadata(mailboxUid, unrecorded, client);
|
|
341
490
|
state.tailSince = tailStart;
|
|
342
|
-
|
|
343
|
-
|
|
491
|
+
delete state.tailCursor;
|
|
492
|
+
saveState(mailboxUid, state, epoch);
|
|
493
|
+
// Compaction of closed weeks, passing over those this device found it can't fold (`stalled`) while nothing has changed in them.
|
|
344
494
|
const active = keys[0];
|
|
345
|
-
|
|
346
|
-
|
|
495
|
+
const stalledHere = stalledWeeks(mailboxUid, keys);
|
|
496
|
+
const segmentOf = (weekId) => zeroAccessSegments.find((segment) => segment.weekId === weekId);
|
|
497
|
+
const weeks = manifest.weeks.filter((w) => stalledHere.get(w.weekId) !== w.pendingEntries && shouldCompact(w, segmentOf(w.weekId)?.bytes ?? 0));
|
|
498
|
+
for (const week of weeks.slice(0, MAX_COMPACTIONS_PER_PASS)) {
|
|
499
|
+
const current = segmentOf(week.weekId);
|
|
347
500
|
const previous = current ? await readSegment(mailboxUid, current, keys, client) : [];
|
|
348
501
|
if (!previous) {
|
|
349
502
|
// A generation this device can't read can't be folded into: leave the week to a device that can.
|
|
503
|
+
stalledHere.set(week.weekId, week.pendingEntries);
|
|
350
504
|
continue;
|
|
351
505
|
}
|
|
352
506
|
const pending = await openEntries(await listAllEntries(mailboxUid, { week: week.weekId }, client), keys);
|
|
353
507
|
if (pending.uids.length === 0) {
|
|
508
|
+
stalledHere.set(week.weekId, week.pendingEntries);
|
|
354
509
|
continue;
|
|
355
510
|
}
|
|
511
|
+
// What this device can't open stays pending once the rest is folded: the week is passed over until that count changes.
|
|
512
|
+
if (pending.unreadable > 0) {
|
|
513
|
+
stalledHere.set(week.weekId, pending.unreadable);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
stalledHere.delete(week.weekId);
|
|
517
|
+
}
|
|
356
518
|
const merged = new Map(previous.map((doc) => [doc.messageUid, doc]));
|
|
357
519
|
for (const doc of pending.docs) {
|
|
358
520
|
merged.set(doc.messageUid, doc);
|
|
@@ -368,7 +530,7 @@ export async function syncZeroAccessIndex(mailboxUid, unlocked, client) {
|
|
|
368
530
|
// This device already holds every document of the new generation.
|
|
369
531
|
await importDocuments(mailboxUid, pending.docs);
|
|
370
532
|
state.segments[`${stored.weekId}:${stored.origin}`] = stored.etag;
|
|
371
|
-
saveState(mailboxUid, state);
|
|
533
|
+
saveState(mailboxUid, state, epoch);
|
|
372
534
|
result.weeksCompacted++;
|
|
373
535
|
}
|
|
374
536
|
catch (err) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a zero-access sync pass (`zeroAccessIndexSync.ts`) remembers what it imported into a mailbox's local index - the segments it
|
|
3
|
+
* holds, the live tail's last read - and the forgetting of it when that index is destroyed (`localIndexRpcClient.ts`'s
|
|
4
|
+
* `destroyLocalIndex()`/`destroyAllLocalIndexes()`: a lock, sign-out; `pruneInaccessibleLocalIndexes()`; the Worker discarding a corrupted
|
|
5
|
+
* index). Apart from both so neither imports the other.
|
|
6
|
+
*
|
|
7
|
+
* What was remembered describes an index that is gone, so it must not outlive it - a new index the build fills first (decrypted
|
|
8
|
+
* end-to-end mail) would otherwise be taken for the old one, and the segments it never got skipped. A pass that started before the destroy
|
|
9
|
+
* must not write it back either: each pass reads `zeroAccessSyncEpoch()` when it starts and saves nothing once it changed.
|
|
10
|
+
*/
|
|
11
|
+
/** The localStorage key of `mailboxUid`'s sync state. */
|
|
12
|
+
export declare const zeroAccessSyncStateKey: (mailboxUid: string) => string;
|
|
13
|
+
/** Changes whenever `mailboxUid`'s sync state is forgotten. */
|
|
14
|
+
export declare function zeroAccessSyncEpoch(mailboxUid: string): string;
|
|
15
|
+
/** Forgets what a pass remembered for `mailboxUid` (its local index is destroyed). */
|
|
16
|
+
export declare function forgetZeroAccessSync(mailboxUid: string): void;
|
|
17
|
+
/** Forgets what passes remembered for every mailbox (every local index is destroyed). */
|
|
18
|
+
export declare function forgetAllZeroAccessSync(): void;
|
|
19
|
+
/** Forgets what passes remembered for every mailbox not in `keep` (their local indexes are pruned). */
|
|
20
|
+
export declare function forgetZeroAccessSyncExcept(keep: ReadonlySet<string>): void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Where a zero-access sync pass (`zeroAccessIndexSync.ts`) remembers what it imported into a mailbox's local index - the segments it
|
|
7
|
+
* holds, the live tail's last read - and the forgetting of it when that index is destroyed (`localIndexRpcClient.ts`'s
|
|
8
|
+
* `destroyLocalIndex()`/`destroyAllLocalIndexes()`: a lock, sign-out; `pruneInaccessibleLocalIndexes()`; the Worker discarding a corrupted
|
|
9
|
+
* index). Apart from both so neither imports the other.
|
|
10
|
+
*
|
|
11
|
+
* What was remembered describes an index that is gone, so it must not outlive it - a new index the build fills first (decrypted
|
|
12
|
+
* end-to-end mail) would otherwise be taken for the old one, and the segments it never got skipped. A pass that started before the destroy
|
|
13
|
+
* must not write it back either: each pass reads `zeroAccessSyncEpoch()` when it starts and saves nothing once it changed.
|
|
14
|
+
*/
|
|
15
|
+
const PREFIX = "rapidmx:za-index-sync:";
|
|
16
|
+
/** The localStorage key of `mailboxUid`'s sync state. */
|
|
17
|
+
export const zeroAccessSyncStateKey = (mailboxUid) => `${PREFIX}${mailboxUid}`;
|
|
18
|
+
const epochs = new Map();
|
|
19
|
+
let allEpoch = 0;
|
|
20
|
+
/** Changes whenever `mailboxUid`'s sync state is forgotten. */
|
|
21
|
+
export function zeroAccessSyncEpoch(mailboxUid) {
|
|
22
|
+
return `${allEpoch}:${epochs.get(mailboxUid) ?? 0}`;
|
|
23
|
+
}
|
|
24
|
+
/** Forgets what a pass remembered for `mailboxUid` (its local index is destroyed). */
|
|
25
|
+
export function forgetZeroAccessSync(mailboxUid) {
|
|
26
|
+
epochs.set(mailboxUid, (epochs.get(mailboxUid) ?? 0) + 1);
|
|
27
|
+
try {
|
|
28
|
+
localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Nothing to forget.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Forgets what passes remembered for every mailbox (every local index is destroyed). */
|
|
35
|
+
export function forgetAllZeroAccessSync() {
|
|
36
|
+
allEpoch++;
|
|
37
|
+
try {
|
|
38
|
+
storedMailboxUids().forEach((mailboxUid) => localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid)));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Nothing to forget.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Forgets what passes remembered for every mailbox not in `keep` (their local indexes are pruned). */
|
|
45
|
+
export function forgetZeroAccessSyncExcept(keep) {
|
|
46
|
+
let stored;
|
|
47
|
+
try {
|
|
48
|
+
stored = storedMailboxUids();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
stored.filter((mailboxUid) => !keep.has(mailboxUid)).forEach(forgetZeroAccessSync);
|
|
54
|
+
}
|
|
55
|
+
/** The mailboxes some sync state is stored for. Throws when storage can't be read. */
|
|
56
|
+
function storedMailboxUids() {
|
|
57
|
+
const uids = [];
|
|
58
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
59
|
+
const key = localStorage.key(i);
|
|
60
|
+
if (key?.startsWith(PREFIX)) {
|
|
61
|
+
uids.push(key.slice(PREFIX.length));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return uids;
|
|
65
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type SealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
3
|
+
/** A small lock beside a calendar event, contact or task whose details zero-access encryption protects (`sealedFields`) - nothing for
|
|
4
|
+
* one it doesn't. Never says "end-to-end": the server sees what is saved while it saves it (restapi `specs/search.md` §3). */
|
|
5
|
+
export default function ProtectedMark({ record }: {
|
|
6
|
+
record: SealedRecord;
|
|
7
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { isProtectedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
3
|
+
/** A small lock beside a calendar event, contact or task whose details zero-access encryption protects (`sealedFields`) - nothing for
|
|
4
|
+
* one it doesn't. Never says "end-to-end": the server sees what is saved while it saves it (restapi `specs/search.md` §3). */
|
|
5
|
+
export default function ProtectedMark({ record }) {
|
|
6
|
+
if (!isProtectedRecord(record)) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return (_jsx("span", { className: "ml-1 text-text-muted text-xs", role: "img", "aria-label": "Protected by zero-access encryption", title: "Protected by zero-access encryption", children: "\uD83D\uDD12" }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls `onLocked` with the mailbox whenever a mailbox's keys lock (the idle timeout, a lock from settings, signing out) - for a page that
|
|
3
|
+
* holds records it opened with them in its state: the opened-value caches are cleared on a lock, but a page's state is not, so it reloads
|
|
4
|
+
* (the reloaded records keep their placeholders) and closes what shows opened content. The latest `onLocked` is called; subscribed once.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useKeysLocked(onLocked: (mailboxUid: string) => void): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { subscribeKeySession } from "../../../lib/crypto/keySession.js";
|
|
7
|
+
/**
|
|
8
|
+
* Calls `onLocked` with the mailbox whenever a mailbox's keys lock (the idle timeout, a lock from settings, signing out) - for a page that
|
|
9
|
+
* holds records it opened with them in its state: the opened-value caches are cleared on a lock, but a page's state is not, so it reloads
|
|
10
|
+
* (the reloaded records keep their placeholders) and closes what shows opened content. The latest `onLocked` is called; subscribed once.
|
|
11
|
+
*/
|
|
12
|
+
export function useKeysLocked(onLocked) {
|
|
13
|
+
const latest = useRef(onLocked);
|
|
14
|
+
latest.current = onLocked;
|
|
15
|
+
useEffect(() => subscribeKeySession(({ mailboxUid, state }) => {
|
|
16
|
+
if (state === "locked") {
|
|
17
|
+
latest.current(mailboxUid);
|
|
18
|
+
}
|
|
19
|
+
}), []);
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
2
|
+
/**
|
|
3
|
+
* `record` with all its protected fields opened (`openSealedRecord()`), for a view that shows or edits every field - a list opens only
|
|
4
|
+
* what it shows. `ready` is false while a protected record is first being opened; `record` is the one given meanwhile, and the given one
|
|
5
|
+
* again when it can't be opened (the mailbox is locked).
|
|
6
|
+
*
|
|
7
|
+
* A new object for the same record (same `uid`) keeps the opened one ready while it is opened in turn, so a form over it isn't unmounted by
|
|
8
|
+
* a live update (its in-progress edits kept); the new one - its new `version` - takes over once opened. The record's mailbox locking drops
|
|
9
|
+
* what was opened at once (nothing opened outlives its key session) and opens it again, which gives the placeholders; its unlocking opens it
|
|
10
|
+
* again too.
|
|
11
|
+
*
|
|
12
|
+
* `open` opens it, `openSealedRecord()` unless a kind of record has its own full open (a calendar event's `fullyOpenedEvent()`, which keeps
|
|
13
|
+
* the title opened from its sealed content) - a function defined once, not per render, so the record isn't opened again on every render.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useOpenedRecord<T extends SealedRecord>(record: T | undefined, open?: (record: T) => Promise<T>): {
|
|
16
|
+
record: T | undefined;
|
|
17
|
+
ready: boolean;
|
|
18
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { subscribeKeySession } from "../../../lib/crypto/keySession.js";
|
|
7
|
+
import { isProtectedRecord, openSealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
|
|
8
|
+
/** The record's `uid`, when it has one - a new object for the same record (a live update, the page's own reload) is the same record. */
|
|
9
|
+
function uidOf(record) {
|
|
10
|
+
return record?.uid;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `record` with all its protected fields opened (`openSealedRecord()`), for a view that shows or edits every field - a list opens only
|
|
14
|
+
* what it shows. `ready` is false while a protected record is first being opened; `record` is the one given meanwhile, and the given one
|
|
15
|
+
* again when it can't be opened (the mailbox is locked).
|
|
16
|
+
*
|
|
17
|
+
* A new object for the same record (same `uid`) keeps the opened one ready while it is opened in turn, so a form over it isn't unmounted by
|
|
18
|
+
* a live update (its in-progress edits kept); the new one - its new `version` - takes over once opened. The record's mailbox locking drops
|
|
19
|
+
* what was opened at once (nothing opened outlives its key session) and opens it again, which gives the placeholders; its unlocking opens it
|
|
20
|
+
* again too.
|
|
21
|
+
*
|
|
22
|
+
* `open` opens it, `openSealedRecord()` unless a kind of record has its own full open (a calendar event's `fullyOpenedEvent()`, which keeps
|
|
23
|
+
* the title opened from its sealed content) - a function defined once, not per render, so the record isn't opened again on every render.
|
|
24
|
+
*/
|
|
25
|
+
export function useOpenedRecord(record, open = openSealedRecord) {
|
|
26
|
+
const [opened, setOpened] = useState(undefined);
|
|
27
|
+
const [generation, setGeneration] = useState(0);
|
|
28
|
+
const protectedRecord = isProtectedRecord(record);
|
|
29
|
+
const mailboxUid = record?.mailboxUid;
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!mailboxUid || !protectedRecord) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
return subscribeKeySession((event) => {
|
|
35
|
+
if (event.mailboxUid !== mailboxUid) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (event.state === "locked") {
|
|
39
|
+
setOpened(undefined);
|
|
40
|
+
}
|
|
41
|
+
setGeneration((current) => current + 1);
|
|
42
|
+
});
|
|
43
|
+
}, [mailboxUid, protectedRecord]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!record || !protectedRecord) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
void open(record).then((result) => {
|
|
50
|
+
if (!cancelled) {
|
|
51
|
+
setOpened({ from: record, record: result });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true;
|
|
56
|
+
};
|
|
57
|
+
}, [record, protectedRecord, generation, open]);
|
|
58
|
+
if (!record || !protectedRecord) {
|
|
59
|
+
return { record, ready: true };
|
|
60
|
+
}
|
|
61
|
+
if (opened?.from === record) {
|
|
62
|
+
return { record: opened.record, ready: true };
|
|
63
|
+
}
|
|
64
|
+
const uid = uidOf(record);
|
|
65
|
+
return opened && uid !== undefined && uidOf(opened.from) === uid ? { record: opened.record, ready: true } : { record, ready: false };
|
|
66
|
+
}
|
|
@@ -23,4 +23,4 @@ export type WwwShellProps = PropsWithChildren<Omit<AppShellProps, "active">>;
|
|
|
23
23
|
* app. Anything else - Mail's `/` and `/messages/:uid` - is Mail.
|
|
24
24
|
*/
|
|
25
25
|
export declare function activeAppOf(route: string): AppShellActive;
|
|
26
|
-
export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, }: WwwShellProps): React.JSX.Element;
|
|
26
|
+
export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, signOutHost, }: WwwShellProps): React.JSX.Element;
|
package/dist/apps/www/_shell.js
CHANGED
|
@@ -16,7 +16,7 @@ export function activeAppOf(route) {
|
|
|
16
16
|
const app = route.split("/")[1];
|
|
17
17
|
return app === "calendar" || app === "contacts" || app === "tasks" || app === "settings" ? app : "mail";
|
|
18
18
|
}
|
|
19
|
-
export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, }) {
|
|
19
|
+
export default function WwwShell({ children, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, trustedRoles, pluginNav, branding, appearance, signOutHost, }) {
|
|
20
20
|
const { route, pending } = useRouter();
|
|
21
21
|
const [takeovers, setTakeovers] = useState(0);
|
|
22
22
|
const enterTakeover = useCallback(() => {
|
|
@@ -27,5 +27,5 @@ export default function WwwShell({ children, userUid, authServerUrl, impersonati
|
|
|
27
27
|
// The content region is what a page load would have started at: focus it (it is only focusable by script), start at the top, and say the
|
|
28
28
|
// page's title - without the unread count that the tab's title carries in front of it.
|
|
29
29
|
useNavigationEffects({ focus: "#app-content", scroll: "top", announce: ({ title }) => titleWithUnread(title, 0) });
|
|
30
|
-
return (_jsx(AppFrameContext.Provider, { value: frame, children: _jsx(AppChrome, { active: activeAppOf(route), busy: pending, hideChrome: takeovers > 0, userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, branding: branding, appearance: appearance, children: children }) }));
|
|
30
|
+
return (_jsx(AppFrameContext.Provider, { value: frame, children: _jsx(AppChrome, { active: activeAppOf(route), busy: pending, hideChrome: takeovers > 0, userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, branding: branding, appearance: appearance, signOutHost: signOutHost, children: children }) }));
|
|
31
31
|
}
|