@rapidmx/web-client 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -4,36 +4,104 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
+ import { HiOutlineLockClosed } from "react-icons/hi2";
7
8
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
- import { getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { getMessage, getMessageRawContent, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
9
10
  import { listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
10
11
  import { listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
11
12
  import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
12
13
  import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
13
14
  import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
14
15
  import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
15
- import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
16
+ import { searchLocalIndex } from "../shared/search/searchTier2.js";
17
+ import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
18
+ import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
16
19
  import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
17
20
  import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
18
- import MailShell, { useMailShell } from "../shared/components/mail/layout/MailShell.js";
21
+ import MailShell, { useMailShell, } from "../shared/components/mail/layout/MailShell.js";
19
22
  import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
20
23
  import ConversationList from "../shared/components/mail/ConversationList.js";
21
24
  import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
22
25
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
26
+ import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
27
+ import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
23
28
  const MESSAGE_PAGE_SIZE = 50;
24
29
  const SEARCH_DEBOUNCE_MS = 300;
25
- /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message) and Tier 3 (decrypted,
26
- * content-verified) results into one ranked list, per `specs/search.md` §7's "client MUST re-score all
27
- * results it can see... normalise into the same space rather than interleaving raw scores": both sides
28
- * are normalized independently via `normalizeServerScores()` before merging, since a Postgres/OpenSearch
29
- * score and this module's own term-count score occupy unrelated ranges. A uid present in both lists
30
- * (Tier 1 found it via a `participants` coincidence, Tier 3 then genuinely verified its content) keeps
31
- * only the Tier 3 entry - real content verification supersedes a metadata guess for the same message.
32
- * Deliberately not the spec's full skeleton/reordering "Progressive Results" UX (§_Progressive
33
- * Results_) - that's real, separate UI work; this returns one final merged list once both tiers
34
- * resolve, same as how the search box already waits on one round-trip today. */
35
- function mergeSearchResults(tier1, tier3) {
30
+ const LIST_PREVIEW_MAX_LENGTH = 160;
31
+ /** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
32
+ * `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
33
+ * `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
34
+ * decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
35
+ const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
36
+ /** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
37
+ * `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
38
+ * so duplicated here rather than pulled in through a package change just for this one small, pure
39
+ * helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
40
+ * never rendered back into any DOM. */
41
+ function stripHtmlToText(html) {
42
+ return html
43
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
44
+ .replace(/<[^>]+>/g, " ")
45
+ .replace(/&nbsp;/gi, " ")
46
+ .replace(/&amp;/gi, "&")
47
+ .replace(/&lt;/gi, "<")
48
+ .replace(/&gt;/gi, ">")
49
+ .replace(/&quot;/gi, '"')
50
+ .replace(/&#0*39;/gi, "'")
51
+ .replace(/\s+/g, " ")
52
+ .trim();
53
+ }
54
+ /**
55
+ * Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
56
+ * placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
57
+ * inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
58
+ * per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
59
+ * whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
60
+ * once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
61
+ * and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
62
+ */
63
+ async function decryptEncryptedRows(messages, unlocked) {
64
+ const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
65
+ const entries = await Promise.all(encrypted.map(async (message) => {
66
+ try {
67
+ const rawMime = await getMessageRawContent(message.uid);
68
+ const security = await evaluateMessageSecurity(rawMime, unlocked);
69
+ if (!security.subject && !security.html) {
70
+ return null;
71
+ }
72
+ const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
73
+ return [message.uid, { subject: security.subject, preview }];
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }));
79
+ const result = {};
80
+ for (const entry of entries) {
81
+ if (entry) {
82
+ result[entry[0]] = entry[1];
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+ /** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
88
+ * decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
89
+ * one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
90
+ * into the same space rather than interleaving raw scores": each tier is normalized independently via
91
+ * `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
92
+ * and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
93
+ * one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
94
+ * Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
95
+ * overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
96
+ * Tier 1's metadata-only guess for the same uid.
97
+ *
98
+ * Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
99
+ * far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
100
+ * Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
101
+ * stateless; it has no notion of "in progress" versus "final." */
102
+ function mergeSearchResults(tier1, tier2, tier3) {
36
103
  const normalizedTier1 = normalizeServerScores(tier1);
104
+ const normalizedTier2 = normalizeServerScores(tier2);
37
105
  const normalizedTier3 = normalizeServerScores(tier3);
38
106
  const merged = new Map();
39
107
  for (const entry of normalizedTier1) {
@@ -42,75 +110,223 @@ function mergeSearchResults(tier1, tier3) {
42
110
  for (const entry of normalizedTier3) {
43
111
  merged.set(entry.result.entityUid, entry);
44
112
  }
113
+ for (const entry of normalizedTier2) {
114
+ merged.set(entry.result.entityUid, entry);
115
+ }
45
116
  return Array.from(merged.values())
46
117
  .sort((a, b) => b.normalizedScore - a.normalizedScore)
47
118
  .map((entry) => entry.result);
48
119
  }
49
- /** Resolves one page of search hits into full `Message` records for display, plus each hit's own
50
- * `snippet` (keyed by message uid) for rendering in place of the plain `bodyPreview` while searching.
51
- *
52
- * `rawQuery` is parsed once, client-side, via `queryGrammar.ts`'s `parseSearchQuery()` — the operator
53
- * grammar (`from:`/`to:`/`subject:`/`has:attachment`/`before:`/`after:`/`in:`/`is:`/`label:`/`type:`,
54
- * `specs/search.md` §14) is extracted into structured filters passed to the server, while the
55
- * remaining free text (quotes, `-` negation, `OR` all preserved) still drives ranking as `q`.
56
- * `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
120
+ /** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
57
121
  * (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
58
122
  * dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
59
123
  * (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
60
- * larger UI project outside this pass).
124
+ * larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
125
+ * below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
126
+ function tier1SearchParams(parsed, cursor, mailboxUid) {
127
+ return {
128
+ // The mailbox actually open - omitted, the server searches the caller's *own* mailbox, which is the
129
+ // wrong one whenever a shared mailbox's folder is being viewed.
130
+ mailboxUid,
131
+ types: parsed.entityTypes ?? ["message"],
132
+ cursor,
133
+ limit: MESSAGE_PAGE_SIZE,
134
+ from: parsed.from,
135
+ to: parsed.to,
136
+ cc: parsed.cc,
137
+ subject: parsed.subject,
138
+ hasAttachment: parsed.hasAttachment,
139
+ before: parsed.before,
140
+ after: parsed.after,
141
+ folderUid: parsed.folderUid,
142
+ flags: parsed.flags,
143
+ labels: parsed.labels,
144
+ };
145
+ }
146
+ /** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
147
+ * metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
148
+ * position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
149
+ * approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
150
+ * match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
151
+ *
152
+ * A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
153
+ * under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
154
+ * - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
155
+ * orchestration). */
156
+ const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
157
+ function capSkeletons(tier1Hits) {
158
+ let skeletonsSeen = 0;
159
+ return tier1Hits.filter((hit) => {
160
+ if (!hit.metadataOnly) {
161
+ return true;
162
+ }
163
+ skeletonsSeen += 1;
164
+ return skeletonsSeen <= SKELETON_CAP;
165
+ });
166
+ }
167
+ /** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
168
+ * `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
169
+ * separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
170
+ * this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
171
+ * "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
172
+ * extraction); a query whose true candidate set exceeds this simply pages out once this cache is
173
+ * exhausted; `loadMore()` reflects that honestly via `hasMore`. */
174
+ const TIER3_CANDIDATE_LIMIT = 200;
175
+ /** Keyed by the exact query windows Tier 3 actually ran (`tier3Windows()` - which already reflect the query,
176
+ * the "Search all mail" toggle, and Tier 2's coverage at the time), not just the query: the same query
177
+ * narrowed differently (a build finished, new mail moved the coverage end) must not reuse a result that was
178
+ * computed over a different date range. */
179
+ function tier3CacheKey(mailboxUid, windows, unlocked) {
180
+ return `${mailboxUid}|${JSON.stringify(windows)}|${String(unlocked)}`;
181
+ }
182
+ /** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
183
+ * distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
184
+ * different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
185
+ * its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
186
+ * whose key order could vary between two structurally-identical parses of the same text. */
187
+ function queryFingerprint(parsed) {
188
+ return JSON.stringify(parsed);
189
+ }
190
+ /** The date windows Tier 3 still has to search: the query's own range minus Tier 2's guaranteed coverage
191
+ * `[coverage.indexedFrom, coverage.indexedUntil]`, unless the reader explicitly asked to "Search all mail".
192
+ * Tier 2 already holds fully-decrypted, current content for that range, so re-fetching and re-decrypting it
193
+ * through Tier 3's slower candidate-narrowing path would be pure waste. Yields up to two windows - older than
194
+ * the coverage (`before:` tightened to `indexedFrom`) and newer than it (`after:` raised to `indexedUntil`,
195
+ * since nothing indexes mail that arrived after the build pass started) - never *widening* a bound the query
196
+ * already specified, and none at all when the query lies entirely inside the coverage.
61
197
  *
62
- * Tier 3 (`searchTier3.ts#searchEncryptedCandidates()`) runs alongside Tier 1 only for the first page
63
- * (`cursor` absent) - it has no pagination wiring yet (a deliberate scope trim, see that module's own
64
- * doc comment), so a `loadMore()` continuation stays Tier-1-only. Silently contributes nothing when
65
- * this mailbox has no unlocked keys this session (`getUnlockedKeys()` returns `undefined`) - nothing
66
- * for it to decrypt, same as `MessageDetailPane`'s own encrypted-message handling elsewhere. */
67
- async function searchMessages(mailboxUid, rawQuery, cursor) {
68
- const parsed = parseSearchQuery(rawQuery);
69
- const [page, tier3Results] = await Promise.all([
70
- searchMailbox(parsed.text, {
71
- types: parsed.entityTypes ?? ["message"],
72
- cursor,
73
- limit: MESSAGE_PAGE_SIZE,
74
- from: parsed.from,
75
- to: parsed.to,
76
- cc: parsed.cc,
77
- subject: parsed.subject,
78
- hasAttachment: parsed.hasAttachment,
79
- before: parsed.before,
80
- after: parsed.after,
81
- folderUid: parsed.folderUid,
82
- flags: parsed.flags,
83
- labels: parsed.labels,
84
- }),
85
- cursor ? Promise.resolve([]) : searchEncryptedCandidates(parsed, getUnlockedKeys(mailboxUid)),
86
- ]);
87
- const mergedResults = mergeSearchResults(page.results, tier3Results);
88
- const resolved = await Promise.all(mergedResults.map(async (hit) => {
198
+ * Only narrows once Tier 2 reports a finished, complete build pass from this session: while it's still
199
+ * building (or a pass stopped early - a failed folder listing, the byte budget) `indexedFrom` is just the
200
+ * oldest row that happens to be present, not a guarantee every encrypted message since then is indexed, and
201
+ * narrowing on it would silently drop encrypted results neither tier returns. */
202
+ function tier3Windows(parsed, coverage, searchAllMail) {
203
+ if (searchAllMail || !coverage?.indexedFrom || !coverage.indexedUntil || coverage.building || !coverage.complete) {
204
+ return [parsed];
205
+ }
206
+ const coveredFrom = new Date(coverage.indexedFrom);
207
+ const coveredUntil = new Date(coverage.indexedUntil);
208
+ const windows = [];
209
+ if (!parsed.after || parsed.after.getTime() < coveredFrom.getTime()) {
210
+ windows.push({ ...parsed, before: parsed.before && parsed.before.getTime() < coveredFrom.getTime() ? parsed.before : coveredFrom });
211
+ }
212
+ if (!parsed.before || parsed.before.getTime() > coveredUntil.getTime()) {
213
+ windows.push({ ...parsed, after: parsed.after && parsed.after.getTime() > coveredUntil.getTime() ? parsed.after : coveredUntil });
214
+ }
215
+ return windows;
216
+ }
217
+ /** Runs Tier 3 over each window and merges the candidates (a uid can't match in two disjoint windows, but a
218
+ * message whose date sits exactly on a boundary may come back from both). */
219
+ async function searchTier3Windows(windows, unlocked, mailboxUid) {
220
+ const pages = await Promise.all(windows.map((window) => searchEncryptedCandidates(window, unlocked, TIER3_CANDIDATE_LIMIT, { mailboxUid })));
221
+ const merged = new Map();
222
+ for (const result of pages.flat()) {
223
+ if (!merged.has(result.entityUid)) {
224
+ merged.set(result.entityUid, result);
225
+ }
226
+ }
227
+ return [...merged.values()];
228
+ }
229
+ /** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
230
+ * cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
231
+ function decodeCursor(raw, fingerprint) {
232
+ return raw?.fingerprint === fingerprint ? raw : undefined;
233
+ }
234
+ /** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
235
+ * repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
236
+ * and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
237
+ * stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
238
+ * (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
239
+ * "a search hit can briefly outlive the message it points to" tolerance this function's inline
240
+ * predecessor already had. */
241
+ async function resolveHitsToMessages(hits, cache) {
242
+ const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
243
+ await Promise.all(toFetch.map(async (hit) => {
89
244
  const message = await getMessage(hit.entityUid).catch(() => null);
90
- return message ? { message, snippet: hit.snippet } : null;
245
+ cache.set(hit.entityUid, message);
91
246
  }));
92
- // A search hit can briefly outlive the message it points to (index updates are eventually consistent,
93
- // and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
94
- // rendering a broken entry.
95
247
  const messages = [];
96
248
  const snippets = {};
97
- for (const entry of resolved) {
98
- if (!entry) {
249
+ for (const hit of hits) {
250
+ const message = cache.get(hit.entityUid);
251
+ if (!message) {
99
252
  continue;
100
253
  }
101
- messages.push(entry.message);
102
- if (entry.snippet) {
103
- snippets[entry.message.uid] = entry.snippet;
254
+ messages.push(message);
255
+ if (hit.snippet) {
256
+ snippets[message.uid] = hit.snippet;
104
257
  }
105
258
  }
106
- return { messages, nextCursor: page.nextCursor, snippets };
259
+ return { messages, snippets };
260
+ }
261
+ /** How far outside the scroll container's visible area the load-more sentinel still counts as "in view". */
262
+ const LOAD_MORE_ROOT_MARGIN_PX = 200;
263
+ /** How many full pages in a row that added no new rows the list keeps loading on its own before it shows a
264
+ * "Load more" button instead. */
265
+ const MAX_EMPTY_PAGE_CONTINUATIONS = 3;
266
+ /** `true` when `more` has at least one uid `shown` doesn't - i.e. appending it actually adds rows. */
267
+ function hasUnseenMessages(shown, more) {
268
+ const seen = new Set(shown.map((m) => m.uid));
269
+ return more.some((m) => !seen.has(m.uid));
270
+ }
271
+ /** The load-more sentinel's actual current geometry against its scroll container, with the same margin the
272
+ * IntersectionObserver uses. */
273
+ function isWithinLoadMoreRange(sentinel, root) {
274
+ const rect = sentinel.getBoundingClientRect();
275
+ const rootRect = root.getBoundingClientRect();
276
+ return rect.top <= rootRect.bottom + LOAD_MORE_ROOT_MARGIN_PX && rect.bottom >= rootRect.top - LOAD_MORE_ROOT_MARGIN_PX;
277
+ }
278
+ /** Appends `more` to `shown`, skipping any uid already shown - a later page can repeat rows (Tier 1 and
279
+ * Tier 2/3 cursors advance independently, so the same message can come back from a different tier on a
280
+ * later page; a plain folder listing's pages shift when new mail arrives between fetches). */
281
+ function appendUnseenMessages(shown, more) {
282
+ const seen = new Set(shown.map((m) => m.uid));
283
+ const unseen = [];
284
+ for (const message of more) {
285
+ if (!seen.has(message.uid)) {
286
+ seen.add(message.uid);
287
+ unseen.push(message);
288
+ }
289
+ }
290
+ return unseen.length === 0 ? shown : [...shown, ...unseen];
291
+ }
292
+ /** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
293
+ * Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
294
+ * natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
295
+ function mergeInboxMessages(perMailbox) {
296
+ return perMailbox
297
+ .flatMap((entry) => entry.messages)
298
+ .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
299
+ }
300
+ /**
301
+ * Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
302
+ * results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
303
+ * nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
304
+ *
305
+ * **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
306
+ * no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
307
+ * always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
308
+ * "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
309
+ * composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
310
+ * page in aggregate view often.
311
+ */
312
+ async function fetchAggregateMessages(mailboxFolders, type) {
313
+ const perMailbox = await Promise.all(mailboxFolders.map(async ({ mailbox, folders }) => {
314
+ const folder = folders.find((f) => f.type === type);
315
+ if (!folder) {
316
+ return { mailbox, messages: [] };
317
+ }
318
+ const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE }).catch(() => []);
319
+ return { mailbox, messages };
320
+ }));
321
+ return mergeInboxMessages(perMailbox);
107
322
  }
108
323
  export default function InboxPage(props) {
109
- return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, {}) }));
324
+ return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
110
325
  }
111
- function InboxContent() {
112
- const { folderUid, mailboxUid, folders } = useMailShell();
326
+ function InboxContent({ userUid }) {
327
+ const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
113
328
  const isMobile = useIsMobile();
329
+ const { requestUnlock } = useUnlockPrompt();
114
330
  const [viewMode, setViewMode] = useState("date");
115
331
  const [messages, setMessages] = useState([]);
116
332
  const [conversations, setConversations] = useState([]);
@@ -125,22 +341,194 @@ function InboxContent() {
125
341
  const [searchQuery, setSearchQuery] = useState("");
126
342
  const [snippets, setSnippets] = useState({});
127
343
  const [labels, setLabels] = useState([]);
128
- const isSearching = viewMode === "date" && searchQuery.length > 0;
129
- const pageRef = useRef(0);
130
- const cursorRef = useRef(undefined);
344
+ // Tier 2's own reported window coverage for the current search - undefined outside a search, or
345
+ // before Tier 2 has resolved yet for this search pass.
346
+ const [coverage, setCoverage] = useState(undefined);
347
+ // Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
348
+ // decrypted row stays decrypted while its keys stay unlocked; re-decrypting on every navigation would
349
+ // waste work for no benefit) - only cleared when the mailbox's keys are locked (see the
350
+ // `subscribeKeySession()` effect below).
351
+ const [decryptedRows, setDecryptedRows] = useState({});
352
+ // Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
353
+ // dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
354
+ // React state; see keySession.ts's own doc comment).
355
+ const [unlockRefresh, setUnlockRefresh] = useState(0);
356
+ // §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
357
+ // `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
358
+ // always empty again once every tier has reported for the current pass (each unresolved entry is by
359
+ // then either confirmed, real content, or pruned - see the search orchestration effect).
360
+ const [pendingUids, setPendingUids] = useState(new Set());
361
+ // Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
362
+ // Results_: "Never show a hard count until every tier has reported... A settled count is the signal
363
+ // that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
364
+ const [tier1Done, setTier1Done] = useState(false);
365
+ const [tier2Done, setTier2Done] = useState(false);
366
+ const [tier3Done, setTier3Done] = useState(false);
367
+ // Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
368
+ // bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
369
+ // re-run. Stored as the mailbox/folder/query it was requested for, not a plain flag, so it resets the
370
+ // moment any of those change - derived in the same render, so the search effect never runs a new
371
+ // query with a previous query's unbounded Tier 3 window first.
372
+ const [searchAllMailKey, setSearchAllMailKey] = useState(null);
373
+ const searchAllMailScope = `${mailboxUid ?? ""}\n${folderUid ?? ""}\n${searchQuery}`;
374
+ const searchAllMail = searchAllMailKey === searchAllMailScope;
375
+ // Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
376
+ // to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
377
+ // branch below, which the search effect never reaches while `folderUid` is unset).
378
+ const isSearching = viewMode === "date" && searchQuery.length > 0 && !aggregateFolderType;
379
+ // How far into the folder's *current* server-side listing the rows fetched so far reach. Offset
380
+ // paging, not a cursor (`listMessages()` has none): a row removed locally (archived, scheduled send
381
+ // cancelled) also left the folder server-side, shifting every later message back by one - so each
382
+ // removal steps this back too, or the next page would silently skip a message. See `loadMore()`.
383
+ const listedOffsetRef = useRef(0);
131
384
  const scrollContainerRef = useRef(null);
132
- const sentinelRef = useRef(null);
385
+ // State (via a callback ref), not a plain ref: the sentinel mounts and unmounts as the list loads,
386
+ // filters, and empties, and the observer effect below must re-attach to whichever node is current.
387
+ const [sentinel, setSentinel] = useState(null);
388
+ const [loadMoreError, setLoadMoreError] = useState(null);
389
+ const loadMoreErrorRef = useRef(null);
390
+ loadMoreErrorRef.current = loadMoreError;
391
+ const loadMoreInFlightRef = useRef(false);
392
+ // Bumped by `loadMore()` for each page that added at least one row - the only event the continuation
393
+ // effect below keeps loading after. A counter rather than watching `loadingMore` flip back to false: a
394
+ // fast response can settle before React ever renders the `true`, so that flip isn't reliably observable.
395
+ const [appendedPageCount, setAppendedPageCount] = useState(0);
396
+ // Consecutive full pages that added no new rows (every row was already shown - e.g. new mail shifted the
397
+ // folder's pages). Such a page still counts as progress for the continuation effect, up to
398
+ // `MAX_EMPTY_PAGE_CONTINUATIONS` in a row; past that, `loadMoreStalled` shows a "Load more" button
399
+ // instead, so a server that keeps repeating rows is never looped on.
400
+ const emptyPageStreakRef = useRef(0);
401
+ const [loadMoreStalled, setLoadMoreStalled] = useState(false);
402
+ const messagesRef = useRef(messages);
403
+ messagesRef.current = messages;
404
+ // The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
405
+ // one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
406
+ // aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
407
+ // own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
408
+ // comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
409
+ const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
410
+ const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
411
+ // Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
412
+ // resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
413
+ // across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
414
+ const resolvedMessageCacheRef = useRef(new Map());
415
+ // Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
416
+ const tier3CacheRef = useRef(new Map());
417
+ // The latest composite cursor this search pass has reached - read by loadMore(), written at the end
418
+ // of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
419
+ // render on its own, only what loadMore() does with it later.
420
+ const compositeCursorRef = useRef(undefined);
421
+ // Guards every async load below - each search stage, the plain folder/conversation/aggregate listings,
422
+ // and `loadMore()` - against a stale, still-in-flight pass clobbering state for a newer one that started
423
+ // after it (the query, folder, or view changed) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
424
+ // `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
425
+ // async stages instead of one.
426
+ const searchRunIdRef = useRef(0);
427
+ // Bumped whenever `activeMailboxUid`'s keys are locked (see the `subscribeKeySession()` effect below) - a
428
+ // decrypt that started before the lock checks it before storing its now-stale plaintext rows.
429
+ const lockGenerationRef = useRef(0);
430
+ // `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
431
+ // never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
432
+ // state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
433
+ // re-trigger this the normal way, by changing `messages` itself.
434
+ //
435
+ // Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
436
+ // mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
437
+ // own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
438
+ // auto-decrypt or the manual unlock banner below.
439
+ const undecryptedEncryptedUids = messages
440
+ .filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
441
+ .map((m) => m.uid);
442
+ // Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
443
+ // no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
444
+ // matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
445
+ // arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
446
+ useEffect(() => {
447
+ if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
448
+ return;
449
+ }
450
+ const unlocked = getUnlockedKeys(activeMailboxUid);
451
+ if (!unlocked) {
452
+ return;
453
+ }
454
+ let cancelled = false;
455
+ const lockGeneration = lockGenerationRef.current;
456
+ void decryptEncryptedRows(messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)), unlocked).then((decrypted) => {
457
+ // A lock while this was in flight already cleared `decryptedRows` - never put them back.
458
+ if (!cancelled && lockGeneration === lockGenerationRef.current && Object.keys(decrypted).length > 0) {
459
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
460
+ }
461
+ });
462
+ return () => {
463
+ cancelled = true;
464
+ };
465
+ }, [messages, unlockRefresh]);
466
+ // Locking a mailbox's keys (idle timeout, "Destroy keys on this device now", sign-out) must take its
467
+ // decrypted content off screen too, not just out of memory: decrypted subjects/previews, search
468
+ // snippets (Tier 2/3 snippets are decrypted content), and the already-decrypted Tier 3 candidates. A
469
+ // live search re-runs, so Tier 2/3 contribute nothing again until the user unlocks. Read through a ref
470
+ // so the subscription itself doesn't churn on every render.
471
+ const keyLockStateRef = useRef({ activeMailboxUid, isSearching });
472
+ keyLockStateRef.current = { activeMailboxUid, isSearching };
473
+ useEffect(() => subscribeKeySession(({ mailboxUid: changedMailboxUid, state }) => {
474
+ const current = keyLockStateRef.current;
475
+ if (state !== "locked" || changedMailboxUid !== current.activeMailboxUid) {
476
+ return;
477
+ }
478
+ lockGenerationRef.current += 1;
479
+ setDecryptedRows({});
480
+ setSnippets({});
481
+ tier3CacheRef.current.clear();
482
+ if (current.isSearching) {
483
+ setUnlockRefresh((n) => n + 1);
484
+ }
485
+ }), []);
486
+ async function handleUnlockList() {
487
+ try {
488
+ const unlocked = await requestUnlock(activeMailboxUid, mailboxKeys);
489
+ const lockGeneration = lockGenerationRef.current;
490
+ const decrypted = await decryptEncryptedRows(messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid), unlocked);
491
+ if (lockGeneration === lockGenerationRef.current) {
492
+ setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
493
+ }
494
+ }
495
+ catch {
496
+ // User dismissed the unlock dialog - rows stay exactly as they were.
497
+ }
498
+ }
499
+ async function handleUnlockSearch() {
500
+ try {
501
+ await requestUnlock(activeMailboxUid, mailboxKeys);
502
+ setUnlockRefresh((n) => n + 1);
503
+ }
504
+ catch {
505
+ // User dismissed the unlock dialog - the search results stay exactly as they were.
506
+ }
507
+ }
133
508
  // Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
134
- // handed to every `MessageDetailPane` instance below. A failure here just means the Labels control
135
- // stays hidden (an empty `labels` array) rather than blocking the rest of the inbox - it's a small
136
- // enhancement, not critical path the way the message list itself is. No `mailboxUid` guard needed -
137
- // `MailShell` never renders this component at all until `mailboxUid` has resolved (same invariant
138
- // `searchMessages(mailboxUid!, ...)` below already relies on).
509
+ // handed to the detail pane below. A failure here just means the Labels control stays hidden (an empty
510
+ // `labels` array) rather than blocking the rest of the inbox. Keyed on the *selected message's own*
511
+ // mailbox: aggregate views and search results can show messages from a mailbox other than
512
+ // `activeMailboxUid`, and offering that mailbox's labels would let a label from the wrong mailbox be
513
+ // applied. The conversation view stays scoped to `activeMailboxUid`, the mailbox its threads come from.
514
+ const selectedMessageMailboxUid = messages.find((m) => m.uid === selectedUid)?.mailboxUid;
515
+ const labelsMailboxUid = viewMode === "date" ? (selectedMessageMailboxUid ?? activeMailboxUid) : activeMailboxUid;
516
+ // `labelsMailboxUid` is always set: `MailShell` only renders this component once at least one mailbox
517
+ // exists, so `activeMailboxUid` (its fallback) always resolves.
139
518
  useEffect(() => {
140
- listLabels(mailboxUid, { limit: 200 })
141
- .then(setLabels)
142
- .catch(() => setLabels([]));
143
- }, [mailboxUid]);
519
+ let cancelled = false;
520
+ setLabels([]);
521
+ listLabels(labelsMailboxUid, { limit: 200 })
522
+ .then((result) => {
523
+ if (!cancelled) {
524
+ setLabels(result);
525
+ }
526
+ })
527
+ .catch(() => undefined);
528
+ return () => {
529
+ cancelled = true;
530
+ };
531
+ }, [labelsMailboxUid]);
144
532
  // Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
145
533
  useEffect(() => {
146
534
  const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
@@ -153,20 +541,63 @@ function InboxContent() {
153
541
  setSelectedUid(null);
154
542
  setSelectedConversationId(null);
155
543
  setClassificationFilter("all");
156
- pageRef.current = 0;
157
- cursorRef.current = undefined;
544
+ listedOffsetRef.current = 0;
545
+ compositeCursorRef.current = undefined;
158
546
  setHasMore(false);
547
+ setLoadMoreError(null);
548
+ setLoadMoreStalled(false);
549
+ emptyPageStreakRef.current = 0;
550
+ // Every run - search or not - supersedes whatever an earlier run (or a `loadMore()` it started)
551
+ // still has in flight; each async callback below checks this before touching state.
552
+ searchRunIdRef.current += 1;
553
+ const myRunId = searchRunIdRef.current;
554
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
555
+ // Also invalidates on unmount, so nothing lands after the component is gone. Unconditional: this
556
+ // cleanup only ever runs for the latest run of this effect (React runs it before the next run
557
+ // bumps the id, or on unmount), so the id is always still `myRunId` here.
558
+ const invalidate = () => {
559
+ searchRunIdRef.current += 1;
560
+ };
159
561
  if (viewMode === "conversation") {
160
- // `mailboxUid` is always set by this point — `MailShell` only ever resolves `folderUid`
161
- // (this component's own guard just below, gating everything before this effect can even
162
- // run with `viewMode === "conversation"`) after `mailboxUid` is already known.
562
+ // Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
563
+ // aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
564
+ // (always set - `MailShell` only renders this component once at least one mailbox exists).
163
565
  setLoading(true);
164
566
  setError(null);
165
- listConversations(mailboxUid)
166
- .then(setConversations)
167
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load conversations."))
168
- .finally(() => setLoading(false));
169
- return;
567
+ listConversations(activeMailboxUid)
568
+ .then((result) => {
569
+ if (isCurrentRun()) {
570
+ setConversations(result);
571
+ }
572
+ })
573
+ .catch((err) => {
574
+ if (isCurrentRun()) {
575
+ setError(err instanceof ApiRequestError ? err.message : "Could not load conversations.");
576
+ }
577
+ })
578
+ .finally(() => {
579
+ if (isCurrentRun()) {
580
+ setLoading(false);
581
+ }
582
+ });
583
+ return invalidate;
584
+ }
585
+ if (aggregateFolderType) {
586
+ setLoading(true);
587
+ setError(null);
588
+ // hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
589
+ void fetchAggregateMessages(mailboxFolders, aggregateFolderType)
590
+ .then((results) => {
591
+ if (isCurrentRun()) {
592
+ setMessages(results);
593
+ }
594
+ })
595
+ .finally(() => {
596
+ if (isCurrentRun()) {
597
+ setLoading(false);
598
+ }
599
+ });
600
+ return invalidate;
170
601
  }
171
602
  if (!folderUid) {
172
603
  setMessages([]);
@@ -176,74 +607,264 @@ function InboxContent() {
176
607
  setLoading(true);
177
608
  setError(null);
178
609
  if (isSearching) {
179
- searchMessages(mailboxUid, searchQuery)
180
- .then(({ messages: results, nextCursor, snippets: newSnippets }) => {
181
- setMessages(results);
182
- setSnippets(newSnippets);
183
- setHasMore(!!nextCursor);
184
- cursorRef.current = nextCursor;
185
- })
186
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Search failed."))
187
- .finally(() => setLoading(false));
188
- return;
610
+ setCoverage(undefined);
611
+ setPendingUids(new Set());
612
+ setTier1Done(false);
613
+ setTier2Done(false);
614
+ setTier3Done(false);
615
+ resolvedMessageCacheRef.current = new Map();
616
+ const parsed = parseSearchQuery(searchQuery);
617
+ const unlocked = getUnlockedKeys(mailboxUid);
618
+ const fingerprint = queryFingerprint(parsed);
619
+ // Recomputes and re-renders the merged list from whatever tiers have reported so far -
620
+ // called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
621
+ // skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
622
+ // §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
623
+ // newer search pass has since started.
624
+ async function reveal(tier1Hits, tier2Hits, tier3Hits, final) {
625
+ const capped = capSkeletons(tier1Hits);
626
+ const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
627
+ const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
628
+ const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
629
+ const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
630
+ if (searchRunIdRef.current !== myRunId) {
631
+ return;
632
+ }
633
+ setMessages(resolved);
634
+ setSnippets(resolvedSnippets);
635
+ if (final) {
636
+ setPendingUids(new Set());
637
+ }
638
+ else {
639
+ const pending = new Set();
640
+ for (const hit of capped) {
641
+ if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
642
+ pending.add(hit.entityUid);
643
+ }
644
+ }
645
+ setPendingUids(pending);
646
+ }
647
+ }
648
+ void (async () => {
649
+ try {
650
+ const [tier1Page, tier2Page] = await Promise.all([
651
+ searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid)),
652
+ searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
653
+ ]);
654
+ if (searchRunIdRef.current !== myRunId) {
655
+ return;
656
+ }
657
+ setTier1Done(true);
658
+ setTier2Done(true);
659
+ setCoverage(tier2Page.coverage);
660
+ setLoading(false);
661
+ await reveal(tier1Page.results, tier2Page.results, [], false);
662
+ const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
663
+ const cacheKey = tier3CacheKey(mailboxUid, windows, !!unlocked);
664
+ let tier3Full = tier3CacheRef.current.get(cacheKey);
665
+ if (!tier3Full) {
666
+ tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid);
667
+ if (searchRunIdRef.current !== myRunId) {
668
+ return;
669
+ }
670
+ tier3CacheRef.current.set(cacheKey, tier3Full);
671
+ }
672
+ const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
673
+ setTier3Done(true);
674
+ compositeCursorRef.current = {
675
+ tier1Cursor: tier1Page.nextCursor,
676
+ tier2Offset: tier2Page.results.length,
677
+ tier3Offset: tier3Page.length,
678
+ tier3Key: cacheKey,
679
+ fingerprint,
680
+ };
681
+ setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
682
+ await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
683
+ }
684
+ catch (err) {
685
+ if (searchRunIdRef.current === myRunId) {
686
+ setError(err instanceof ApiRequestError ? err.message : "Search failed.");
687
+ setLoading(false);
688
+ }
689
+ }
690
+ })();
691
+ return invalidate;
189
692
  }
190
693
  listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
191
694
  .then((results) => {
192
- setMessages(results);
193
- setHasMore(results.length === MESSAGE_PAGE_SIZE);
695
+ if (isCurrentRun()) {
696
+ setMessages(results);
697
+ listedOffsetRef.current = results.length;
698
+ setHasMore(results.length === MESSAGE_PAGE_SIZE);
699
+ }
194
700
  })
195
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load messages."))
196
- .finally(() => setLoading(false));
197
- }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery]);
701
+ .catch((err) => {
702
+ if (isCurrentRun()) {
703
+ setError(err instanceof ApiRequestError ? err.message : "Could not load messages.");
704
+ }
705
+ })
706
+ .finally(() => {
707
+ if (isCurrentRun()) {
708
+ setLoading(false);
709
+ }
710
+ });
711
+ return invalidate;
712
+ // `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
713
+ // mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
714
+ // contribute nothing without unlocked keys, so this is what actually makes them appear once the
715
+ // user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
716
+ // has any effect on the non-search branch below; re-running it with identical inputs just
717
+ // re-fetches the same page.
718
+ }, [viewMode, folderUid, mailboxUid, isSearching, searchQuery, unlockRefresh, searchAllMail, aggregateFolderType, mailboxFolders, activeMailboxUid]);
719
+ function handleSearchAllMail() {
720
+ setSearchAllMailKey(searchAllMailScope);
721
+ }
198
722
  const loadMore = useCallback(async () => {
199
- if (loadingMore || !hasMore || loading || viewMode !== "date" || !folderUid) {
723
+ const parsed = parseSearchQuery(searchQuery);
724
+ const fingerprint = queryFingerprint(parsed);
725
+ // While searching, a load-more continues from this query's own cursor - absent until its first page
726
+ // has fully finished (the ref may still hold an earlier query's), in which case there's nowhere to
727
+ // continue from yet.
728
+ const searchCursor = decodeCursor(compositeCursorRef.current, fingerprint);
729
+ if (loadMoreInFlightRef.current ||
730
+ !hasMore ||
731
+ loading ||
732
+ viewMode !== "date" ||
733
+ !folderUid ||
734
+ (isSearching && !searchCursor)) {
200
735
  return;
201
736
  }
737
+ // A ref, not `loadingMore` state: the observer and the continuation effect below can both call in the
738
+ // same tick, each closing over a render where `loadingMore` was still false.
739
+ loadMoreInFlightRef.current = true;
740
+ setLoadMoreError(null);
741
+ setLoadMoreStalled(false);
202
742
  setLoadingMore(true);
743
+ /** Records whether a landed page added rows, and whether the continuation effect should keep going. */
744
+ const notePageLanded = (addedRows, moreRemain) => {
745
+ if (addedRows) {
746
+ emptyPageStreakRef.current = 0;
747
+ setAppendedPageCount((n) => n + 1);
748
+ }
749
+ else if (moreRemain) {
750
+ emptyPageStreakRef.current += 1;
751
+ if (emptyPageStreakRef.current <= MAX_EMPTY_PAGE_CONTINUATIONS) {
752
+ setAppendedPageCount((n) => n + 1);
753
+ }
754
+ else {
755
+ emptyPageStreakRef.current = 0;
756
+ setLoadMoreStalled(true);
757
+ }
758
+ }
759
+ };
760
+ // A query/folder/view change while this page is in flight bumps the run id (see the effect above) -
761
+ // its rows then belong to a list that's no longer on screen and must not be appended to the new one.
762
+ const myRunId = searchRunIdRef.current;
763
+ const isCurrentRun = () => searchRunIdRef.current === myRunId;
203
764
  try {
204
765
  if (isSearching) {
205
- const { messages: more, nextCursor, snippets: moreSnippets } = await searchMessages(mailboxUid, searchQuery, cursorRef.current);
206
- setMessages((prev) => [...prev, ...more]);
766
+ const unlocked = getUnlockedKeys(mailboxUid);
767
+ const cursor = searchCursor;
768
+ const [tier1Page, tier2Page] = await Promise.all([
769
+ searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid)),
770
+ searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
771
+ ]);
772
+ // The first page cached this pass under `tier3Key` before it created the cursor.
773
+ const tier3Full = tier3CacheRef.current.get(cursor.tier3Key);
774
+ const tier3Offset = cursor.tier3Offset;
775
+ const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
776
+ // Unlike the fresh-search pass above, a load-more page is resolved and appended in one
777
+ // shot rather than progressively revealed - rows already on screen shouldn't reorder or
778
+ // grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
779
+ // metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
780
+ // confirms simply keeps showing its existing placeholder text rather than a live skeleton
781
+ // - a deliberate, documented scope trim of progressive reveal to the first page only.
782
+ const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
783
+ const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
784
+ if (!isCurrentRun()) {
785
+ return;
786
+ }
787
+ const addedRows = hasUnseenMessages(messagesRef.current, more);
788
+ setMessages((prev) => appendUnseenMessages(prev, more));
207
789
  setSnippets((prev) => ({ ...prev, ...moreSnippets }));
208
- setHasMore(!!nextCursor);
209
- cursorRef.current = nextCursor;
790
+ const nextTier3Offset = tier3Offset + tier3Page.length;
791
+ compositeCursorRef.current = {
792
+ tier1Cursor: tier1Page.nextCursor,
793
+ tier2Offset: cursor.tier2Offset + tier2Page.results.length,
794
+ tier3Offset: nextTier3Offset,
795
+ tier3Key: cursor.tier3Key,
796
+ fingerprint,
797
+ };
798
+ const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
799
+ setHasMore(moreRemain);
800
+ notePageLanded(addedRows, moreRemain);
210
801
  }
211
802
  else {
212
- const nextPage = pageRef.current + 1;
213
- const more = await listMessages(folderUid, { page: nextPage, limit: MESSAGE_PAGE_SIZE });
214
- setMessages((prev) => [...prev, ...more]);
803
+ // The page containing the first message not fetched yet. After a local removal that offset is
804
+ // no longer a page boundary, so this page overlaps rows already shown - `appendUnseenMessages()`
805
+ // drops those - rather than skipping the message that shifted back across the boundary.
806
+ const page = Math.floor(listedOffsetRef.current / MESSAGE_PAGE_SIZE);
807
+ const more = await listMessages(folderUid, { page, limit: MESSAGE_PAGE_SIZE });
808
+ if (!isCurrentRun()) {
809
+ return;
810
+ }
811
+ const addedRows = hasUnseenMessages(messagesRef.current, more);
812
+ setMessages((prev) => appendUnseenMessages(prev, more));
215
813
  setHasMore(more.length === MESSAGE_PAGE_SIZE);
216
- pageRef.current = nextPage;
814
+ listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
815
+ notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
217
816
  }
218
817
  }
219
818
  catch (err) {
220
- setError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
819
+ // Shown next to the sentinel with a Retry button - never auto-retried (see the continuation
820
+ // effect below), so a failing server isn't hammered while the sentinel stays in view.
821
+ if (isCurrentRun()) {
822
+ setLoadMoreError(err instanceof ApiRequestError ? err.message : "Could not load more messages.");
823
+ }
221
824
  }
222
825
  finally {
826
+ loadMoreInFlightRef.current = false;
223
827
  setLoadingMore(false);
224
828
  }
225
- }, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery]);
829
+ }, [hasMore, loading, viewMode, folderUid, isSearching, searchQuery, mailboxUid]);
226
830
  // Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
