@rapidmx/web-client 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -29,6 +29,7 @@ import {
29
29
  getSearchIndexManifest,
30
30
  getSearchIndexSegment,
31
31
  type IndexSegment,
32
+ type IndexWeekEntries,
32
33
  listSearchIndexEntries,
33
34
  type SegmentManifest,
34
35
  putSearchIndexSegment,
@@ -36,8 +37,13 @@ import {
36
37
  type ZeroAccessIndexDocument,
37
38
  } from "../../../lib/search/zeroAccessIndexApi.js";
38
39
  import { ApiRequestError, type ApiClient } from "../../../lib/util/api.js";
39
- import { getLocalCoverage, indexLocalEntities } from "./localIndexRpcClient.js";
40
- import type { LocalIndexEntity } from "./localIndexSchema.js";
40
+ import { clientPlatform, isConstrainedConnection, type ClientPlatform } from "../../../lib/util/clientPlatform.js";
41
+ import { getMessage } from "../../../lib/mail/mailApi.js";
42
+ import { getIndexedVersions, getLocalCoverage, indexLocalEntities, moveLocalEntity } from "./localIndexRpcClient.js";
43
+ import { type LocalIndexEntity, messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
44
+ import { forgetZeroAccessSync, zeroAccessSyncEpoch, zeroAccessSyncStateKey } from "./zeroAccessSyncState.js";
45
+
46
+ export { forgetZeroAccessSync };
41
47
 
42
48
  /** The segment format this client writes and reads. */
43
49
  export const SEGMENT_SCHEMA_VERSION = 1;
@@ -48,6 +54,39 @@ export const TAIL_OVERLAP_MS = 10 * 60 * 1000;
48
54
  /** At most this many weeks are compacted per pass, so a large backlog is worked through over several passes. */
49
55
  export const MAX_COMPACTIONS_PER_PASS = 4;
50
56
 
57
+ /** How long after a week's `compactableAt` each platform takes it (restapi `specs/search.md` §8 *Who compacts*): a computer first,
58
+ * a phone only when nothing else has for a day. */
59
+ export const COMPACTION_DELAY_MS: Record<ClientPlatform, number> = { desktop: 0, web: 6 * 3_600_000, mobile: 24 * 3_600_000 };
60
+
61
+ /** A mobile client declines a week whose pending entries and current segment together are larger than this. */
62
+ export const MOBILE_COMPACTION_MAX_BYTES = 5 * 1024 * 1024;
63
+
64
+ /**
65
+ * Whether this client should compact `week` now: it is compactable, has pending entries, and this platform's delay after its
66
+ * `compactableAt` has passed. A mobile client also declines on a cellular or data-saver connection, and a week larger than
67
+ * `MOBILE_COMPACTION_MAX_BYTES` with `segmentBytes` (its current segment's size) - or of unknown size, or from a server that
68
+ * doesn't say when it became compactable.
69
+ */
70
+ export function shouldCompact(
71
+ week: IndexWeekEntries,
72
+ segmentBytes: number,
73
+ platform: ClientPlatform = clientPlatform(),
74
+ now: number = Date.now(),
75
+ constrained: boolean = isConstrainedConnection(),
76
+ ): boolean {
77
+ if (!week.compactable || week.pendingEntries === 0) {
78
+ return false;
79
+ }
80
+ const since = week.compactableAt ? Date.parse(week.compactableAt) : NaN;
81
+ if (platform === "mobile") {
82
+ if (constrained || Number.isNaN(since) || week.pendingBytes === undefined || week.pendingBytes + segmentBytes > MOBILE_COMPACTION_MAX_BYTES) {
83
+ return false;
84
+ }
85
+ }
86
+ // A server that doesn't say when the week became compactable: compactable is all there is to go on.
87
+ return Number.isNaN(since) || since + COMPACTION_DELAY_MS[platform] <= now;
88
+ }
89
+
51
90
  /** Entities handed to the worker per call. */
52
91
  const IMPORT_BATCH = 200;
53
92
 
@@ -61,6 +100,10 @@ interface SyncState {
61
100
  segments: Record<string, string>;
62
101
  /** ISO 8601: when the last live-tail pass started. */
63
102
  tailSince?: string;
103
+ /** Where a live-tail pass that ended early stopped: the listing it was paging through (`since`, absent for a whole one), the page
104
+ * after the last one it imported, and when it started - which the pass that finishes it records as `tailSince`, so nothing written
105
+ * while it was interrupted is skipped. */
106
+ tailCursor?: { since?: string; after: string; start: string };
64
107
  }
65
108
 
66
109
  /** What a pass did, for the caller's logging and tests. */
@@ -74,11 +117,9 @@ export interface ZeroAccessSyncResult {
74
117
  unreadable: number;
75
118
  }
76
119
 
77
- const stateKey = (mailboxUid: string): string => `rapidmx:za-index-sync:${mailboxUid}`;
78
-
79
120
  function loadState(mailboxUid: string): SyncState {
80
121
  try {
81
- const raw = localStorage.getItem(stateKey(mailboxUid));
122
+ const raw = localStorage.getItem(zeroAccessSyncStateKey(mailboxUid));
82
123
  const parsed: unknown = raw ? JSON.parse(raw) : undefined;
83
124
  if (parsed && typeof parsed === "object" && typeof (parsed as SyncState).segments === "object") {
84
125
  return parsed as SyncState;
@@ -89,20 +130,16 @@ function loadState(mailboxUid: string): SyncState {
89
130
  return { segments: {} };
90
131
  }
91
132
 
92
- function saveState(mailboxUid: string, state: SyncState): void {
93
- try {
94
- localStorage.setItem(stateKey(mailboxUid), JSON.stringify(state));
95
- } catch {
96
- // Not remembered: the next pass imports again, which is idempotent (entities are upserted).
133
+ /** Remembers `state` - unless the mailbox's local index was destroyed since the pass began (`epoch`, `zeroAccessSyncEpoch()` then): what
134
+ * the pass imported went with it, and a new index must not be taken for it. */
135
+ function saveState(mailboxUid: string, state: SyncState, epoch: string): void {
136
+ if (zeroAccessSyncEpoch(mailboxUid) !== epoch) {
137
+ return;
97
138
  }
98
- }
99
-
100
- /** Forgets what a pass remembered for `mailboxUid` (e.g. when its local index is destroyed). */
101
- export function forgetZeroAccessSync(mailboxUid: string): void {
102
139
  try {
103
- localStorage.removeItem(stateKey(mailboxUid));
140
+ localStorage.setItem(zeroAccessSyncStateKey(mailboxUid), JSON.stringify(state));
104
141
  } catch {
105
- // Nothing to forget.
142
+ // Not remembered: the next pass imports again, which is idempotent (entities are upserted).
106
143
  }
107
144
  }
108
145
 
@@ -131,8 +168,81 @@ function isDocument(value: unknown): value is ZeroAccessIndexDocument {
131
168
  return !!doc && doc.v === 1 && typeof doc.messageUid === "string" && typeof doc.subject === "string" && typeof doc.body === "string";
132
169
  }
133
170
 
134
- /** A document as a row of the local index. Folder and flags aren't in the document - the builder records the folder
135
- * (`localIndexBuilder.ts`), and the upsert keeps what is already there. */
171
+ /** What the builder (`localIndexBuilder.ts`) knows of a zero-access message from the message list - none of it is in its document. */
172
+ export interface ZeroAccessRowMetadata {
173
+ folderUid: string;
174
+ entityVersion: string;
175
+ /** `,flag,flag,` */
176
+ flags: string;
177
+ }
178
+
179
+ /** At most this many messages' metadata are kept per mailbox for rows not imported yet; the oldest go first. */
180
+ export const MAX_REMEMBERED_METADATA = 50_000;
181
+
182
+ /** Folder, version and flags the builder recorded for zero-access messages, by mailbox and message uid, until their row is imported. */
183
+ const rememberedMetadata = new Map<string, Map<string, ZeroAccessRowMetadata>>();
184
+
185
+ /**
186
+ * Remembers zero-access message `messageUid`'s folder, version and flags for when its row is imported: the builder records them on the row
187
+ * 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
188
+ * imports the rows after the build).
189
+ */
190
+ export function rememberZeroAccessMetadata(mailboxUid: string, messageUid: string, metadata: ZeroAccessRowMetadata): void {
191
+ let remembered = rememberedMetadata.get(mailboxUid);
192
+ if (!remembered) {
193
+ remembered = new Map();
194
+ rememberedMetadata.set(mailboxUid, remembered);
195
+ }
196
+ remembered.delete(messageUid);
197
+ remembered.set(messageUid, metadata);
198
+ if (remembered.size > MAX_REMEMBERED_METADATA) {
199
+ remembered.delete(remembered.keys().next().value!);
200
+ }
201
+ }
202
+
203
+ /** Records on the just-imported rows of `messageUids` what the builder remembered of them, once. */
204
+ async function applyRememberedMetadata(mailboxUid: string, messageUids: string[]): Promise<void> {
205
+ const remembered = rememberedMetadata.get(mailboxUid);
206
+ for (const uid of messageUids) {
207
+ const metadata = remembered?.get(uid);
208
+ if (metadata) {
209
+ await moveLocalEntity(mailboxUid, uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
210
+ remembered!.delete(uid);
211
+ }
212
+ }
213
+ }
214
+
215
+ /** At most this many live-tail rows a pass looks up (`recordTailMetadata()`); the rest get theirs from the next build pass. */
216
+ export const MAX_TAIL_LOOKUPS = 500;
217
+
218
+ /** How many of those lookups run at once. */
219
+ const TAIL_LOOKUP_CONCURRENCY = 4;
220
+
221
+ /**
222
+ * Gives the live tail's rows that nothing recorded yet (`docs`: rows the builder hasn't seen - mail that arrived after its walk) their
223
+ * folder, version and flags at import, from the message itself, so a folder or flag filter finds them before the next build pass. At
224
+ * most `MAX_TAIL_LOOKUPS`. Best effort: a message that is gone, moved to another mailbox or won't load is left to the builder.
225
+ */
226
+ async function recordTailMetadata(mailboxUid: string, messageUids: string[], client?: ApiClient): Promise<void> {
227
+ const queue: string[] = messageUids.slice(0, MAX_TAIL_LOOKUPS);
228
+ const worker = async (): Promise<void> => {
229
+ for (let uid = queue.shift(); uid !== undefined; uid = queue.shift()) {
230
+ try {
231
+ const message = await getMessage(uid, client);
232
+ if (message.mailboxUid === mailboxUid && message.folderUid) {
233
+ await moveLocalEntity(mailboxUid, uid, message.folderUid, messageRowVersion(message), messageRowFlags(message));
234
+ }
235
+ } catch {
236
+ // Left to the next build pass.
237
+ }
238
+ }
239
+ };
240
+ await Promise.all(Array.from({ length: Math.min(TAIL_LOOKUP_CONCURRENCY, queue.length) }, worker));
241
+ }
242
+
243
+ /** A document as a row of the local index. Folder and flags aren't in the document - the builder records them (`localIndexBuilder.ts`,
244
+ * here through `rememberZeroAccessMetadata()` when the row came later; a live-tail row nothing recorded is looked up,
245
+ * `recordTailMetadata()`), and the upsert keeps what is already there. */
136
246
  export function documentEntity(mailboxUid: string, doc: ZeroAccessIndexDocument): LocalIndexEntity {
137
247
  const participants: string = (doc.participants ?? []).join(" ");
138
248
  const attachmentText: string = [...(doc.attachmentNames ?? []), ...(doc.attachmentText ?? [])].join("\n");
@@ -153,9 +263,14 @@ export function documentEntity(mailboxUid: string, doc: ZeroAccessIndexDocument)
153
263
 
154
264
  async function importDocuments(mailboxUid: string, docs: ZeroAccessIndexDocument[]): Promise<void> {
155
265
  for (let i = 0; i < docs.length; i += IMPORT_BATCH) {
266
+ const batch = docs.slice(i, i + IMPORT_BATCH);
156
267
  await indexLocalEntities(
157
268
  mailboxUid,
158
- docs.slice(i, i + IMPORT_BATCH).map((doc) => documentEntity(mailboxUid, doc)),
269
+ batch.map((doc) => documentEntity(mailboxUid, doc)),
270
+ );
271
+ await applyRememberedMetadata(
272
+ mailboxUid,
273
+ batch.map((doc) => doc.messageUid),
159
274
  );
160
275
  }
161
276
  }
@@ -254,9 +369,21 @@ export function isoWeekId(date: Date): string {
254
369
  return `${day.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
255
370
  }
256
371
 
257
- /** The sync state, started over when the local index is empty (nothing remembered in it is there any more). */
372
+ /**
373
+ * 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
374
+ * 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
375
+ * index filled by the build's end-to-end mail would take it up and skip the segments it never got.
376
+ */
258
377
  async function currentState(mailboxUid: string): Promise<SyncState> {
259
- return (await getLocalCoverage(mailboxUid)).indexedCount === 0 ? { segments: {} } : loadState(mailboxUid);
378
+ if ((await getLocalCoverage(mailboxUid)).indexedCount > 0) {
379
+ return loadState(mailboxUid);
380
+ }
381
+ try {
382
+ localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
383
+ } catch {
384
+ // Nothing stored to drop.
385
+ }
386
+ return { segments: {} };
260
387
  }
261
388
 
262
389
  /**
@@ -274,8 +401,10 @@ export async function importClientSegments(
274
401
  if (!keys?.length) {
275
402
  return 0;
276
403
  }
277
- const segments: IndexSegment[] = (manifest ?? (await getSearchIndexManifest(mailboxUid, client))).segments.filter((segment) => segment.origin === "client");
404
+ const epoch: string = zeroAccessSyncEpoch(mailboxUid);
405
+ // Read first, while a new index is still empty: its stale state is dropped even when the manifest can't be read.
278
406
  const state: SyncState = await currentState(mailboxUid);
407
+ const segments: IndexSegment[] = (manifest ?? (await getSearchIndexManifest(mailboxUid, client))).segments.filter((segment) => segment.origin === "client");
279
408
  let imported = 0;
280
409
  for (const segment of segments.sort((a, b) => b.weekId.localeCompare(a.weekId))) {
281
410
  const key = `${segment.weekId}:${segment.origin}`;
@@ -294,7 +423,7 @@ export async function importClientSegments(
294
423
  );
295
424
  }
296
425
  state.segments[key] = segment.etag;
297
- saveState(mailboxUid, state);
426
+ saveState(mailboxUid, state, epoch);
298
427
  imported++;
299
428
  }
300
429
  return imported;
@@ -315,8 +444,14 @@ export async function uploadClientDocuments(mailboxUid: string, unlocked: Unlock
315
444
  const weeks = new Map<string, ClientIndexDocument[]>();
316
445
  for (const entity of entities) {
317
446
  const weekId: string = isoWeekId(new Date(entity.dateForSort));
318
- weeks.set(weekId, [...(weeks.get(weekId) ?? []), clientDocument(entity)]);
447
+ let week = weeks.get(weekId);
448
+ if (!week) {
449
+ week = [];
450
+ weeks.set(weekId, week);
451
+ }
452
+ week.push(clientDocument(entity));
319
453
  }
454
+ const epoch: string = zeroAccessSyncEpoch(mailboxUid);
320
455
  const state: SyncState = loadState(mailboxUid);
321
456
  let written = 0;
322
457
  let manifest: SegmentManifest | undefined;
@@ -351,7 +486,7 @@ export async function uploadClientDocuments(mailboxUid: string, unlocked: Unlock
351
486
  // This device already holds every document of the new generation only if it held the previous one.
352
487
  if (current && state.segments[`${weekId}:client`] === current.etag) {
353
488
  state.segments[`${weekId}:client`] = stored.etag;
354
- saveState(mailboxUid, state);
489
+ saveState(mailboxUid, state, epoch);
355
490
  }
356
491
  written++;
357
492
  break;
@@ -366,8 +501,8 @@ export async function uploadClientDocuments(mailboxUid: string, unlocked: Unlock
366
501
  return written;
367
502
  }
368
503
 
369
- /** Every pending entry of `week` (or, without one, written since `since`), paged through. */
370
- async function listAllEntries(mailboxUid: string, filter: { week?: string; since?: string }, client?: ApiClient): Promise<SealedIndexEntry[]> {
504
+ /** Every pending entry of `week`, paged through. */
505
+ async function listAllEntries(mailboxUid: string, filter: { week: string }, client?: ApiClient): Promise<SealedIndexEntry[]> {
371
506
  const entries: SealedIndexEntry[] = [];
372
507
  let after: string | undefined;
373
508
  do {
@@ -378,6 +513,24 @@ async function listAllEntries(mailboxUid: string, filter: { week?: string; since
378
513
  return entries;
379
514
  }
380
515
 
516
+ /** 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
517
+ * 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
518
+ * 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
519
+ * `MAX_COMPACTIONS_PER_PASS` and no other week would ever be compacted here. A week is tried again once its count changes (new entries,
520
+ * or another device folded it), and every week once this device's keys do. */
521
+ const stalled = new Map<string, { keys: string; weeks: Map<string, number> }>();
522
+
523
+ /** The weeks of `mailboxUid` this device found it can't fold with `keys` (see `stalled`). */
524
+ function stalledWeeks(mailboxUid: string, keys: ZeroAccessKeys): Map<string, number> {
525
+ const fingerprints: string = keys.map((key) => key.fingerprint).join(",");
526
+ let entry = stalled.get(mailboxUid);
527
+ if (entry?.keys !== fingerprints) {
528
+ entry = { keys: fingerprints, weeks: new Map() };
529
+ stalled.set(mailboxUid, entry);
530
+ }
531
+ return entry.weeks;
532
+ }
533
+
381
534
  /** The documents of `entries` that open, with the uids of those entries; how many didn't. */
382
535
  async function openEntries(entries: SealedIndexEntry[], keys: ZeroAccessKeys): Promise<{ docs: ZeroAccessIndexDocument[]; uids: string[]; unreadable: number }> {
383
536
  const docs: ZeroAccessIndexDocument[] = [];
@@ -405,6 +558,7 @@ export async function syncZeroAccessIndex(mailboxUid: string, unlocked: Unlocked
405
558
  if (!keys?.length) {
406
559
  return result;
407
560
  }
561
+ const epoch: string = zeroAccessSyncEpoch(mailboxUid);
408
562
  const manifest = await getSearchIndexManifest(mailboxUid, client);
409
563
  let state: SyncState = await currentState(mailboxUid);
410
564
 
@@ -423,37 +577,75 @@ export async function syncZeroAccessIndex(mailboxUid: string, unlocked: Unlocked
423
577
  await importDocuments(mailboxUid, docs);
424
578
  state.segments[key] = segment.etag;
425
579
  result.segmentsImported++;
426
- saveState(mailboxUid, state);
580
+ saveState(mailboxUid, state, epoch);
427
581
  }
428
582
 
429
583
  // End-to-end mail other devices indexed.
430
- saveState(mailboxUid, state);
584
+ saveState(mailboxUid, state, epoch);
431
585
  result.clientSegmentsImported = await importClientSegments(mailboxUid, unlocked, client, { manifest });
432
586
  state = loadState(mailboxUid);
433
587
 
434
- // The live tail.
435
- const tailStart = new Date().toISOString();
588
+ // 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
589
+ // after zero-access is turned on can be every message it has - never held, opened, all at once. Each page imported is remembered
590
+ // (`tailCursor`), so a pass that ends early is taken up where it stopped rather than from the start.
436
591
  const since = state.tailSince ? new Date(new Date(state.tailSince).getTime() - TAIL_OVERLAP_MS).toISOString() : undefined;
437
- const tail = await openEntries(await listAllEntries(mailboxUid, since ? { since } : {}, client), keys);
438
- await importDocuments(mailboxUid, tail.docs);
439
- result.entriesImported += tail.docs.length;
440
- result.unreadable += tail.unreadable;
592
+ const resumed = state.tailCursor?.since === since ? state.tailCursor : undefined;
593
+ const tailStart: string = resumed?.start ?? new Date().toISOString();
594
+ // 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.
595
+ // 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
596
+ // are as many as a pass looks up.
597
+ const remembered = rememberedMetadata.get(mailboxUid);
598
+ const asked = new Set<string>();
599
+ const unrecorded: string[] = [];
600
+ let after: string | undefined = resumed?.after;
601
+ do {
602
+ const page = await listSearchIndexEntries(mailboxUid, { ...(since ? { since } : {}), ...(after ? { after } : {}) }, client);
603
+ const tail = await openEntries(page.entries, keys);
604
+ const tailUids: string[] = [...new Set(tail.docs.map((doc) => doc.messageUid))].filter((uid) => !asked.has(uid));
605
+ tailUids.forEach((uid) => asked.add(uid));
606
+ for (let i = 0; i < tailUids.length && unrecorded.length < MAX_TAIL_LOOKUPS; i += IMPORT_BATCH) {
607
+ const batch: string[] = tailUids.slice(i, i + IMPORT_BATCH);
608
+ const recorded: Record<string, string> = await getIndexedVersions(mailboxUid, batch);
609
+ unrecorded.push(...batch.filter((uid) => recorded[uid] === undefined && !remembered?.has(uid)));
610
+ }
611
+ await importDocuments(mailboxUid, tail.docs);
612
+ result.entriesImported += tail.docs.length;
613
+ result.unreadable += tail.unreadable;
614
+ after = page.next;
615
+ if (after) {
616
+ state.tailCursor = { ...(since ? { since } : {}), after, start: tailStart };
617
+ saveState(mailboxUid, state, epoch);
618
+ }
619
+ } while (after);
620
+ await recordTailMetadata(mailboxUid, unrecorded, client);
441
621
  state.tailSince = tailStart;
442
- saveState(mailboxUid, state);
622
+ delete state.tailCursor;
623
+ saveState(mailboxUid, state, epoch);
443
624
 
444
- // Compaction of closed weeks.
625
+ // Compaction of closed weeks, passing over those this device found it can't fold (`stalled`) while nothing has changed in them.
445
626
  const active = keys[0];
446
- for (const week of manifest.weeks.filter((w) => w.compactable && w.pendingEntries > 0).slice(0, MAX_COMPACTIONS_PER_PASS)) {
447
- const current: IndexSegment | undefined = zeroAccessSegments.find((segment) => segment.weekId === week.weekId);
627
+ const stalledHere: Map<string, number> = stalledWeeks(mailboxUid, keys);
628
+ const segmentOf = (weekId: string): IndexSegment | undefined => zeroAccessSegments.find((segment) => segment.weekId === weekId);
629
+ const weeks: IndexWeekEntries[] = manifest.weeks.filter((w) => stalledHere.get(w.weekId) !== w.pendingEntries && shouldCompact(w, segmentOf(w.weekId)?.bytes ?? 0));
630
+ for (const week of weeks.slice(0, MAX_COMPACTIONS_PER_PASS)) {
631
+ const current: IndexSegment | undefined = segmentOf(week.weekId);
448
632
  const previous: ZeroAccessIndexDocument[] | undefined = current ? await readSegment(mailboxUid, current, keys, client) : [];
449
633
  if (!previous) {
450
634
  // A generation this device can't read can't be folded into: leave the week to a device that can.
635
+ stalledHere.set(week.weekId, week.pendingEntries);
451
636
  continue;
452
637
  }
453
638
  const pending = await openEntries(await listAllEntries(mailboxUid, { week: week.weekId }, client), keys);
454
639
  if (pending.uids.length === 0) {
640
+ stalledHere.set(week.weekId, week.pendingEntries);
455
641
  continue;
456
642
  }
643
+ // What this device can't open stays pending once the rest is folded: the week is passed over until that count changes.
644
+ if (pending.unreadable > 0) {
645
+ stalledHere.set(week.weekId, pending.unreadable);
646
+ } else {
647
+ stalledHere.delete(week.weekId);
648
+ }
457
649
  const merged = new Map<string, ZeroAccessIndexDocument>(previous.map((doc) => [doc.messageUid, doc]));
458
650
  for (const doc of pending.docs) {
459
651
  merged.set(doc.messageUid, doc);
@@ -474,7 +666,7 @@ export async function syncZeroAccessIndex(mailboxUid: string, unlocked: Unlocked
474
666
  // This device already holds every document of the new generation.
475
667
  await importDocuments(mailboxUid, pending.docs);
476
668
  state.segments[`${stored.weekId}:${stored.origin}`] = stored.etag;
477
- saveState(mailboxUid, state);
669
+ saveState(mailboxUid, state, epoch);
478
670
  result.weeksCompacted++;
479
671
  } catch (err) {
480
672
  // Another device compacted the week first: the next pass imports its generation.
@@ -0,0 +1,70 @@
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
+
16
+ const PREFIX = "rapidmx:za-index-sync:";
17
+
18
+ /** The localStorage key of `mailboxUid`'s sync state. */
19
+ export const zeroAccessSyncStateKey = (mailboxUid: string): string => `${PREFIX}${mailboxUid}`;
20
+
21
+ const epochs = new Map<string, number>();
22
+ let allEpoch = 0;
23
+
24
+ /** Changes whenever `mailboxUid`'s sync state is forgotten. */
25
+ export function zeroAccessSyncEpoch(mailboxUid: string): string {
26
+ return `${allEpoch}:${epochs.get(mailboxUid) ?? 0}`;
27
+ }
28
+
29
+ /** Forgets what a pass remembered for `mailboxUid` (its local index is destroyed). */
30
+ export function forgetZeroAccessSync(mailboxUid: string): void {
31
+ epochs.set(mailboxUid, (epochs.get(mailboxUid) ?? 0) + 1);
32
+ try {
33
+ localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid));
34
+ } catch {
35
+ // Nothing to forget.
36
+ }
37
+ }
38
+
39
+ /** Forgets what passes remembered for every mailbox (every local index is destroyed). */
40
+ export function forgetAllZeroAccessSync(): void {
41
+ allEpoch++;
42
+ try {
43
+ storedMailboxUids().forEach((mailboxUid) => localStorage.removeItem(zeroAccessSyncStateKey(mailboxUid)));
44
+ } catch {
45
+ // Nothing to forget.
46
+ }
47
+ }
48
+
49
+ /** Forgets what passes remembered for every mailbox not in `keep` (their local indexes are pruned). */
50
+ export function forgetZeroAccessSyncExcept(keep: ReadonlySet<string>): void {
51
+ let stored: string[];
52
+ try {
53
+ stored = storedMailboxUids();
54
+ } catch {
55
+ return;
56
+ }
57
+ stored.filter((mailboxUid) => !keep.has(mailboxUid)).forEach(forgetZeroAccessSync);
58
+ }
59
+
60
+ /** The mailboxes some sync state is stored for. Throws when storage can't be read. */
61
+ function storedMailboxUids(): string[] {
62
+ const uids: string[] = [];
63
+ for (let i = 0; i < localStorage.length; i++) {
64
+ const key = localStorage.key(i);
65
+ if (key?.startsWith(PREFIX)) {
66
+ uids.push(key.slice(PREFIX.length));
67
+ }
68
+ }
69
+ return uids;
70
+ }
@@ -0,0 +1,19 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { isProtectedRecord, type SealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
7
+
8
+ /** A small lock beside a calendar event, contact or task whose details zero-access encryption protects (`sealedFields`) - nothing for
9
+ * one it doesn't. Never says "end-to-end": the server sees what is saved while it saves it (restapi `specs/search.md` §3). */
10
+ export default function ProtectedMark({ record }: { record: SealedRecord }) {
11
+ if (!isProtectedRecord(record)) {
12
+ return null;
13
+ }
14
+ return (
15
+ <span className="ml-1 text-text-muted text-xs" role="img" aria-label="Protected by zero-access encryption" title="Protected by zero-access encryption">
16
+ &#128274;
17
+ </span>
18
+ );
19
+ }
@@ -0,0 +1,25 @@
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
+ /**
9
+ * Calls `onLocked` with the mailbox whenever a mailbox's keys lock (the idle timeout, a lock from settings, signing out) - for a page that
10
+ * 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
11
+ * (the reloaded records keep their placeholders) and closes what shows opened content. The latest `onLocked` is called; subscribed once.
12
+ */
13
+ export function useKeysLocked(onLocked: (mailboxUid: string) => void): void {
14
+ const latest = useRef(onLocked);
15
+ latest.current = onLocked;
16
+ useEffect(
17
+ () =>
18
+ subscribeKeySession(({ mailboxUid, state }) => {
19
+ if (state === "locked") {
20
+ latest.current(mailboxUid);
21
+ }
22
+ }),
23
+ [],
24
+ );
25
+ }
@@ -0,0 +1,71 @@
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, type SealedRecord } from "../../../lib/zeroAccess/sealedRecords.js";
8
+
9
+ /** 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. */
10
+ function uidOf(record: SealedRecord | undefined): string | undefined {
11
+ return (record as { uid?: unknown } | undefined)?.uid as string | undefined;
12
+ }
13
+
14
+ /**
15
+ * `record` with all its protected fields opened (`openSealedRecord()`), for a view that shows or edits every field - a list opens only
16
+ * what it shows. `ready` is false while a protected record is first being opened; `record` is the one given meanwhile, and the given one
17
+ * again when it can't be opened (the mailbox is locked).
18
+ *
19
+ * 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
20
+ * a live update (its in-progress edits kept); the new one - its new `version` - takes over once opened. The record's mailbox locking drops
21
+ * what was opened at once (nothing opened outlives its key session) and opens it again, which gives the placeholders; its unlocking opens it
22
+ * again too.
23
+ *
24
+ * `open` opens it, `openSealedRecord()` unless a kind of record has its own full open (a calendar event's `fullyOpenedEvent()`, which keeps
25
+ * the title opened from its sealed content) - a function defined once, not per render, so the record isn't opened again on every render.
26
+ */
27
+ export function useOpenedRecord<T extends SealedRecord>(
28
+ record: T | undefined,
29
+ open: (record: T) => Promise<T> = openSealedRecord,
30
+ ): { record: T | undefined; ready: boolean } {
31
+ const [opened, setOpened] = useState<{ from: T; record: T } | undefined>(undefined);
32
+ const [generation, setGeneration] = useState(0);
33
+ const protectedRecord = isProtectedRecord(record);
34
+ const mailboxUid = record?.mailboxUid;
35
+ useEffect(() => {
36
+ if (!mailboxUid || !protectedRecord) {
37
+ return;
38
+ }
39
+ return subscribeKeySession((event) => {
40
+ if (event.mailboxUid !== mailboxUid) {
41
+ return;
42
+ }
43
+ if (event.state === "locked") {
44
+ setOpened(undefined);
45
+ }
46
+ setGeneration((current) => current + 1);
47
+ });
48
+ }, [mailboxUid, protectedRecord]);
49
+ useEffect(() => {
50
+ if (!record || !protectedRecord) {
51
+ return;
52
+ }
53
+ let cancelled = false;
54
+ void open(record).then((result) => {
55
+ if (!cancelled) {
56
+ setOpened({ from: record, record: result });
57
+ }
58
+ });
59
+ return () => {
60
+ cancelled = true;
61
+ };
62
+ }, [record, protectedRecord, generation, open]);
63
+ if (!record || !protectedRecord) {
64
+ return { record, ready: true };
65
+ }
66
+ if (opened?.from === record) {
67
+ return { record: opened.record, ready: true };
68
+ }
69
+ const uid = uidOf(record);
70
+ return opened && uid !== undefined && uidOf(opened.from) === uid ? { record: opened.record, ready: true } : { record, ready: false };
71
+ }
@@ -47,6 +47,7 @@ export default function WwwShell({
47
47
  pluginNav,
48
48
  branding,
49
49
  appearance,
50
+ signOutHost,
50
51
  }: WwwShellProps) {
51
52
  const { route, pending } = useRouter();
52
53
  const [takeovers, setTakeovers] = useState(0);
@@ -74,6 +75,7 @@ export default function WwwShell({
74
75
  pluginNav={pluginNav}
75
76
  branding={branding}
76
77
  appearance={appearance}
78
+ signOutHost={signOutHost}
77
79
  >
78
80
  {children}
79
81
  </AppChrome>