@rapidmx/web-client 0.3.0 → 0.4.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/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
package/apps/www/index.tsx
CHANGED
|
@@ -3,40 +3,125 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { HiOutlineLockClosed } from "react-icons/hi2";
|
|
6
7
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
-
import { Message, MessageClassification, getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { Mailbox, Message, MessageClassification, getMessage, getMessageRawContent, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
9
|
import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
9
10
|
import { ConversationSummary, listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
10
11
|
import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
11
|
-
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
12
|
+
import { parseSearchQuery, type ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
12
13
|
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
13
14
|
import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
|
|
14
|
-
import {
|
|
15
|
+
import { searchLocalIndex } from "../shared/search/searchTier2.js";
|
|
16
|
+
import type { Coverage } from "../shared/search/localIndexWorker.js";
|
|
17
|
+
import { getUnlockedKeys, UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
18
|
+
import { evaluateMessageSecurity } from "@rapidmx/react-shared/crypto/messageSecurity.js";
|
|
15
19
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
16
20
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
17
|
-
import MailShell, {
|
|
21
|
+
import MailShell, {
|
|
22
|
+
AggregateFolderType,
|
|
23
|
+
MailboxFolders,
|
|
24
|
+
MailShellProps,
|
|
25
|
+
useMailShell,
|
|
26
|
+
} from "../shared/components/mail/layout/MailShell.js";
|
|
18
27
|
import MessageDetailPane from "../shared/components/mail/MessageDetailPane.js";
|
|
19
28
|
import ConversationList from "../shared/components/mail/ConversationList.js";
|
|
20
29
|
import ConversationThreadPane from "../shared/components/mail/ConversationThreadPane.js";
|
|
21
30
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
31
|
+
import Skeleton from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
32
|
+
import { useUnlockPrompt } from "../shared/components/layout/UnlockPromptProvider.js";
|
|
22
33
|
|
|
23
34
|
type ViewMode = "date" | "conversation";
|
|
24
35
|
|
|
25
36
|
const MESSAGE_PAGE_SIZE = 50;
|
|
26
37
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
38
|
+
const LIST_PREVIEW_MAX_LENGTH = 160;
|
|
27
39
|
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
/** The literal outer-envelope `Subject` every encrypted message carries server-side - RFC 9788's
|
|
41
|
+
* `hcp_baseline` policy obscures it to this exact string (see `smimeMessage.ts`'s
|
|
42
|
+
* `applyBaselineOuterHeaders()`), which is also all `Message.subject` ever shows for one of these until
|
|
43
|
+
* decrypted client-side. Used here to recognize which loaded rows are worth decrypting for display. */
|
|
44
|
+
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
45
|
+
|
|
46
|
+
/** A row's client-recovered subject/preview, once decrypted - `undefined` fields mean nothing better
|
|
47
|
+
* than the placeholder/blank server value was recoverable for that field specifically. */
|
|
48
|
+
interface DecryptedRow {
|
|
49
|
+
subject?: string;
|
|
50
|
+
preview?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Strips HTML down to plain text, for a short list-row preview of a decrypted body - mirrors
|
|
54
|
+
* `searchTier3.ts`'s own private `stripHtml()` (not currently exported from `@rapidmx/react-shared`,
|
|
55
|
+
* so duplicated here rather than pulled in through a package change just for this one small, pure
|
|
56
|
+
* helper). Not a security boundary - the output only ever feeds plain text display, truncated below,
|
|
57
|
+
* never rendered back into any DOM. */
|
|
58
|
+
function stripHtmlToText(html: string): string {
|
|
59
|
+
return html
|
|
60
|
+
.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
61
|
+
.replace(/<[^>]+>/g, " ")
|
|
62
|
+
.replace(/ /gi, " ")
|
|
63
|
+
.replace(/&/gi, "&")
|
|
64
|
+
.replace(/</gi, "<")
|
|
65
|
+
.replace(/>/gi, ">")
|
|
66
|
+
.replace(/"/gi, '"')
|
|
67
|
+
.replace(/�*39;/gi, "'")
|
|
68
|
+
.replace(/\s+/g, " ")
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decrypts the subject/preview of every currently-loaded row whose subject is still the RFC 9788
|
|
74
|
+
* placeholder (i.e. every encrypted message this device hasn't already resolved), keyed by uid - the
|
|
75
|
+
* inbox-list counterpart to `MessageDetailPane`'s own single-message decrypt and `searchTier3.ts`'s
|
|
76
|
+
* per-candidate decrypt. Bounded to `messages` (at most one loaded page, `MESSAGE_PAGE_SIZE`), never the
|
|
77
|
+
* whole mailbox - matching `searchEncryptedCandidates()`'s own "bounded, not everything" scope. Runs only
|
|
78
|
+
* once `unlocked` is available (the caller decides when to call this - see `InboxContent`'s own effect
|
|
79
|
+
* and `handleUnlockList()`), and a single row's fetch/decrypt failure never blocks the rest.
|
|
80
|
+
*/
|
|
81
|
+
async function decryptEncryptedRows(messages: Message[], unlocked: UnlockedKeys): Promise<Record<string, DecryptedRow>> {
|
|
82
|
+
const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER);
|
|
83
|
+
const entries = await Promise.all(
|
|
84
|
+
encrypted.map(async (message): Promise<[string, DecryptedRow] | null> => {
|
|
85
|
+
try {
|
|
86
|
+
const rawMime = await getMessageRawContent(message.uid);
|
|
87
|
+
const security = await evaluateMessageSecurity(rawMime, unlocked);
|
|
88
|
+
if (!security.subject && !security.html) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const preview = security.html ? stripHtmlToText(security.html).slice(0, LIST_PREVIEW_MAX_LENGTH) : undefined;
|
|
92
|
+
return [message.uid, { subject: security.subject, preview }];
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const result: Record<string, DecryptedRow> = {};
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (entry) {
|
|
101
|
+
result[entry[0]] = entry[1];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message), Tier 2 (local index, fully
|
|
108
|
+
* decrypted and re-scored), and Tier 3 (server-narrowed candidates, decrypted and re-scored) results into
|
|
109
|
+
* one ranked list, per `specs/search.md` §7's "client MUST re-score all results it can see... normalise
|
|
110
|
+
* into the same space rather than interleaving raw scores": each tier is normalized independently via
|
|
111
|
+
* `normalizeServerScores()` before merging, since a Postgres/OpenSearch score, a local `bm25()` score,
|
|
112
|
+
* and this module's own Tier 3 term-count score all occupy unrelated ranges. A uid present in more than
|
|
113
|
+
* one list keeps only the last-inserted entry (Tier 2 wins over Tier 3 wins over Tier 1) - Tier 2 and
|
|
114
|
+
* Tier 3 both represent genuine, content-verified scores for the same message, so which one "wins" on
|
|
115
|
+
* overlap doesn't change correctness, only which of two equally-valid scores is shown; either supersedes
|
|
116
|
+
* Tier 1's metadata-only guess for the same uid.
|
|
117
|
+
*
|
|
118
|
+
* Called progressively - once per tier as it resolves, each time with whatever tiers have reported so
|
|
119
|
+
* far (an empty array for the rest) - by `InboxContent`'s own search orchestration below, per §_Progressive
|
|
120
|
+
* Results_' "reordering is permitted and preferred over appending." This function itself stays pure and
|
|
121
|
+
* stateless; it has no notion of "in progress" versus "final." */
|
|
122
|
+
function mergeSearchResults(tier1: SearchResult[], tier2: SearchResult[], tier3: SearchResult[]): SearchResult[] {
|
|
39
123
|
const normalizedTier1 = normalizeServerScores(tier1);
|
|
124
|
+
const normalizedTier2 = normalizeServerScores(tier2);
|
|
40
125
|
const normalizedTier3 = normalizeServerScores(tier3);
|
|
41
126
|
const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
|
|
42
127
|
for (const entry of normalizedTier1) {
|
|
@@ -45,75 +130,195 @@ function mergeSearchResults(tier1: SearchResult[], tier3: SearchResult[]): Searc
|
|
|
45
130
|
for (const entry of normalizedTier3) {
|
|
46
131
|
merged.set(entry.result.entityUid, entry);
|
|
47
132
|
}
|
|
133
|
+
for (const entry of normalizedTier2) {
|
|
134
|
+
merged.set(entry.result.entityUid, entry);
|
|
135
|
+
}
|
|
48
136
|
return Array.from(merged.values())
|
|
49
137
|
.sort((a, b) => b.normalizedScore - a.normalizedScore)
|
|
50
138
|
.map((entry) => entry.result);
|
|
51
139
|
}
|
|
52
140
|
|
|
53
|
-
/**
|
|
54
|
-
* `snippet` (keyed by message uid) for rendering in place of the plain `bodyPreview` while searching.
|
|
55
|
-
*
|
|
56
|
-
* `rawQuery` is parsed once, client-side, via `queryGrammar.ts`'s `parseSearchQuery()` — the operator
|
|
57
|
-
* grammar (`from:`/`to:`/`subject:`/`has:attachment`/`before:`/`after:`/`in:`/`is:`/`label:`/`type:`,
|
|
58
|
-
* `specs/search.md` §14) is extracted into structured filters passed to the server, while the
|
|
59
|
-
* remaining free text (quotes, `-` negation, `OR` all preserved) still drives ranking as `q`.
|
|
60
|
-
* `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
|
|
141
|
+
/** `type:` narrows `entityTypes`; when absent this still defaults to `["message"]` — a non-message hit
|
|
61
142
|
* (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
|
|
62
143
|
* dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
|
|
63
144
|
* (this inbox list only ever shows message rows; a real multi-entity-type results view is a separate,
|
|
64
|
-
* larger UI project outside this pass).
|
|
145
|
+
* larger UI project outside this pass). Shared by both the fresh-search orchestration and `loadMore()`
|
|
146
|
+
* below, which each build this from the same `ParsedSearchQuery` differently only in `cursor`. */
|
|
147
|
+
function tier1SearchParams(parsed: ParsedSearchQuery, cursor: string | undefined) {
|
|
148
|
+
return {
|
|
149
|
+
types: parsed.entityTypes ?? ["message"],
|
|
150
|
+
cursor,
|
|
151
|
+
limit: MESSAGE_PAGE_SIZE,
|
|
152
|
+
from: parsed.from,
|
|
153
|
+
to: parsed.to,
|
|
154
|
+
cc: parsed.cc,
|
|
155
|
+
subject: parsed.subject,
|
|
156
|
+
hasAttachment: parsed.hasAttachment,
|
|
157
|
+
before: parsed.before,
|
|
158
|
+
after: parsed.after,
|
|
159
|
+
folderUid: parsed.folderUid,
|
|
160
|
+
flags: parsed.flags,
|
|
161
|
+
labels: parsed.labels,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** How many of Tier 1's own `metadataOnly` hits (§7: "an encrypted entity matched only on server-visible
|
|
166
|
+
* metadata... MUST be rendered as skeleton entries in place... using the metadata score as a provisional
|
|
167
|
+
* position") are shown as skeleton rows at once - §_Progressive Results_' "Skeletons MUST be capped, at
|
|
168
|
+
* approximately one and a half pages." A non-`metadataOnly` Tier 1 hit (a real, already-scored content
|
|
169
|
+
* match — always the case for unencrypted mail) is never a skeleton and is never subject to this cap.
|
|
65
170
|
*
|
|
66
|
-
* Tier 3
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
171
|
+
* A Tier 3 candidate that Tier 1 did *not* already surface has no provisional score/position of its own
|
|
172
|
+
* under the spec's own wording above, so it is deliberately never pre-rendered as a skeleton here either
|
|
173
|
+
* - it simply appears, fully resolved, once Tier 3 confirms it (see `InboxContent`'s search
|
|
174
|
+
* orchestration). */
|
|
175
|
+
const SKELETON_CAP = Math.round(MESSAGE_PAGE_SIZE * 1.5);
|
|
176
|
+
|
|
177
|
+
function capSkeletons(tier1Hits: SearchResult[]): SearchResult[] {
|
|
178
|
+
let skeletonsSeen = 0;
|
|
179
|
+
return tier1Hits.filter((hit) => {
|
|
180
|
+
if (!hit.metadataOnly) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
skeletonsSeen += 1;
|
|
184
|
+
return skeletonsSeen <= SKELETON_CAP;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** How many candidates Tier 3 pulls per distinct search - larger than one page's worth so several
|
|
189
|
+
* `loadMore()` pages can be sliced from one decrypt pass (see `Tier3Cache` below) instead of a second,
|
|
190
|
+
* separately expensive server round trip and re-decrypt for the same query. Bounded, not unlimited - per
|
|
191
|
+
* this module's own `tier1SearchParams()` sibling, Tier 3's own candidate-narrowing is already the
|
|
192
|
+
* "heaviest single client-side cost" tier (`specs/search.md` §9's identical framing for attachment
|
|
193
|
+
* extraction); a query whose true candidate set exceeds this simply pages out once this cache is
|
|
194
|
+
* exhausted; `loadMore()` reflects that honestly via `hasMore`. */
|
|
195
|
+
const TIER3_CANDIDATE_LIMIT = 200;
|
|
196
|
+
|
|
197
|
+
/** One entry per distinct (mailbox, query, "search all mail" toggle, unlocked-or-not) combination this
|
|
198
|
+
* tab has already run Tier 3 for - keyed by `tier3CacheKey()` below. Tier 3's decrypt-and-match pass
|
|
199
|
+
* (`searchEncryptedCandidates()`) is by far this search's most expensive step, so it runs once per
|
|
200
|
+
* combination; every subsequent `loadMore()` page for that same combination slices further into the
|
|
201
|
+
* same already-decrypted array. Session-scoped, per tab, with no explicit eviction - a small map that's
|
|
202
|
+
* simply never read again once the query changes, the same shape `InboxContent`'s own `decryptedRows`
|
|
203
|
+
* state already accepts for a similar "worth keeping around, not worth actively pruning" tradeoff.
|
|
204
|
+
*
|
|
205
|
+
* The unlocked-or-not dimension matters: re-running an identical query right after an on-demand unlock
|
|
206
|
+
* (`handleUnlockSearch()`) MUST NOT reuse the "nothing to contribute" entry that same query cached while
|
|
207
|
+
* still locked - `searchEncryptedCandidates()` degrades to `[]` for an absent `unlocked`, and that empty
|
|
208
|
+
* result is exactly as cacheable/reusable as a real one, just under a different key. */
|
|
209
|
+
type Tier3Cache = Map<string, SearchResult[]>;
|
|
210
|
+
|
|
211
|
+
function tier3CacheKey(mailboxUid: string, fingerprint: string, searchAllMail: boolean, unlocked: boolean): string {
|
|
212
|
+
return `${mailboxUid}|${fingerprint}|${String(searchAllMail)}|${String(unlocked)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A search query's cache/cursor identity - stable across re-parsing the identical raw text, and
|
|
216
|
+
* distinct for anything else (§8's "a query fingerprint, so a cursor cannot be replayed against a
|
|
217
|
+
* different query"). `JSON.stringify` on `ParsedSearchQuery` is deterministic here because every one of
|
|
218
|
+
* its own fields is a primitive or a `Date` (which serializes to a fixed ISO string) - no nested object
|
|
219
|
+
* whose key order could vary between two structurally-identical parses of the same text. */
|
|
220
|
+
function queryFingerprint(parsed: ParsedSearchQuery): string {
|
|
221
|
+
return JSON.stringify(parsed);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Tier 3's own `before` bound, tightened to Tier 2's already-covered window (`coverage.indexedFrom`)
|
|
225
|
+
* unless the reader explicitly asked to "Search all mail" - Tier 2 already holds fully-decrypted,
|
|
226
|
+
* current content for everything at least that recent, so re-fetching and re-decrypting the same range
|
|
227
|
+
* through Tier 3's slower candidate-narrowing path would be pure waste. Never *widens* an existing
|
|
228
|
+
* `before:` the query already specified. */
|
|
229
|
+
function tightenBeforeToCoverage(parsed: ParsedSearchQuery, coverage: Coverage | undefined, searchAllMail: boolean): ParsedSearchQuery {
|
|
230
|
+
if (searchAllMail || !coverage?.indexedFrom) {
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
const coverageBound = new Date(coverage.indexedFrom);
|
|
234
|
+
const effectiveBefore = parsed.before && parsed.before.getTime() < coverageBound.getTime() ? parsed.before : coverageBound;
|
|
235
|
+
return { ...parsed, before: effectiveBefore };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The paging state for one search, composited across all three tiers (`specs/search.md` §8) - opaque to
|
|
239
|
+
* every caller the same way `SearchResultPage.nextCursor` is opaque to callers of `search()` itself.
|
|
240
|
+
* Tier 1 keeps the server's own opaque cursor unmodified; Tier 2 (the local index) and Tier 3 (the
|
|
241
|
+
* cached, already-decrypted candidate array - see `Tier3Cache` above) are both this client's own state,
|
|
242
|
+
* so their "position" is just a plain offset into each. */
|
|
243
|
+
interface CompositeCursor {
|
|
244
|
+
tier1Cursor?: string;
|
|
245
|
+
tier2Offset: number;
|
|
246
|
+
tier3Offset: number;
|
|
247
|
+
fingerprint: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `undefined` for a missing, corrupted, or foreign-query cursor - every caller already treats "no
|
|
251
|
+
* cursor" as "start this tier from the beginning," so there's no separate error path needed here. */
|
|
252
|
+
function decodeCursor(raw: CompositeCursor | undefined, fingerprint: string): CompositeCursor | undefined {
|
|
253
|
+
return raw?.fingerprint === fingerprint ? raw : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolves every hit's `entityUid` to a full `Message` via `getMessage()`, reusing `cache` across
|
|
257
|
+
* repeated calls within the same search pass - `InboxContent`'s own search orchestration below re-merges
|
|
258
|
+
* and re-resolves the *entire* current hit set each time a tier resolves, so without this cache every
|
|
259
|
+
* stage would re-fetch messages an earlier stage already fetched. A hit whose message no longer resolves
|
|
260
|
+
* (deleted after being indexed, or the fetch itself failed) is cached as `null` and dropped - the same
|
|
261
|
+
* "a search hit can briefly outlive the message it points to" tolerance this function's inline
|
|
262
|
+
* predecessor already had. */
|
|
263
|
+
async function resolveHitsToMessages(
|
|
264
|
+
hits: SearchResult[],
|
|
265
|
+
cache: Map<string, Message | null>,
|
|
266
|
+
): Promise<{ messages: Message[]; snippets: Record<string, string> }> {
|
|
267
|
+
const toFetch = hits.filter((hit) => !cache.has(hit.entityUid));
|
|
268
|
+
await Promise.all(
|
|
269
|
+
toFetch.map(async (hit) => {
|
|
98
270
|
const message = await getMessage(hit.entityUid).catch(() => null);
|
|
99
|
-
|
|
271
|
+
cache.set(hit.entityUid, message);
|
|
100
272
|
}),
|
|
101
273
|
);
|
|
102
|
-
// A search hit can briefly outlive the message it points to (index updates are eventually consistent,
|
|
103
|
-
// and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
|
|
104
|
-
// rendering a broken entry.
|
|
105
274
|
const messages: Message[] = [];
|
|
106
275
|
const snippets: Record<string, string> = {};
|
|
107
|
-
for (const
|
|
108
|
-
|
|
276
|
+
for (const hit of hits) {
|
|
277
|
+
const message = cache.get(hit.entityUid);
|
|
278
|
+
if (!message) {
|
|
109
279
|
continue;
|
|
110
280
|
}
|
|
111
|
-
messages.push(
|
|
112
|
-
if (
|
|
113
|
-
snippets[
|
|
281
|
+
messages.push(message);
|
|
282
|
+
if (hit.snippet) {
|
|
283
|
+
snippets[message.uid] = hit.snippet;
|
|
114
284
|
}
|
|
115
285
|
}
|
|
116
|
-
return { messages,
|
|
286
|
+
return { messages, snippets };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Flattens and sorts a per-mailbox fetch into one merged, newest-first list - the aggregate ("All
|
|
290
|
+
* Inboxes" etc.) equivalent of `mergeSearchResults()` above, but simpler: an aggregated message has no
|
|
291
|
+
* natural relevance score to normalize, so this only ever sorts by `receivedDate`. */
|
|
292
|
+
function mergeInboxMessages(perMailbox: { mailbox: Mailbox; messages: Message[] }[]): Message[] {
|
|
293
|
+
return perMailbox
|
|
294
|
+
.flatMap((entry) => entry.messages)
|
|
295
|
+
.sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fans out one `listMessages()` call per accessible mailbox that has a folder of `type`, merges the
|
|
300
|
+
* results newest-first. A mailbox with no matching folder, or whose fetch fails, simply contributes
|
|
301
|
+
* nothing - one mailbox's absence/failure must not blank out every other mailbox's messages.
|
|
302
|
+
*
|
|
303
|
+
* **Pagination scope trim (deliberate, matching this file's own documented Tier 2/3 tradeoffs)**: there is
|
|
304
|
+
* no composite cursor across an arbitrary number of independently-paginated mailboxes in this pass - this
|
|
305
|
+
* always fetches exactly each mailbox's own first page (`MESSAGE_PAGE_SIZE`) and the caller never offers a
|
|
306
|
+
* "load more" for the result (see `InboxContent`'s own `hasMore` handling in aggregate mode) - a real
|
|
307
|
+
* composite-cursor "load more" per mailbox is a natural v2 if usage shows people scrolling past the first
|
|
308
|
+
* page in aggregate view often.
|
|
309
|
+
*/
|
|
310
|
+
async function fetchAggregateMessages(mailboxFolders: MailboxFolders[], type: AggregateFolderType): Promise<Message[]> {
|
|
311
|
+
const perMailbox = await Promise.all(
|
|
312
|
+
mailboxFolders.map(async ({ mailbox, folders }) => {
|
|
313
|
+
const folder = folders.find((f) => f.type === type);
|
|
314
|
+
if (!folder) {
|
|
315
|
+
return { mailbox, messages: [] as Message[] };
|
|
316
|
+
}
|
|
317
|
+
const messages = await listMessages(folder.uid, { limit: MESSAGE_PAGE_SIZE }).catch(() => [] as Message[]);
|
|
318
|
+
return { mailbox, messages };
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
return mergeInboxMessages(perMailbox);
|
|
117
322
|
}
|
|
118
323
|
|
|
119
324
|
export default function InboxPage(props: MailShellProps) {
|
|
@@ -125,8 +330,9 @@ export default function InboxPage(props: MailShellProps) {
|
|
|
125
330
|
}
|
|
126
331
|
|
|
127
332
|
function InboxContent() {
|
|
128
|
-
const { folderUid, mailboxUid,
|
|
333
|
+
const { folderUid, mailboxUid, mailboxes, mailboxFolders, aggregateFolderType } = useMailShell();
|
|
129
334
|
const isMobile = useIsMobile();
|
|
335
|
+
const { requestUnlock } = useUnlockPrompt();
|
|
130
336
|
const [viewMode, setViewMode] = useState<ViewMode>("date");
|
|
131
337
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
132
338
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|
@@ -141,23 +347,138 @@ function InboxContent() {
|
|
|
141
347
|
const [searchQuery, setSearchQuery] = useState("");
|
|
142
348
|
const [snippets, setSnippets] = useState<Record<string, string>>({});
|
|
143
349
|
const [labels, setLabels] = useState<Label[]>([]);
|
|
144
|
-
|
|
350
|
+
// Tier 2's own reported window coverage for the current search - undefined outside a search, or
|
|
351
|
+
// before Tier 2 has resolved yet for this search pass.
|
|
352
|
+
const [coverage, setCoverage] = useState<Coverage | undefined>(undefined);
|
|
353
|
+
// Keyed by message uid - see decryptEncryptedRows(). Never cleared on folder/search switches (a
|
|
354
|
+
// decrypted row stays decrypted for the rest of the session; re-decrypting on every navigation would
|
|
355
|
+
// waste work for no benefit), only ever added to.
|
|
356
|
+
const [decryptedRows, setDecryptedRows] = useState<Record<string, DecryptedRow>>({});
|
|
357
|
+
// Bumped after a successful on-demand unlock to re-run the search effect below - it's not a
|
|
358
|
+
// dependency the effect could otherwise react to (getUnlockedKeys() is a plain module-level read, not
|
|
359
|
+
// React state; see keySession.ts's own doc comment).
|
|
360
|
+
const [unlockRefresh, setUnlockRefresh] = useState(0);
|
|
361
|
+
// §_Progressive Results_: which of the current search's uids are still an unconfirmed Tier 1
|
|
362
|
+
// `metadataOnly` guess (rendered as a skeleton row - see the JSX below) - empty outside a search, and
|
|
363
|
+
// always empty again once every tier has reported for the current pass (each unresolved entry is by
|
|
364
|
+
// then either confirmed, real content, or pruned - see the search orchestration effect).
|
|
365
|
+
const [pendingUids, setPendingUids] = useState<Set<string>>(new Set());
|
|
366
|
+
// Withholds a hard result count until every tier has reported for the current search pass (§_Progressive
|
|
367
|
+
// Results_: "Never show a hard count until every tier has reported... A settled count is the signal
|
|
368
|
+
// that ordering is final"). Reset on every fresh search; irrelevant outside search mode.
|
|
369
|
+
const [tier1Done, setTier1Done] = useState(false);
|
|
370
|
+
const [tier2Done, setTier2Done] = useState(false);
|
|
371
|
+
const [tier3Done, setTier3Done] = useState(false);
|
|
372
|
+
// Toggled by the "Search all mail" action next to the results count - removes Tier 3's own default
|
|
373
|
+
// bound (tightened to Tier 2's coverage window otherwise - see tightenBeforeToCoverage()) for one
|
|
374
|
+
// re-run. Reset to false whenever the query itself changes (see the search effect's own dependency).
|
|
375
|
+
const [searchAllMail, setSearchAllMail] = useState(false);
|
|
376
|
+
// Search stays real-folder-only - Tier 1/2/3 are all deeply mailbox/folder-scoped, and extending them
|
|
377
|
+
// to span an arbitrary number of mailboxes is out of scope for this pass (see the aggregate-fetch
|
|
378
|
+
// branch below, which the search effect never reaches while `folderUid` is unset).
|
|
379
|
+
const isSearching = viewMode === "date" && searchQuery.length > 0 && !aggregateFolderType;
|
|
145
380
|
const pageRef = useRef(0);
|
|
146
|
-
const cursorRef = useRef<string | undefined>(undefined);
|
|
147
381
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
148
382
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
383
|
+
// The mailbox unlock/decrypt call sites below treat as "the" mailbox when there's no single selected
|
|
384
|
+
// one (aggregate mode) - mirrors `MailShell`'s own identical `defaultMailboxUid` fallback. An
|
|
385
|
+
// aggregate-view row from a *different*, not-yet-unlocked mailbox stays locked until that mailbox's
|
|
386
|
+
// own folder view is opened directly - an accepted limitation, not a bug (see `MailShell`'s own doc
|
|
387
|
+
// comment on the same tradeoff for its `LocalIndexLifecycle`/`KeyEnrollmentGate` wiring).
|
|
388
|
+
const activeMailboxUid = mailboxUid ?? mailboxes.find((mb) => mb.ownerUserUid)?.uid ?? mailboxes[0]?.uid;
|
|
389
|
+
const mailboxKeys = mailboxes.find((mb) => mb.uid === activeMailboxUid)?.keys ?? [];
|
|
390
|
+
// Reset to a fresh Map at the start of every new search pass (see the search effect below) - see
|
|
391
|
+
// resolveHitsToMessages()'s own doc comment on why this needs to persist *within* one pass but not
|
|
392
|
+
// across passes (a stale `null` for a uid that's since become resolvable elsewhere must not stick).
|
|
393
|
+
const resolvedMessageCacheRef = useRef<Map<string, Message | null>>(new Map());
|
|
394
|
+
// Session-scoped, never explicitly cleared - see Tier3Cache's own doc comment above.
|
|
395
|
+
const tier3CacheRef = useRef<Tier3Cache>(new Map());
|
|
396
|
+
// The latest composite cursor this search pass has reached - read by loadMore(), written at the end
|
|
397
|
+
// of both the fresh-search orchestration and loadMore() itself. Not React state: it never drives a
|
|
398
|
+
// render on its own, only what loadMore() does with it later.
|
|
399
|
+
const compositeCursorRef = useRef<CompositeCursor | undefined>(undefined);
|
|
400
|
+
// Guards every async stage of the search orchestration below against a stale, still-in-flight pass
|
|
401
|
+
// clobbering state for a newer one that started after it (the query changed again, or the folder
|
|
402
|
+
// did) - the same `loadSeq`-style monotonic-id pattern already used elsewhere in this codebase (e.g.
|
|
403
|
+
// `settings/privacy/index.tsx`'s `ExportSection`), generalized here across three independently-timed
|
|
404
|
+
// async stages instead of one.
|
|
405
|
+
const searchRunIdRef = useRef(0);
|
|
406
|
+
// `messages` themselves aren't a dependency here on purpose - a message uid, once decrypted, is
|
|
407
|
+
// never re-decrypted just because the list re-renders with the same rows (e.g. a folder-unrelated
|
|
408
|
+
// state update elsewhere). New rows (a fresh page load, load-more, or a completed search) each
|
|
409
|
+
// re-trigger this the normal way, by changing `messages` itself.
|
|
410
|
+
//
|
|
411
|
+
// Scoped to `activeMailboxUid`'s own rows only - in aggregate mode `messages` can span several
|
|
412
|
+
// mailboxes, but only one mailbox's keys are ever being unlocked/tracked here (see `activeMailboxUid`'s
|
|
413
|
+
// own doc comment above); an encrypted row from any other mailbox simply isn't a candidate for this
|
|
414
|
+
// auto-decrypt or the manual unlock banner below.
|
|
415
|
+
const undecryptedEncryptedUids = messages
|
|
416
|
+
.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && !decryptedRows[m.uid] && m.mailboxUid === activeMailboxUid)
|
|
417
|
+
.map((m) => m.uid);
|
|
418
|
+
|
|
419
|
+
// Once unlocked, silently decrypt this page's own encrypted rows to show their real subject/preview -
|
|
420
|
+
// no prompt needed here, the same way searchEncryptedCandidates() already auto-includes decrypted
|
|
421
|
+
// matches once unlocked without asking again. Only the *first* unlock (or a fresh page of messages
|
|
422
|
+
// arriving) needs this; `handleUnlockList()` below covers the not-yet-unlocked case explicitly.
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
if (undecryptedEncryptedUids.length === 0 || !activeMailboxUid) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const unlocked = getUnlockedKeys(activeMailboxUid);
|
|
428
|
+
if (!unlocked) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
let cancelled = false;
|
|
432
|
+
decryptEncryptedRows(
|
|
433
|
+
messages.filter((m) => undecryptedEncryptedUids.includes(m.uid)),
|
|
434
|
+
unlocked,
|
|
435
|
+
).then((decrypted) => {
|
|
436
|
+
if (!cancelled && Object.keys(decrypted).length > 0) {
|
|
437
|
+
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
return () => {
|
|
441
|
+
cancelled = true;
|
|
442
|
+
};
|
|
443
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
444
|
+
}, [messages, unlockRefresh]);
|
|
445
|
+
|
|
446
|
+
async function handleUnlockList() {
|
|
447
|
+
try {
|
|
448
|
+
const unlocked = await requestUnlock(activeMailboxUid!, mailboxKeys);
|
|
449
|
+
const decrypted = await decryptEncryptedRows(
|
|
450
|
+
messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER && m.mailboxUid === activeMailboxUid),
|
|
451
|
+
unlocked,
|
|
452
|
+
);
|
|
453
|
+
setDecryptedRows((prev) => ({ ...prev, ...decrypted }));
|
|
454
|
+
} catch {
|
|
455
|
+
// User dismissed the unlock dialog - rows stay exactly as they were.
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function handleUnlockSearch() {
|
|
460
|
+
try {
|
|
461
|
+
await requestUnlock(activeMailboxUid!, mailboxKeys);
|
|
462
|
+
setUnlockRefresh((n) => n + 1);
|
|
463
|
+
} catch {
|
|
464
|
+
// User dismissed the unlock dialog - the search results stay exactly as they were.
|
|
465
|
+
}
|
|
466
|
+
}
|
|
149
467
|
|
|
150
468
|
// Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
|
|
151
469
|
// handed to every `MessageDetailPane` instance below. A failure here just means the Labels control
|
|
152
470
|
// stays hidden (an empty `labels` array) rather than blocking the rest of the inbox - it's a small
|
|
153
|
-
// enhancement, not critical path the way the message list itself is.
|
|
154
|
-
// `MailShell` never renders this component at
|
|
155
|
-
//
|
|
471
|
+
// enhancement, not critical path the way the message list itself is. Keyed on `activeMailboxUid`, which
|
|
472
|
+
// `MailShell` guarantees resolves (it never renders this component without at least one mailbox) even
|
|
473
|
+
// in aggregate mode, where there's no single selected `mailboxUid`.
|
|
156
474
|
useEffect(() => {
|
|
157
|
-
|
|
475
|
+
if (!activeMailboxUid) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
listLabels(activeMailboxUid, { limit: 200 })
|
|
158
479
|
.then(setLabels)
|
|
159
480
|
.catch(() => setLabels([]));
|
|
160
|
-
}, [
|
|
481
|
+
}, [activeMailboxUid]);
|
|
161
482
|
|
|
162
483
|
// Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
|
|
163
484
|
useEffect(() => {
|
|
@@ -173,22 +494,45 @@ function InboxContent() {
|
|
|
173
494
|
setSelectedConversationId(null);
|
|
174
495
|
setClassificationFilter("all");
|
|
175
496
|
pageRef.current = 0;
|
|
176
|
-
|
|
497
|
+
compositeCursorRef.current = undefined;
|
|
177
498
|
setHasMore(false);
|
|
178
499
|
|
|
179
500
|
if (viewMode === "conversation") {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
501
|
+
// Conversations stay single-mailbox (not aggregated across mailboxes in this pass) - in
|
|
502
|
+
// aggregate mode this falls back to `activeMailboxUid`, the same mailbox unlock/labels use.
|
|
503
|
+
if (!activeMailboxUid) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
183
506
|
setLoading(true);
|
|
184
507
|
setError(null);
|
|
185
|
-
listConversations(
|
|
508
|
+
listConversations(activeMailboxUid)
|
|
186
509
|
.then(setConversations)
|
|
187
510
|
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load conversations."))
|
|
188
511
|
.finally(() => setLoading(false));
|
|
189
512
|
return;
|
|
190
513
|
}
|
|
191
514
|
|
|
515
|
+
if (aggregateFolderType) {
|
|
516
|
+
setLoading(true);
|
|
517
|
+
setError(null);
|
|
518
|
+
let cancelled = false;
|
|
519
|
+
// hasMore stays false (set above) - see fetchAggregateMessages()'s own pagination scope trim.
|
|
520
|
+
fetchAggregateMessages(mailboxFolders, aggregateFolderType)
|
|
521
|
+
.then((results) => {
|
|
522
|
+
if (!cancelled) {
|
|
523
|
+
setMessages(results);
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
.finally(() => {
|
|
527
|
+
if (!cancelled) {
|
|
528
|
+
setLoading(false);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
return () => {
|
|
532
|
+
cancelled = true;
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
192
536
|
if (!folderUid) {
|
|
193
537
|
setMessages([]);
|
|
194
538
|
setLoading(false);
|
|
@@ -198,15 +542,92 @@ function InboxContent() {
|
|
|
198
542
|
setError(null);
|
|
199
543
|
|
|
200
544
|
if (isSearching) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
545
|
+
setCoverage(undefined);
|
|
546
|
+
setPendingUids(new Set());
|
|
547
|
+
setTier1Done(false);
|
|
548
|
+
setTier2Done(false);
|
|
549
|
+
setTier3Done(false);
|
|
550
|
+
resolvedMessageCacheRef.current = new Map();
|
|
551
|
+
searchRunIdRef.current += 1;
|
|
552
|
+
const myRunId = searchRunIdRef.current;
|
|
553
|
+
const parsed = parseSearchQuery(searchQuery);
|
|
554
|
+
const unlocked = getUnlockedKeys(mailboxUid!);
|
|
555
|
+
const fingerprint = queryFingerprint(parsed);
|
|
556
|
+
|
|
557
|
+
// Recomputes and re-renders the merged list from whatever tiers have reported so far -
|
|
558
|
+
// called once after Tier 1+2 (interim: unconfirmed Tier 1 metadataOnly hits render as
|
|
559
|
+
// skeletons) and again after Tier 3 (final: anything still unconfirmed is pruned instead -
|
|
560
|
+
// §_Progressive Results_' "Skeletons resolve or disappear"). Bails out via `myRunId` if a
|
|
561
|
+
// newer search pass has since started.
|
|
562
|
+
async function reveal(tier1Hits: SearchResult[], tier2Hits: SearchResult[], tier3Hits: SearchResult[], final: boolean) {
|
|
563
|
+
const capped = capSkeletons(tier1Hits);
|
|
564
|
+
const confirmed = new Set([...tier2Hits, ...tier3Hits].map((r) => r.entityUid));
|
|
565
|
+
const effectiveTier1 = final ? capped.filter((hit) => !hit.metadataOnly || confirmed.has(hit.entityUid)) : capped;
|
|
566
|
+
const merged = mergeSearchResults(effectiveTier1, tier2Hits, tier3Hits);
|
|
567
|
+
const { messages: resolved, snippets: resolvedSnippets } = await resolveHitsToMessages(
|
|
568
|
+
merged,
|
|
569
|
+
resolvedMessageCacheRef.current,
|
|
570
|
+
);
|
|
571
|
+
if (searchRunIdRef.current !== myRunId) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
setMessages(resolved);
|
|
575
|
+
setSnippets(resolvedSnippets);
|
|
576
|
+
if (final) {
|
|
577
|
+
setPendingUids(new Set());
|
|
578
|
+
} else {
|
|
579
|
+
const pending = new Set<string>();
|
|
580
|
+
for (const hit of capped) {
|
|
581
|
+
if (hit.metadataOnly && !confirmed.has(hit.entityUid)) {
|
|
582
|
+
pending.add(hit.entityUid);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
setPendingUids(pending);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
void (async () => {
|
|
590
|
+
try {
|
|
591
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
592
|
+
searchMailbox(parsed.text, tier1SearchParams(parsed, undefined)),
|
|
593
|
+
searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, 0),
|
|
594
|
+
]);
|
|
595
|
+
if (searchRunIdRef.current !== myRunId) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
setTier1Done(true);
|
|
599
|
+
setTier2Done(true);
|
|
600
|
+
setCoverage(tier2Page.coverage);
|
|
601
|
+
setLoading(false);
|
|
602
|
+
await reveal(tier1Page.results, tier2Page.results, [], false);
|
|
603
|
+
|
|
604
|
+
const tightened = tightenBeforeToCoverage(parsed, tier2Page.coverage, searchAllMail);
|
|
605
|
+
const cacheKey = tier3CacheKey(mailboxUid!, fingerprint, searchAllMail, !!unlocked);
|
|
606
|
+
let tier3Full = tier3CacheRef.current.get(cacheKey);
|
|
607
|
+
if (!tier3Full) {
|
|
608
|
+
tier3Full = await searchEncryptedCandidates(tightened, unlocked, TIER3_CANDIDATE_LIMIT);
|
|
609
|
+
if (searchRunIdRef.current !== myRunId) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
tier3CacheRef.current.set(cacheKey, tier3Full);
|
|
613
|
+
}
|
|
614
|
+
const tier3Page = tier3Full.slice(0, MESSAGE_PAGE_SIZE);
|
|
615
|
+
setTier3Done(true);
|
|
616
|
+
compositeCursorRef.current = {
|
|
617
|
+
tier1Cursor: tier1Page.nextCursor,
|
|
618
|
+
tier2Offset: tier2Page.results.length,
|
|
619
|
+
tier3Offset: tier3Page.length,
|
|
620
|
+
fingerprint,
|
|
621
|
+
};
|
|
622
|
+
setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || tier3Page.length < tier3Full.length);
|
|
623
|
+
await reveal(tier1Page.results, tier2Page.results, tier3Page, true);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
if (searchRunIdRef.current === myRunId) {
|
|
626
|
+
setError(err instanceof ApiRequestError ? err.message : "Search failed.");
|
|
627
|
+
setLoading(false);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
})();
|
|
210
631
|
return;
|
|
211
632
|
}
|
|
212
633
|
|
|
@@ -217,7 +638,17 @@ function InboxContent() {
|
|
|
217
638
|
})
|
|
218
639
|
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load messages."))
|
|
219
640
|
.finally(() => setLoading(false));
|
|
220
|
-
|
|
641
|
+
// `unlockRefresh`/`searchAllMail` are dependencies solely so `handleUnlockSearch()`/"Search all
|
|
642
|
+
// mail" can force this effect to re-run the search above - Tier 3 (encrypted) results silently
|
|
643
|
+
// contribute nothing without unlocked keys, so this is what actually makes them appear once the
|
|
644
|
+
// user unlocks, and what makes "Search all mail" actually remove Tier 3's coverage bound. Neither
|
|
645
|
+
// has any effect on the non-search branch below; re-running it with identical inputs just
|
|
646
|
+
// re-fetches the same page.
|
|
647
|
+
}, [viewMode, folderUid, mailboxUid, isSearching, searchQuery, unlockRefresh, searchAllMail, aggregateFolderType, mailboxFolders, activeMailboxUid]);
|
|
648
|
+
|
|
649
|
+
function handleSearchAllMail() {
|
|
650
|
+
setSearchAllMail(true);
|
|
651
|
+
}
|
|
221
652
|
|
|
222
653
|
const loadMore = useCallback(async () => {
|
|
223
654
|
if (loadingMore || !hasMore || loading || viewMode !== "date" || !folderUid) {
|
|
@@ -226,11 +657,39 @@ function InboxContent() {
|
|
|
226
657
|
setLoadingMore(true);
|
|
227
658
|
try {
|
|
228
659
|
if (isSearching) {
|
|
229
|
-
const
|
|
660
|
+
const parsed = parseSearchQuery(searchQuery);
|
|
661
|
+
const unlocked = getUnlockedKeys(mailboxUid!);
|
|
662
|
+
const fingerprint = queryFingerprint(parsed);
|
|
663
|
+
const cursor = decodeCursor(compositeCursorRef.current, fingerprint);
|
|
664
|
+
|
|
665
|
+
const [tier1Page, tier2Page] = await Promise.all([
|
|
666
|
+
searchMailbox(parsed.text, tier1SearchParams(parsed, cursor?.tier1Cursor)),
|
|
667
|
+
searchLocalIndex(mailboxUid!, parsed, unlocked, MESSAGE_PAGE_SIZE, cursor?.tier2Offset ?? 0),
|
|
668
|
+
]);
|
|
669
|
+
const cacheKey = tier3CacheKey(mailboxUid!, fingerprint, searchAllMail, !!unlocked);
|
|
670
|
+
const tier3Full = tier3CacheRef.current.get(cacheKey) ?? [];
|
|
671
|
+
const tier3Offset = cursor?.tier3Offset ?? 0;
|
|
672
|
+
const tier3Page = tier3Full.slice(tier3Offset, tier3Offset + MESSAGE_PAGE_SIZE);
|
|
673
|
+
|
|
674
|
+
// Unlike the fresh-search pass above, a load-more page is resolved and appended in one
|
|
675
|
+
// shot rather than progressively revealed - rows already on screen shouldn't reorder or
|
|
676
|
+
// grow skeletons out from under a reader who has since scrolled past them. Any Tier 1
|
|
677
|
+
// metadataOnly hit this page that neither Tier 2 nor Tier 3 (both already awaited above)
|
|
678
|
+
// confirms simply keeps showing its existing placeholder text rather than a live skeleton
|
|
679
|
+
// - a deliberate, documented scope trim of progressive reveal to the first page only.
|
|
680
|
+
const merged = mergeSearchResults(capSkeletons(tier1Page.results), tier2Page.results, tier3Page);
|
|
681
|
+
const { messages: more, snippets: moreSnippets } = await resolveHitsToMessages(merged, resolvedMessageCacheRef.current);
|
|
230
682
|
setMessages((prev) => [...prev, ...more]);
|
|
231
683
|
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
232
|
-
|
|
233
|
-
|
|
684
|
+
|
|
685
|
+
const nextTier3Offset = tier3Offset + tier3Page.length;
|
|
686
|
+
compositeCursorRef.current = {
|
|
687
|
+
tier1Cursor: tier1Page.nextCursor,
|
|
688
|
+
tier2Offset: (cursor?.tier2Offset ?? 0) + tier2Page.results.length,
|
|
689
|
+
tier3Offset: nextTier3Offset,
|
|
690
|
+
fingerprint,
|
|
691
|
+
};
|
|
692
|
+
setHasMore(!!tier1Page.nextCursor || tier2Page.hasMore || nextTier3Offset < tier3Full.length);
|
|
234
693
|
} else {
|
|
235
694
|
const nextPage = pageRef.current + 1;
|
|
236
695
|
const more = await listMessages(folderUid, { page: nextPage, limit: MESSAGE_PAGE_SIZE });
|
|
@@ -243,7 +702,7 @@ function InboxContent() {
|
|
|
243
702
|
} finally {
|
|
244
703
|
setLoadingMore(false);
|
|
245
704
|
}
|
|
246
|
-
}, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery]);
|
|
705
|
+
}, [loadingMore, hasMore, loading, viewMode, folderUid, isSearching, searchQuery, searchAllMail, mailboxUid]);
|
|
247
706
|
|
|
248
707
|
// Always calls the latest `loadMore` closure so the effect below doesn't need `loadMore` itself in its
|
|
249
708
|
// dependency array (it changes on every keystroke/page load, which would otherwise mean nothing here).
|
|
@@ -278,8 +737,12 @@ function InboxContent() {
|
|
|
278
737
|
// Search results can span every folder in the mailbox, not just the one selected in the sidebar - a
|
|
279
738
|
// selected message's own folderUid is the only reliable source for its actual folder type once
|
|
280
739
|
// searching (outside search, every message in `messages` already comes from `folderUid` itself, so
|
|
281
|
-
// this falls back to the sidebar selection unchanged).
|
|
282
|
-
|
|
740
|
+
// this falls back to the sidebar selection unchanged). Aggregate views span every *mailbox* too, so
|
|
741
|
+
// the folder list consulted is the selected message's own mailbox's, not the shell's ambient one.
|
|
742
|
+
const spansFolders = isSearching || !!aggregateFolderType;
|
|
743
|
+
const selectedFolderUid = spansFolders ? (selected?.folderUid ?? folderUid) : folderUid;
|
|
744
|
+
const selectedMailboxUid = spansFolders ? (selected?.mailboxUid ?? activeMailboxUid) : mailboxUid;
|
|
745
|
+
const folders = mailboxFolders.find((mf) => mf.mailbox.uid === selectedMailboxUid)?.folders ?? [];
|
|
283
746
|
const isSentItems = folders.find((f) => f.uid === selectedFolderUid)?.type === "sent_items";
|
|
284
747
|
const isOutbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "outbox";
|
|
285
748
|
const isInbox = folders.find((f) => f.uid === selectedFolderUid)?.type === "inbox";
|
|
@@ -287,9 +750,10 @@ function InboxContent() {
|
|
|
287
750
|
|
|
288
751
|
// Focused/Other is an Inbox-only concept (see `MessageDetailPane`'s own `isInbox` doc comment) — the
|
|
289
752
|
// sub-tabs only ever render there, so a message with no `inferenceClassification` (the common case:
|
|
290
|
-
// absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other.
|
|
753
|
+
// absent means Focused) or an explicit `"focused"` counts as Focused, everything else as Other. Not
|
|
754
|
+
// offered for an aggregate view (each mailbox classifies independently; merging that is out of scope).
|
|
291
755
|
const visibleMessages =
|
|
292
|
-
!isSearching && isInbox && classificationFilter !== "all"
|
|
756
|
+
!isSearching && !aggregateFolderType && isInbox && classificationFilter !== "all"
|
|
293
757
|
? messages.filter((m) =>
|
|
294
758
|
classificationFilter === "other"
|
|
295
759
|
? m.inferenceClassification === "other"
|
|
@@ -319,7 +783,7 @@ function InboxContent() {
|
|
|
319
783
|
setSelectedConversationId(conversation.conversationId);
|
|
320
784
|
}
|
|
321
785
|
|
|
322
|
-
if (!folderUid) {
|
|
786
|
+
if (!folderUid && !aggregateFolderType) {
|
|
323
787
|
// `MailShell` never renders this component at all until a mailbox is resolved (see its own
|
|
324
788
|
// full-screen `MailboxProvisioning` takeover otherwise) — this is purely the brief gap before
|
|
325
789
|
// that mailbox's own folder list has finished loading, not a "no mailbox" state. Distinct text
|
|
@@ -364,13 +828,14 @@ function InboxContent() {
|
|
|
364
828
|
type="search"
|
|
365
829
|
value={searchInput}
|
|
366
830
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
367
|
-
placeholder="Search all mail…"
|
|
831
|
+
placeholder={aggregateFolderType ? "Open a mailbox's own folder to search" : "Search all mail…"}
|
|
368
832
|
aria-label="Search all mail"
|
|
369
|
-
|
|
833
|
+
disabled={!!aggregateFolderType}
|
|
834
|
+
className="w-full text-sm px-3 py-1.5 rounded-md border border-border bg-surface disabled:opacity-55"
|
|
370
835
|
/>
|
|
371
836
|
</div>
|
|
372
837
|
)}
|
|
373
|
-
{viewMode === "date" && isInbox && !isSearching && (
|
|
838
|
+
{viewMode === "date" && isInbox && !isSearching && !aggregateFolderType && (
|
|
374
839
|
<div className="flex border-b border-border text-xs">
|
|
375
840
|
{(["all", "focused", "other"] as const).map((value) => (
|
|
376
841
|
<button
|
|
@@ -390,6 +855,61 @@ function InboxContent() {
|
|
|
390
855
|
</div>
|
|
391
856
|
)}
|
|
392
857
|
|
|
858
|
+
{isSearching && (
|
|
859
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border flex items-center justify-between gap-2">
|
|
860
|
+
{/* §_Progressive Results_: "Never show a hard count until every tier has reported.
|
|
861
|
+
Display n of ??, or omit the count. A settled count is the signal that ordering
|
|
862
|
+
is final." */}
|
|
863
|
+
<span>
|
|
864
|
+
{tier1Done && tier2Done && tier3Done
|
|
865
|
+
? `${visibleMessages.length} result${visibleMessages.length === 1 ? "" : "s"}`
|
|
866
|
+
: `${visibleMessages.length} of ??`}
|
|
867
|
+
</span>
|
|
868
|
+
{tier2Done && !searchAllMail && (
|
|
869
|
+
<button
|
|
870
|
+
type="button"
|
|
871
|
+
onClick={handleSearchAllMail}
|
|
872
|
+
className="text-primary-dark hover:underline font-medium shrink-0"
|
|
873
|
+
>
|
|
874
|
+
Search all mail
|
|
875
|
+
</button>
|
|
876
|
+
)}
|
|
877
|
+
</div>
|
|
878
|
+
)}
|
|
879
|
+
{isSearching && coverage?.indexedFrom && (
|
|
880
|
+
<div className="px-4 py-1.5 text-xs text-text-muted border-b border-border">
|
|
881
|
+
Local search covers messages back to {new Date(coverage.indexedFrom).toLocaleDateString()}
|
|
882
|
+
{coverage.building ? " (still building)" : ""}
|
|
883
|
+
{searchAllMail
|
|
884
|
+
? " - searching everything, not just recent mail."
|
|
885
|
+
: " - older encrypted mail is still searched, just slower."}
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
{isSearching && !getUnlockedKeys(mailboxUid!) && (
|
|
889
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
890
|
+
<button
|
|
891
|
+
type="button"
|
|
892
|
+
onClick={handleUnlockSearch}
|
|
893
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
894
|
+
>
|
|
895
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
896
|
+
Unlock to include encrypted messages in these results
|
|
897
|
+
</button>
|
|
898
|
+
</div>
|
|
899
|
+
)}
|
|
900
|
+
{!isSearching && viewMode === "date" && undecryptedEncryptedUids.length > 0 && !getUnlockedKeys(activeMailboxUid!) && (
|
|
901
|
+
<div className="px-4 py-2 border-b border-border bg-surface-alt">
|
|
902
|
+
<button
|
|
903
|
+
type="button"
|
|
904
|
+
onClick={handleUnlockList}
|
|
905
|
+
className="inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline"
|
|
906
|
+
>
|
|
907
|
+
<HiOutlineLockClosed size={12} aria-hidden="true" />
|
|
908
|
+
Unlock to show {undecryptedEncryptedUids.length === 1 ? "an encrypted message's" : "encrypted messages'"} subject
|
|
909
|
+
</button>
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
|
|
393
913
|
{error && (
|
|
394
914
|
<div className="p-4">
|
|
395
915
|
<Alert>{error}</Alert>
|
|
@@ -432,10 +952,35 @@ function InboxContent() {
|
|
|
432
952
|
{new Date(message.receivedDate).toLocaleDateString()}
|
|
433
953
|
</span>
|
|
434
954
|
</div>
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
955
|
+
{aggregateFolderType && (
|
|
956
|
+
// The one view where a row needs to say which mailbox it came from.
|
|
957
|
+
<div className="text-xs text-text-muted truncate font-normal">
|
|
958
|
+
{mailboxes.find((mb) => mb.uid === message.mailboxUid)?.displayName}
|
|
959
|
+
</div>
|
|
960
|
+
)}
|
|
961
|
+
{isSearching && pendingUids.has(message.uid) ? (
|
|
962
|
+
// §_Progressive Results_: "Unresolved encrypted results MUST
|
|
963
|
+
// be rendered as skeleton entries in place, not appended on
|
|
964
|
+
// arrival." This uid is a Tier 1 metadataOnly guess Tier 2/3
|
|
965
|
+
// haven't confirmed (or ruled out) yet.
|
|
966
|
+
<div className="flex flex-col gap-1.5 py-0.5">
|
|
967
|
+
<Skeleton height="h-3.5" className="w-2/3 rounded-sm" />
|
|
968
|
+
<Skeleton height="h-3" className="w-full rounded-sm" />
|
|
969
|
+
</div>
|
|
970
|
+
) : (
|
|
971
|
+
<>
|
|
972
|
+
<div className="text-sm truncate">
|
|
973
|
+
{decryptedRows[message.uid]?.subject ||
|
|
974
|
+
(message.subject === ENCRYPTED_SUBJECT_PLACEHOLDER
|
|
975
|
+
? "Encrypted message"
|
|
976
|
+
: message.subject) ||
|
|
977
|
+
"(no subject)"}
|
|
978
|
+
</div>
|
|
979
|
+
<div className="text-xs text-text-muted truncate font-normal">
|
|
980
|
+
{snippets[message.uid] || decryptedRows[message.uid]?.preview || message.bodyPreview}
|
|
981
|
+
</div>
|
|
982
|
+
</>
|
|
983
|
+
)}
|
|
439
984
|
</button>
|
|
440
985
|
</li>
|
|
441
986
|
))}
|
|
@@ -445,12 +990,22 @@ function InboxContent() {
|
|
|
445
990
|
{loadingMore ? "Loading more…" : ""}
|
|
446
991
|
</div>
|
|
447
992
|
)}
|
|
993
|
+
{aggregateFolderType && (
|
|
994
|
+
<p className="p-4 text-center text-xs text-text-muted">
|
|
995
|
+
Showing the most recent mail from each mailbox. Open a specific mailbox’s folder to
|
|
996
|
+
see older mail.
|
|
997
|
+
</p>
|
|
998
|
+
)}
|
|
448
999
|
</>
|
|
449
1000
|
)}
|
|
450
1001
|
</div>
|
|
451
1002
|
<div className="hidden md:flex flex-1 min-w-0">
|
|
452
1003
|
{viewMode === "conversation" ? (
|
|
453
|
-
<ConversationThreadPane
|
|
1004
|
+
<ConversationThreadPane
|
|
1005
|
+
conversation={selectedConversation}
|
|
1006
|
+
folders={mailboxFolders.find((mf) => mf.mailbox.uid === activeMailboxUid)?.folders ?? []}
|
|
1007
|
+
labels={labels}
|
|
1008
|
+
/>
|
|
454
1009
|
) : (
|
|
455
1010
|
<MessageDetailPane
|
|
456
1011
|
message={selected}
|