@rapidmx/web-client 0.4.0 → 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.
- package/apps/admin/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
package/dist/apps/www/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
|
14
14
|
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
15
15
|
import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
|
|
16
16
|
import { searchLocalIndex } from "../shared/search/searchTier2.js";
|
|
17
|
-
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
17
|
+
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
18
18
|
import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
19
19
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
20
20
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
@@ -123,8 +123,11 @@ function mergeSearchResults(tier1, tier2, tier3) {
|
|
|
123
123
|
* (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
|
|
124
124
|
* larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
|
|
125
125
|
* below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
|
|
126
|
-
function tier1SearchParams(parsed, cursor) {
|
|
126
|
+
function tier1SearchParams(parsed, cursor, mailboxUid) {
|
|
127
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,
|
|
128
131
|
types: parsed.entityTypes ?? ["message"],
|
|
129
132
|
cursor,
|
|
130
133
|
limit: MESSAGE_PAGE_SIZE,
|
|
@@ -169,8 +172,12 @@ function capSkeletons(tier1Hits) {
|
|
|
169
172
|
* extraction); a query whose true candidate set exceeds this simply pages out once this cache is
|
|
170
173
|
* exhausted; `loadMore()` reflects that honestly via `hasMore`. */
|
|
171
174
|
const TIER3_CANDIDATE_LIMIT = 200;
|
|
172
|
-
|
|
173
|
-
|
|
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)}`;
|
|
174
181
|
}
|
|
175
182
|
/** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
|
|
176
183
|
* distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
|
|
@@ -180,18 +187,44 @@ function tier3CacheKey(mailboxUid, fingerprint, searchAllMail, unlocked) {
|
|
|
180
187
|
function queryFingerprint(parsed) {
|
|
181
188
|
return JSON.stringify(parsed);
|
|
182
189
|
}
|
|
183
|
-
/** Tier 3's own
|
|
184
|
-
* unless the reader explicitly asked to "Search all mail"
|
|
185
|
-
* current content for
|
|
186
|
-
* through Tier 3's slower candidate-narrowing path would be pure waste.
|
|
187
|
-
* `before:`
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
197
|
+
*
|
|
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
|
+
}
|
|
191
226
|
}
|
|
192
|
-
|
|
193
|
-
const effectiveBefore = parsed.before && parsed.before.getTime() < coverageBound.getTime() ? parsed.before : coverageBound;
|
|
194
|
-
return { ...parsed, before: effectiveBefore };
|
|
227
|
+
return [...merged.values()];
|
|
195
228
|
}
|
|
196
229
|
/** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
|
|
197
230
|
* cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
|
|
@@ -225,6 +258,37 @@ async function resolveHitsToMessages(hits, cache) {
|
|
|
225
258
|
}
|
|
226
259
|
return { messages, snippets };
|
|
227
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
|
+
}
|
|
228
292
|
/** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
|
|
229
293
|
* Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
|
|
230
294
|
* natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
|
|
@@ -257,9 +321,9 @@ async function fetchAggregateMessages(mailboxFolders, type) {
|
|
|
257
321
|
return mergeInboxMessages(perMailbox);
|
|
258
322
|
}
|
|
259
323
|
export default function InboxPage(props) {
|
|
260
|
-
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, {}) }));
|
|
324
|
+
return (_jsx(MailShell, { ...props, children: _jsx(InboxContent, { userUid: props.userUid }) }));
|
|
261
325
|
}
|
|
262
|
-
function InboxContent() {
|
|
326
|
+
function InboxContent({ userUid }) {
|
|
263
327
|
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
|
|
264
328
|
const isMobile = useIsMobile();
|
|
265
329
|
const { requestUnlock } = useUnlockPrompt();
|
|
@@ -281,8 +345,9 @@ function InboxContent() {
|
|
|
281
345
|
// before Tier 2 has resolved yet for this search pass.
|
|
282
346
|
const [coverage, setCoverage] = useState(undefined);
|
|
283
347
|
// Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
|
|
284
|
-
// decrypted row stays decrypted
|
|
285
|
-
// waste work for no benefit)
|
|
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).
|
|
286
351
|
const [decryptedRows, setDecryptedRows] = useState({});
|
|
287
352
|
// Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
|
|
288
353
|
// dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
|
|
@@ -301,21 +366,47 @@ function InboxContent() {
|
|
|
301
366
|
const [tier3Done, setTier3Done] = useState(false);
|
|
302
367
|
// Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
|
|
303
368
|
// bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
|
|
304
|
-
// re-run.
|
|
305
|
-
|
|
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;
|
|
306
375
|
// Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
|
|
307
376
|
// to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
|
|
308
377
|
// branch below, which the search effect never reaches while `folderUid` is unset).
|
|
309
378
|
const isSearching = viewMode === "date" && searchQuery.length > 0 && !aggregateFolderType;
|
|
310
|
-
|
|
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);
|
|
311
384
|
const scrollContainerRef = useRef(null);
|
|
312
|
-
|
|
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;
|
|
313
404
|
// The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
|
|
314
405
|
// one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
|
|
315
406
|
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
316
407
|
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
317
408
|
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
318
|
-
const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid)?.uid ?? mailboxes[0]?.uid;
|
|
409
|
+
const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid === userUid)?.uid ?? mailboxes[0]?.uid;
|
|
319
410
|
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
320
411
|
// Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
|
|
321
412
|
// resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
|
|
@@ -327,12 +418,15 @@ function InboxContent() {
|
|
|
327
418
|
// of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
|
|
328
419
|
// render on its own, only what loadMore() does with it later.
|
|
329
420
|
const compositeCursorRef = useRef(undefined);
|
|
330
|
-
// Guards every async
|
|
331
|
-
// clobbering state for a newer one that started
|
|
332
|
-
//
|
|
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.
|
|
333
424
|
// `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
|
|
334
425
|
// async stages instead of one.
|
|
335
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);
|
|
336
430
|
// `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
|
|
337
431
|
// never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
|
|
338
432
|
// state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
|
|
@@ -358,21 +452,45 @@ function InboxContent() {
|
|
|
358
452
|
return;
|
|
359
453
|
}
|
|
360
454
|
let cancelled = false;
|
|
361
|
-
|
|
362
|
-
|
|
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) {
|
|
363
459
|
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
364
460
|
}
|
|
365
461
|
});
|
|
366
462
|
return () => {
|
|
367
463
|
cancelled = true;
|
|
368
464
|
};
|
|
369
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
370
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
|
+
}), []);
|
|
371
486
|
async function handleUnlockList() {
|
|
372
487
|
try {
|
|
373
488
|
const unlocked = await requestUnlock(activeMailboxUid, mailboxKeys);
|
|
489
|
+
const lockGeneration = lockGenerationRef.current;
|
|
374
490
|
const decrypted = await decryptEncryptedRows(messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid), unlocked);
|
|
375
|
-
|
|
491
|
+
if (lockGeneration === lockGenerationRef.current) {
|
|
492
|
+
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
493
|
+
}
|
|
376
494
|
}
|
|
377
495
|
catch {
|
|
378
496
|
// User dismissed the unlock dialog - rows stay exactly as they were.
|
|
@@ -388,19 +506,29 @@ function InboxContent() {
|
|
|
388
506
|
}
|
|
389
507
|
}
|
|
390
508
|
// Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
|
|
391
|
-
// handed to
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// `
|
|
395
|
-
//
|
|
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.
|
|
396
518
|
useEffect(() => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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]);
|
|
404
532
|
// Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
|
|
405
533
|
useEffect(() => {
|
|
406
534
|
const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
|
@@ -413,42 +541,63 @@ function InboxContent() {
|
|
|
413
541
|
setSelectedUid(null);
|
|
414
542
|
setSelectedConversationId(null);
|
|
415
543
|
setClassificationFilter("all");
|
|
416
|
-
|
|
544
|
+
listedOffsetRef.current = 0;
|
|
417
545
|
compositeCursorRef.current = undefined;
|
|
418
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
|
+
};
|
|
419
561
|
if (viewMode === "conversation") {
|
|
420
562
|
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
421
|
-
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use
|
|
422
|
-
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
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).
|
|
425
565
|
setLoading(true);
|
|
426
566
|
setError(null);
|
|
427
567
|
listConversations(activeMailboxUid)
|
|
428
|
-
.then(
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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;
|
|
432
584
|
}
|
|
433
585
|
if (aggregateFolderType) {
|
|
434
586
|
setLoading(true);
|
|
435
587
|
setError(null);
|
|
436
|
-
let cancelled = false;
|
|
437
588
|
// hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
|
|
438
|
-
fetchAggregateMessages(mailboxFolders, aggregateFolderType)
|
|
589
|
+
void fetchAggregateMessages(mailboxFolders, aggregateFolderType)
|
|
439
590
|
.then((results) => {
|
|
440
|
-
if (
|
|
591
|
+
if (isCurrentRun()) {
|
|
441
592
|
setMessages(results);
|
|
442
593
|
}
|
|
443
594
|
})
|
|
444
595
|
.finally(() => {
|
|
445
|
-
if (
|
|
596
|
+
if (isCurrentRun()) {
|
|
446
597
|
setLoading(false);
|
|
447
598
|
}
|
|
448
599
|
});
|
|
449
|
-
return
|
|
450
|
-
cancelled = true;
|
|
451
|
-
};
|
|
600
|
+
return invalidate;
|
|
452
601
|
}
|
|
453
602
|
if (!folderUid) {
|
|
454
603
|
setMessages([]);
|
|
@@ -464,8 +613,6 @@ function InboxContent() {
|
|
|
464
613
|
setTier2Done(false);
|
|
465
614
|
setTier3Done(false);
|
|
466
615
|
resolvedMessageCacheRef.current = new Map();
|
|
467
|
-
searchRunIdRef.current += 1;
|
|
468
|
-
const myRunId = searchRunIdRef.current;
|
|
469
616
|
const parsed = parseSearchQuery(searchQuery);
|
|
470
617
|
const unlocked = getUnlockedKeys(mailboxUid);
|
|
471
618
|
const fingerprint = queryFingerprint(parsed);
|
|
@@ -501,7 +648,7 @@ function InboxContent() {
|
|
|
501
648
|
void (async () => {
|
|
502
649
|
try {
|
|
503
650
|
const [tier1Page, tier2Page] = await Promise.all([
|
|
504
|
-
searchMailbox(parsed.text, tier1SearchParams(parsed, undefined)),
|
|
651
|
+
searchMailbox(parsed.text, tier1SearchParams(parsed, undefined, mailboxUid)),
|
|
505
652
|
searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
|
|
506
653
|
]);
|
|
507
654
|
if (searchRunIdRef.current !== myRunId) {
|
|
@@ -512,11 +659,11 @@ function InboxContent() {
|
|
|
512
659
|
setCoverage(tier2Page.coverage);
|
|
513
660
|
setLoading(false);
|
|
514
661
|
await reveal(tier1Page.results, tier2Page.results, [], false);
|
|
515
|
-
const
|
|
516
|
-
const cacheKey = tier3CacheKey(mailboxUid,
|
|
662
|
+
const windows = tier3Windows(parsed, tier2Page.coverage, searchAllMail);
|
|
663
|
+
const cacheKey = tier3CacheKey(mailboxUid, windows, !!unlocked);
|
|
517
664
|
let tier3Full = tier3CacheRef.current.get(cacheKey);
|
|
518
665
|
if (!tier3Full) {
|
|
519
|
-
tier3Full = await
|
|
666
|
+
tier3Full = await searchTier3Windows(windows, unlocked, mailboxUid);
|
|
520
667
|
if (searchRunIdRef.current !== myRunId) {
|
|
521
668
|
return;
|
|
522
669
|
}
|
|
@@ -528,6 +675,7 @@ function InboxContent() {
|
|
|
528
675
|
tier1Cursor: tier1Page.nextCursor,
|
|
529
676
|
tier2Offset: tier2Page.results.length,
|
|
530
677
|
tier3Offset: tier3Page.length,
|
|
678
|
+
tier3Key: cacheKey,
|
|
531
679
|
fingerprint,
|
|
532
680
|
};
|
|
533
681
|
setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
|
|
@@ -540,15 +688,27 @@ function InboxContent() {
|
|
|
540
688
|
}
|
|
541
689
|
}
|
|
542
690
|
})();
|
|
543
|
-
return;
|
|
691
|
+
return invalidate;
|
|
544
692
|
}
|
|
545
693
|
listMessages(folderUid, { limit: MESSAGE_PAGE_SIZE })
|
|
546
694
|
.then((results) => {
|
|
547
|
-
|
|
548
|
-
|
|
695
|
+
if (isCurrentRun()) {
|
|
696
|
+
setMessages(results);
|
|
697
|
+
listedOffsetRef.current = results.length;
|
|
698
|
+
setHasMore(results.length === MESSAGE_PAGE_SIZE);
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
.catch((err) => {
|
|
702
|
+
if (isCurrentRun()) {
|
|
703
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load messages.");
|
|
704
|
+
}
|
|
549
705
|
})
|
|
550
|
-
.
|
|
551
|
-
|
|
706
|
+
.finally(() => {
|
|
707
|
+
if (isCurrentRun()) {
|
|
708
|
+
setLoading(false);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
return invalidate;
|
|
552
712
|
// `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
|
|
553
713
|
// mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
|
|
554
714
|
// contribute nothing without unlocked keys, so this is what actually makes them appear once the
|
|
@@ -557,26 +717,61 @@ function InboxContent() {
|
|
|
557
717
|
// re-fetches the same page.
|
|
558
718
|
}, [viewMode, folderUid, mailboxUid, isSearching, searchQuery, unlockRefresh, searchAllMail, aggregateFolderType, mailboxFolders, activeMailboxUid]);
|
|
559
719
|
function handleSearchAllMail() {
|
|
560
|
-
|
|
720
|
+
setSearchAllMailKey(searchAllMailScope);
|
|
561
721
|
}
|
|
562
722
|
const loadMore = useCallback(async () => {
|
|
563
|
-
|
|
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)) {
|
|
564
735
|
return;
|
|
565
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);
|
|
566
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;
|
|
567
764
|
try {
|
|
568
765
|
if (isSearching) {
|
|
569
|
-
const parsed = parseSearchQuery(searchQuery);
|
|
570
766
|
const unlocked = getUnlockedKeys(mailboxUid);
|
|
571
|
-
const
|
|
572
|
-
const cursor = decodeCursor(compositeCursorRef.current, fingerprint);
|
|
767
|
+
const cursor = searchCursor;
|
|
573
768
|
const [tier1Page, tier2Page] = await Promise.all([
|
|
574
|
-
searchMailbox(parsed.text, tier1SearchParams(parsed, cursor
|
|
575
|
-
searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor
|
|
769
|
+
searchMailbox(parsed.text, tier1SearchParams(parsed, cursor.tier1Cursor, mailboxUid)),
|
|
770
|
+
searchLocalIndex(mailboxUid, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor.tier2Offset),
|
|
576
771
|
]);
|
|
577
|
-
|
|
578
|
-
const tier3Full = tier3CacheRef.current.get(
|
|
579
|
-
const tier3Offset = cursor
|
|
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;
|
|
580
775
|
const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
|
|
581
776
|
// Unlike the fresh-search pass above, a load-more page is resolved and appended in one
|
|
582
777
|
// shot rather than progressively revealed - rows already on screen shouldn't reorder or
|
|
@@ -586,53 +781,90 @@ function InboxContent() {
|
|
|
586
781
|
// - a deliberate, documented scope trim of progressive reveal to the first page only.
|
|
587
782
|
const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
|
|
588
783
|
const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
589
|
-
|
|
784
|
+
if (!isCurrentRun()) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const addedRows = hasUnseenMessages(messagesRef.current, more);
|
|
788
|
+
setMessages((prev) => appendUnseenMessages(prev, more));
|
|
590
789
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
591
790
|
const nextTier3Offset = tier3Offset + tier3Page.length;
|
|
592
791
|
compositeCursorRef.current = {
|
|
593
792
|
tier1Cursor: tier1Page.nextCursor,
|
|
594
|
-
tier2Offset:
|
|
793
|
+
tier2Offset: cursor.tier2Offset + tier2Page.results.length,
|
|
595
794
|
tier3Offset: nextTier3Offset,
|
|
795
|
+
tier3Key: cursor.tier3Key,
|
|
596
796
|
fingerprint,
|
|
597
797
|
};
|
|
598
|
-
|
|
798
|
+
const moreRemain = !!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length;
|
|
799
|
+
setHasMore(moreRemain);
|
|
800
|
+
notePageLanded(addedRows, moreRemain);
|
|
599
801
|
}
|
|
600
802
|
else {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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));
|
|
604
813
|
setHasMore(more.length === MESSAGE_PAGE_SIZE);
|
|
605
|
-
|
|
814
|
+
listedOffsetRef.current = page * MESSAGE_PAGE_SIZE + more.length;
|
|
815
|
+
notePageLanded(addedRows, more.length === MESSAGE_PAGE_SIZE);
|
|
606
816
|
}
|
|
607
817
|
}
|
|
608
818
|
catch (err) {
|
|
609
|
-
|
|
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
|
+
}
|
|
610
824
|
}
|
|
611
825
|
finally {
|
|
826
|
+
loadMoreInFlightRef.current = false;
|
|
612
827
|
setLoadingMore(false);
|
|
613
828
|
}
|
|
614
|
-
}, [
|
|
829
|
+
}, [hasMore, loading, viewMode, folderUid, isSearching, searchQuery, mailboxUid]);
|
|
615
830
|
// Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
|
|
616
831
|
// dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
|
|
617
832
|
const loadMoreRef = useRef(loadMore);
|
|
618
833
|
loadMoreRef.current = loadMore;
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// 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.
|
|
623
837
|
useEffect(() => {
|
|
624
|
-
|
|
625
|
-
if (!sentinel || viewMode !== "date") {
|
|
838
|
+
if (!sentinel) {
|
|
626
839
|
return;
|
|
627
840
|
}
|
|
628
841
|
const observer = new IntersectionObserver((entries) => {
|
|
629
|
-
if (entries
|
|
842
|
+
if (entries.some((entry) => entry.isIntersecting) && !loadMoreErrorRef.current) {
|
|
630
843
|
void loadMoreRef.current();
|
|
631
844
|
}
|
|
632
|
-
}, { root: scrollContainerRef.current, rootMargin:
|
|
845
|
+
}, { root: scrollContainerRef.current, rootMargin: `${LOAD_MORE_ROOT_MARGIN_PX}px` });
|
|
633
846
|
observer.observe(sentinel);
|
|
634
847
|
return () => observer.disconnect();
|
|
635
|
-
}, [
|
|
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
|
+
}
|
|
636
868
|
const selected = messages.find((m) => m.uid === selectedUid) ?? null;
|
|
637
869
|
const selectedConversation = conversations.find((c) => c.conversationId === selectedConversationId) ?? null;
|
|
638
870
|
const attachments = useMessageAttachments(selected);
|
|
@@ -659,6 +891,7 @@ function InboxContent() {
|
|
|
659
891
|
? m.inferenceClassification === "other"
|
|
660
892
|
: m.inferenceClassification !== "other")
|
|
661
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;
|
|
662
895
|
function handleSelect(message) {
|
|
663
896
|
if (isMobile) {
|
|
664
897
|
window.location.href = `/messages/${encodeURIComponent(message.uid)}`;
|
|
@@ -702,11 +935,11 @@ function InboxContent() {
|
|
|
702
935
|
? `${visibleMessages.length} result${visibleMessages.length === 1 ? "" : "s"}`
|
|
703
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
|
|
704
937
|
? " - searching everything, not just recent mail."
|
|
705
|
-
: " - 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 ? (_jsx("p", { className: "p-4 text-sm text-text-muted", children: isSearching
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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: [
|
|
710
943
|
"w-full text-left px-4 py-3 border-b border-border",
|
|
711
944
|
message.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
712
945
|
message.flags.read ? "" : "font-semibold",
|
|
@@ -714,17 +947,15 @@ function InboxContent() {
|
|
|
714
947
|
(message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER
|
|
715
948
|
? "Encrypted message"
|
|
716
949
|
: message.subject) ||
|
|
717
|
-
"(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:
|
|
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) => {
|
|
718
951
|
// The message moved out of the currently-viewed Outbox folder (into Drafts)
|
|
719
952
|
// — unlike a recall, which patches a message in place, this removes it from
|
|
720
953
|
// the list entirely, matching what a real folder switch would show.
|
|
721
|
-
|
|
722
|
-
setSelectedUid(null);
|
|
954
|
+
removeListedMessage(updated.uid);
|
|
723
955
|
}, onArchived: (updated) => {
|
|
724
956
|
// Same reasoning as onScheduledSendCanceled above — the message moved out of
|
|
725
957
|
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
726
958
|
// from the list rather than patched in place.
|
|
727
|
-
|
|
728
|
-
setSelectedUid(null);
|
|
959
|
+
removeListedMessage(updated.uid);
|
|
729
960
|
}, labels: labels, onLabelsChanged: (updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m))) })) })] }));
|
|
730
961
|
}
|