227
831
  // dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
228
832
  const loadMoreRef = useRef(loadMore);
229
833
  loadMoreRef.current = loadMore;
230
- // `hasMore` is deliberately a dependency: the sentinel div only renders while `hasMore` is true (see
231
- // the JSX below), so this effect must re-run when it flips - otherwise a run that fires before the
232
- // first page of results has loaded (sentinel not in the DOM yet, `sentinelRef.current` still null)
233
- // would bail out once and never attach an observer to the sentinel that appears moments later.
834
+ // Keyed on the sentinel node itself (see `sentinel`'s own comment): it unmounts whenever the list shows
835
+ // "Loading..." and remounts afterwards, so an observer attached once to an earlier node would watch a
836
+ // detached element forever. Only ever rendered in "By date" mode, so no view-mode check is needed.
234
837
  useEffect(() => {
235
- const sentinel = sentinelRef.current;
236
- if (!sentinel || viewMode !== "date") {
838
+ if (!sentinel) {
237
839
  return;
238
840
  }
239
841
  const observer = new IntersectionObserver((entries) => {
240
- if (entries[0]?.isIntersecting) {
842
+ if (entries.some((entry) => entry.isIntersecting) && !loadMoreErrorRef.current) {
241
843
  void loadMoreRef.current();
242
844
  }
243
- }, { root: scrollContainerRef.current, rootMargin: "200px" });
845
+ }, { root: scrollContainerRef.current, rootMargin: `${LOAD_MORE_ROOT_MARGIN_PX}px` });
244
846
  observer.observe(sentinel);
245
847
  return () => observer.disconnect();
246
- }, [viewMode, folderUid, isSearching, searchQuery, hasMore]);
848
+ }, [sentinel]);
849
+ // An observer only reports *changes* - a sentinel still in view after a page lands (the new rows didn't
850
+ // push it out of view, e.g. the Focused/Other filter hid every one of them) never reports again, so
851
+ // keep loading while it's still in view. Only after a page that appended rows, or a full page of rows
852
+ // already shown (bounded - see `emptyPageStreakRef`), never after a failure, and only when the sentinel's
853
+ // real geometry says it's still in view (the observer's last report may predate the rows that just landed).
854
+ useEffect(() => {
855
+ if (appendedPageCount === 0) {
856
+ return;
857
+ }
858
+ // The scroll container is always mounted whenever a sentinel is (the sentinel lives inside it).
859
+ if (sentinel && isWithinLoadMoreRange(sentinel, scrollContainerRef.current)) {
860
+ void loadMoreRef.current();
861
+ }
862
+ }, [appendedPageCount]);
863
+ function removeListedMessage(uid) {
864
+ setMessages((prev) => prev.filter((m) => m.uid !== uid));
865
+ listedOffsetRef.current = Math.max(0, listedOffsetRef.current - 1);
866
+ setSelectedUid(null);
867
+ }
247
868
  const selected = messages.find((m) => m.uid === selectedUid) ?? null;
248
869
  const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
249
870
  const attachments = useMessageAttachments(selected);
@@ -251,20 +872,26 @@ function InboxContent() {
251
872
  // Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
252
873
  // selected message's own folderUid is the only reliable source for its actual folder type once
253
874
  // searching (outside search, every message in `messages` already comes from `folderUid` itself, so
254
- // this falls back to the sidebar selection unchanged).
255
- const selectedFolderUid = isSearching ? (selected?.folderUid ?? folderUid) : folderUid;
875
+ // this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
876
+ // the folder list consulted is the selected message's own mailbox's, not the shell's ambient one.
877
+ const spansFolders = isSearching || !!aggregateFolderType;
878
+ const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
879
+ const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
880
+ const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
256
881
  const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
257
882
  const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
258
883
  const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
259
884
  const draftsFolderUid = folders.find((f) => f.type === "drafts")?.uid;
260
885
  // Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
261
886
  // sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
262
- // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other.
263
- const visibleMessages = !isSearching && isInbox && classificationFilter !== "all"
887
+ // absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other. Not
888
+ // offered for an aggregate view (each mailbox classifies independently; merging that is out of scope).
889
+ const visibleMessages = !isSearching && !aggregateFolderType && isInbox && classificationFilter !== "all"
264
890
  ? messages.filter((m) => classificationFilter === "other"
265
891
  ? m.inferenceClassification === "other"
266
892
  : m.inferenceClassification !== "other")
267
893
  : messages;
894
+ const loadMoreStatus = loadingMore ? ("Loading more…") : loadMoreError ? (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { role: "alert", className: "text-danger", children: loadMoreError }), _jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Retry" })] })) : loadMoreStalled ? (_jsx("button", { type: "button", onClick: () => void loadMoreRef.current(), className: "text-primary-dark hover:underline font-medium", children: "Load more" })) : null;
268
895
  function handleSelect(message) {
269
896
  if (isMobile) {
270
897
  window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
@@ -285,7 +912,7 @@ function InboxContent() {
285
912
  }
286
913
  setSelectedConversationId(conversation.conversationId);
287
914
  }
288
- if (!folderUid) {
915
+ if (!folderUid && !aggregateFolderType) {
289
916
  // `MailShell` never renders this component at all until a mailbox is resolved (see its own
290
917
  // full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
291
918
  // that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
@@ -299,30 +926,36 @@ function InboxContent() {
299
926
  ].join(" "), children: "By date" }), _jsx("button", { type: "button", onClick: () => setViewMode("conversation"), className: [
300
927
  "flex-1 py-2 font-semibold",
301
928
  viewMode === "conversation" ? "text-primary-dark border-b-2 border-primary-dark" : "text-text-muted",
302
- ].join(" "), children: "By conversation" })] }), viewMode === "conversation" && (_jsx("p", { className: "p-3 text-xs text-text-muted border-b border-border", children: "Showing every conversation in this mailbox \u2014 the selected folder doesn't filter this view." })), viewMode === "date" && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: "Search all mail\u2026", "aria-label": "Search all mail", className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface" }) })), viewMode === "date" && isInbox && !isSearching && (_jsx("div", { className: "flex border-b border-border text-xs", children: ["all", "focused", "other"].map((value) => (_jsx("button", { type: "button", onClick: () => setClassificationFilter(value), className: [
929
+ ].join(" "), children: "By conversation" })] }), viewMode === "conversation" && (_jsx("p", { className: "p-3 text-xs text-text-muted border-b border-border", children: "Showing every conversation in this mailbox \u2014 the selected folder doesn't filter this view." })), viewMode === "date" && (_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { type: "search", value: searchInput, onChange: (e) => setSearchInput(e.target.value), placeholder: aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…", "aria-label": "Search all mail", disabled: !!aggregateFolderType, className: "w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55" }) })), viewMode === "date" && isInbox && !isSearching && !aggregateFolderType && (_jsx("div", { className: "flex border-b border-border text-xs", children: ["all", "focused", "other"].map((value) => (_jsx("button", { type: "button", onClick: () => setClassificationFilter(value), className: [
303
930
  "flex-1 py-1.5 font-semibold",
304
931
  classificationFilter === value
305
932
  ? "text-primary-dark border-b-2 border-primary-dark"
306
933
  : "text-text-muted",
307
- ].join(" "), children: value === "all" ? "All" : value === "focused" ? "Focused" : "Other" }, value))) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : viewMode === "conversation" ? (_jsx(ConversationList, { conversations: conversations, selectedId: selectedConversationId, onSelect: handleSelectConversation })) : visibleMessages.length === 0 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
308
- ? `No messages match "${searchQuery}".`
309
- : classificationFilter === "all"
310
- ? "No messages in this folder."
311
- : "No messages here." })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: visibleMessages.map((message) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => handleSelect(message), className: [
934
+ ].join(" "), children: value === "all" ? "All" : value === "focused" ? "Focused" : "Other" }, value))) })), isSearching && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2", children: [_jsx("span", { children: tier1Done && tier2Done && tier3Done
935
+ ? `${visibleMessages.length} result${visibleMessages.length === 1 ? "" : "s"}`
936
+ : `${visibleMessages.length} of ??` }), tier2Done && !searchAllMail && (_jsx("button", { type: "button", onClick: handleSearchAllMail, className: "text-primary-dark hover:underline font-medium shrink-0", children: "Search all mail" }))] })), isSearching && coverage?.indexedFrom && (_jsxs("div", { className: "px-4 py-1.5 text-xs text-text-muted border-b border-border", children: ["Local search covers messages back to ", new Date(coverage.indexedFrom).toLocaleDateString(), coverage.building ? " (still building)" : "", searchAllMail
937
+ ? " - searching everything, not just recent mail."
938
+ : " - older encrypted mail is still searched, just slower."] })), isSearching && !getUnlockedKeys(mailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockSearch, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to include encrypted messages in these results"] }) })), !isSearching && viewMode === "date" && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid) && (_jsx("div", { className: "px-4 py-2 border-b border-border bg-surface-alt", children: _jsxs("button", { type: "button", onClick: handleUnlockList, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to show ", undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'", " subject"] }) })), error && (_jsx("div", { className: "p-4", children: _jsx(Alert, { children: error }) })), loading ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: "Loading\u2026" })) : viewMode === "conversation" ? (_jsx(ConversationList, { conversations: conversations, selectedId: selectedConversationId, onSelect: handleSelectConversation })) : visibleMessages.length === 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
939
+ ? `No messages match "${searchQuery}".`
940
+ : classificationFilter === "all"
941
+ ? "No messages in this folder."
942
+ : "No messages here." }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus }))] })) : (_jsxs(_Fragment, { children: [_jsx("ul", { children: visibleMessages.map((message) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => handleSelect(message), className: [
312
943
  "w-full text-left px-4 py-3 border-b border-border",
313
944
  message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
314
945
  message.flags.read ? "" : "font-semibold",
315
- ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("div", { className: "text-sm truncate", children: message.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: snippets[message.uid] || message.bodyPreview })] }) }, message.uid))) }), hasMore && (_jsx("div", { ref: sentinelRef, className: "p-4 text-center text-xs text-text-muted", children: loadingMore ? "Loading more…" : "" }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0", children: viewMode === "conversation" ? (_jsx(ConversationThreadPane, { conversation: selectedConversation, folders: folders, labels: labels })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), isOutbox: isOutbox, isInbox: isInbox, onClassified: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), onReceiptHandled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: (updated) => {
946
+ ].join(" "), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "truncate", children: message.from.displayName || message.from.address }), _jsx("span", { className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), aggregateFolderType && (_jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName })), isSearching && pendingUids.has(message.uid) ? (_jsxs("div", { className: "flex flex-col gap-1.5 py-0.5", children: [_jsx(Skeleton, { height: "h-3.5", className: "w-2/3 rounded-sm" }), _jsx(Skeleton, { height: "h-3", className: "w-full rounded-sm" })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-sm truncate", children: decryptedRows[message.uid]?.subject ||
947
+ (message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER
948
+ ? "Encrypted message"
949
+ : message.subject) ||
950
+ "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview })] }))] }) }, message.uid))) }), hasMore && (_jsx("div", { ref: setSentinel, "data-testid": "load-more-sentinel", className: "p-4 text-center text-xs text-text-muted", children: loadMoreStatus })), aggregateFolderType && (_jsx("p", { className: "p-4 text-center text-xs text-text-muted", children: "Showing the most recent mail from each mailbox. Open a specific mailbox\u2019s folder to see older mail." }))] }))] }), _jsx("div", { className: "hidden md:flex flex-1 min-w-0", children: viewMode === "conversation" ? (_jsx(ConversationThreadPane, { conversation: selectedConversation, folders: mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? [], labels: labels })) : (_jsx(MessageDetailPane, { message: selected, attachments: attachments, isSentItems: isSentItems, onRecalled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), isOutbox: isOutbox, isInbox: isInbox, onClassified: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), onReceiptHandled: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))), draftsFolderUid: draftsFolderUid, onScheduledSendCanceled: (updated) => {
316
951
  // The message moved out of the currently-viewed Outbox folder (into Drafts)
317
952
  // — unlike a recall, which patches a message in place, this removes it from
318
953
  // the list entirely, matching what a real folder switch would show.
319
- setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
320
- setSelectedUid(null);
954
+ removeListedMessage(updated.uid);
321
955
  }, onArchived: (updated) => {
322
956
  // Same reasoning as onScheduledSendCanceled above — the message moved out of
323
957
  // whichever folder is currently being viewed (into Archive), so it's removed
324
958
  // from the list rather than patched in place.
325
- setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
326
- setSelectedUid(null);
959
+ removeListedMessage(updated.uid);
327
960
  }, labels: labels, onLabelsChanged: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))) })) })] }));
328
961
  }