@rapidmx/web-client 0.2.0 → 0.3.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/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- package/package.json +9 -3
package/apps/www/index.tsx
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import { Message, MessageClassification, getMessage, listMessages } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
8
9
|
import { ConversationSummary, listConversations } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
9
|
-
import { search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
10
|
+
import { SearchResult, search as searchMailbox } from "@rapidmx/react-shared/search/searchApi.js";
|
|
11
|
+
import { parseSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
|
|
12
|
+
import { normalizeServerScores } from "@rapidmx/react-shared/search/searchScoring.js";
|
|
13
|
+
import { searchEncryptedCandidates } from "@rapidmx/react-shared/search/searchTier3.js";
|
|
14
|
+
import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
10
15
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
11
16
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
12
17
|
import MailShell, { MailShellProps, useMailShell } from "../shared/components/mail/layout/MailShell.js";
|
|
@@ -20,16 +25,95 @@ type ViewMode = "date" | "conversation";
|
|
|
20
25
|
const MESSAGE_PAGE_SIZE = 50;
|
|
21
26
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
22
27
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/** Merges Tier 1 (server, possibly `metadataOnly` for an encrypted message) and Tier 3 (decrypted,
|
|
29
|
+
* content-verified) results into one ranked list, per `specs/search.md` §7's "client MUST re-score all
|
|
30
|
+
* results it can see... normalise into the same space rather than interleaving raw scores": both sides
|
|
31
|
+
* are normalized independently via `normalizeServerScores()` before merging, since a Postgres/OpenSearch
|
|
32
|
+
* score and this module's own term-count score occupy unrelated ranges. A uid present in both lists
|
|
33
|
+
* (Tier 1 found it via a `participants` coincidence, Tier 3 then genuinely verified its content) keeps
|
|
34
|
+
* only the Tier 3 entry - real content verification supersedes a metadata guess for the same message.
|
|
35
|
+
* Deliberately not the spec's full skeleton/reordering "Progressive Results" UX (§_Progressive
|
|
36
|
+
* Results_) - that's real, separate UI work; this returns one final merged list once both tiers
|
|
37
|
+
* resolve, same as how the search box already waits on one round-trip today. */
|
|
38
|
+
function mergeSearchResults(tier1: SearchResult[], tier3: SearchResult[]): SearchResult[] {
|
|
39
|
+
const normalizedTier1 = normalizeServerScores(tier1);
|
|
40
|
+
const normalizedTier3 = normalizeServerScores(tier3);
|
|
41
|
+
const merged = new Map<string, { result: SearchResult; normalizedScore: number }>();
|
|
42
|
+
for (const entry of normalizedTier1) {
|
|
43
|
+
merged.set(entry.result.entityUid, entry);
|
|
44
|
+
}
|
|
45
|
+
for (const entry of normalizedTier3) {
|
|
46
|
+
merged.set(entry.result.entityUid, entry);
|
|
47
|
+
}
|
|
48
|
+
return Array.from(merged.values())
|
|
49
|
+
.sort((a, b) => b.normalizedScore - a.normalizedScore)
|
|
50
|
+
.map((entry) => entry.result);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolves one page of search hits into full `Message` records for display, plus each hit's own
|
|
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
|
|
61
|
+
* (contact/calendarEvent/note/task) has no `Message` to resolve via `getMessage()` below and is simply
|
|
62
|
+
* dropped by the same eventually-consistent-index fallback that already existed, rather than rendered
|
|
63
|
+
* (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).
|
|
65
|
+
*
|
|
66
|
+
* Tier 3 (`searchTier3.ts#searchEncryptedCandidates()`) runs alongside Tier 1 only for the first page
|
|
67
|
+
* (`cursor` absent) - it has no pagination wiring yet (a deliberate scope trim, see that module's own
|
|
68
|
+
* doc comment), so a `loadMore()` continuation stays Tier-1-only. Silently contributes nothing when
|
|
69
|
+
* this mailbox has no unlocked keys this session (`getUnlockedKeys()` returns `undefined`) - nothing
|
|
70
|
+
* for it to decrypt, same as `MessageDetailPane`'s own encrypted-message handling elsewhere. */
|
|
71
|
+
async function searchMessages(
|
|
72
|
+
mailboxUid: string,
|
|
73
|
+
rawQuery: string,
|
|
74
|
+
cursor?: string,
|
|
75
|
+
): Promise<{ messages: Message[]; nextCursor?: string; snippets: Record<string, string> }> {
|
|
76
|
+
const parsed = parseSearchQuery(rawQuery);
|
|
77
|
+
const [page, tier3Results] = await Promise.all([
|
|
78
|
+
searchMailbox(parsed.text, {
|
|
79
|
+
types: parsed.entityTypes ?? ["message"],
|
|
80
|
+
cursor,
|
|
81
|
+
limit: MESSAGE_PAGE_SIZE,
|
|
82
|
+
from: parsed.from,
|
|
83
|
+
to: parsed.to,
|
|
84
|
+
cc: parsed.cc,
|
|
85
|
+
subject: parsed.subject,
|
|
86
|
+
hasAttachment: parsed.hasAttachment,
|
|
87
|
+
before: parsed.before,
|
|
88
|
+
after: parsed.after,
|
|
89
|
+
folderUid: parsed.folderUid,
|
|
90
|
+
flags: parsed.flags,
|
|
91
|
+
labels: parsed.labels,
|
|
92
|
+
}),
|
|
93
|
+
cursor ? Promise.resolve<SearchResult[]>([]) : searchEncryptedCandidates(parsed, getUnlockedKeys(mailboxUid)),
|
|
94
|
+
]);
|
|
95
|
+
const mergedResults = mergeSearchResults(page.results, tier3Results);
|
|
26
96
|
const resolved = await Promise.all(
|
|
27
|
-
|
|
97
|
+
mergedResults.map(async (hit) => {
|
|
98
|
+
const message = await getMessage(hit.entityUid).catch(() => null);
|
|
99
|
+
return message ? { message, snippet: hit.snippet } : null;
|
|
100
|
+
}),
|
|
28
101
|
);
|
|
29
102
|
// A search hit can briefly outlive the message it points to (index updates are eventually consistent,
|
|
30
103
|
// and a message can be deleted after being indexed) - drop anything that no longer resolves rather than
|
|
31
104
|
// rendering a broken entry.
|
|
32
|
-
|
|
105
|
+
const messages: Message[] = [];
|
|
106
|
+
const snippets: Record<string, string> = {};
|
|
107
|
+
for (const entry of resolved) {
|
|
108
|
+
if (!entry) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
messages.push(entry.message);
|
|
112
|
+
if (entry.snippet) {
|
|
113
|
+
snippets[entry.message.uid] = entry.snippet;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { messages, nextCursor: page.nextCursor, snippets };
|
|
33
117
|
}
|
|
34
118
|
|
|
35
119
|
export default function InboxPage(props: MailShellProps) {
|
|
@@ -55,12 +139,26 @@ function InboxContent() {
|
|
|
55
139
|
const [classificationFilter, setClassificationFilter] = useState<MessageClassification | "all">("all");
|
|
56
140
|
const [searchInput, setSearchInput] = useState("");
|
|
57
141
|
const [searchQuery, setSearchQuery] = useState("");
|
|
142
|
+
const [snippets, setSnippets] = useState<Record<string, string>>({});
|
|
143
|
+
const [labels, setLabels] = useState<Label[]>([]);
|
|
58
144
|
const isSearching = viewMode === "date" && searchQuery.length > 0;
|
|
59
145
|
const pageRef = useRef(0);
|
|
60
146
|
const cursorRef = useRef<string | undefined>(undefined);
|
|
61
147
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
|
62
148
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
63
149
|
|
|
150
|
+
// Labels are mailbox-wide, not folder-scoped - fetched once per mailbox rather than per message, and
|
|
151
|
+
// handed to every `MessageDetailPane` instance below. A failure here just means the Labels control
|
|
152
|
+
// 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. No `mailboxUid` guard needed -
|
|
154
|
+
// `MailShell` never renders this component at all until `mailboxUid` has resolved (same invariant
|
|
155
|
+
// `searchMessages(mailboxUid!, ...)` below already relies on).
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
listLabels(mailboxUid!, { limit: 200 })
|
|
158
|
+
.then(setLabels)
|
|
159
|
+
.catch(() => setLabels([]));
|
|
160
|
+
}, [mailboxUid]);
|
|
161
|
+
|
|
64
162
|
// Debounce the raw input into the query actually searched, so every keystroke doesn't fire a request.
|
|
65
163
|
useEffect(() => {
|
|
66
164
|
const handle = setTimeout(() => setSearchQuery(searchInput.trim()), SEARCH_DEBOUNCE_MS);
|
|
@@ -100,9 +198,10 @@ function InboxContent() {
|
|
|
100
198
|
setError(null);
|
|
101
199
|
|
|
102
200
|
if (isSearching) {
|
|
103
|
-
searchMessages(searchQuery)
|
|
104
|
-
.then(({ messages: results, nextCursor }) => {
|
|
201
|
+
searchMessages(mailboxUid!, searchQuery)
|
|
202
|
+
.then(({ messages: results, nextCursor, snippets: newSnippets }) => {
|
|
105
203
|
setMessages(results);
|
|
204
|
+
setSnippets(newSnippets);
|
|
106
205
|
setHasMore(!!nextCursor);
|
|
107
206
|
cursorRef.current = nextCursor;
|
|
108
207
|
})
|
|
@@ -127,8 +226,9 @@ function InboxContent() {
|
|
|
127
226
|
setLoadingMore(true);
|
|
128
227
|
try {
|
|
129
228
|
if (isSearching) {
|
|
130
|
-
const { messages: more, nextCursor } = await searchMessages(searchQuery, cursorRef.current);
|
|
229
|
+
const { messages: more, nextCursor, snippets: moreSnippets } = await searchMessages(mailboxUid!, searchQuery, cursorRef.current);
|
|
131
230
|
setMessages((prev) => [...prev, ...more]);
|
|
231
|
+
setSnippets((prev) => ({ ...prev, ...moreSnippets }));
|
|
132
232
|
setHasMore(!!nextCursor);
|
|
133
233
|
cursorRef.current = nextCursor;
|
|
134
234
|
} else {
|
|
@@ -333,7 +433,9 @@ function InboxContent() {
|
|
|
333
433
|
</span>
|
|
334
434
|
</div>
|
|
335
435
|
<div className="text-sm truncate">{message.subject || "(no subject)"}</div>
|
|
336
|
-
<div className="text-xs text-text-muted truncate font-normal">
|
|
436
|
+
<div className="text-xs text-text-muted truncate font-normal">
|
|
437
|
+
{snippets[message.uid] || message.bodyPreview}
|
|
438
|
+
</div>
|
|
337
439
|
</button>
|
|
338
440
|
</li>
|
|
339
441
|
))}
|
|
@@ -348,7 +450,7 @@ function InboxContent() {
|
|
|
348
450
|
</div>
|
|
349
451
|
<div className="hidden md:flex flex-1 min-w-0">
|
|
350
452
|
{viewMode === "conversation" ? (
|
|
351
|
-
<ConversationThreadPane conversation={selectedConversation} folders={folders} />
|
|
453
|
+
<ConversationThreadPane conversation={selectedConversation} folders={folders} labels={labels} />
|
|
352
454
|
) : (
|
|
353
455
|
<MessageDetailPane
|
|
354
456
|
message={selected}
|
|
@@ -367,6 +469,15 @@ function InboxContent() {
|
|
|
367
469
|
setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
|
|
368
470
|
setSelectedUid(null);
|
|
369
471
|
}}
|
|
472
|
+
onArchived={(updated) => {
|
|
473
|
+
// Same reasoning as onScheduledSendCanceled above — the message moved out of
|
|
474
|
+
// whichever folder is currently being viewed (into Archive), so it's removed
|
|
475
|
+
// from the list rather than patched in place.
|
|
476
|
+
setMessages((prev) => prev.filter((m) => m.uid !== updated.uid));
|
|
477
|
+
setSelectedUid(null);
|
|
478
|
+
}}
|
|
479
|
+
labels={labels}
|
|
480
|
+
onLabelsChanged={(updated) => setMessages((prev) => prev.map((m) => (m.uid === updated.uid ? updated : m)))}
|
|
370
481
|
/>
|
|
371
482
|
)}
|
|
372
483
|
</div>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import React, { useEffect, useState } from "react";
|
|
6
6
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
7
|
import { Message, getMessage } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import { Label, listLabels } from "@rapidmx/react-shared/mail/labelsApi.js";
|
|
8
9
|
import { useMarkMessageRead, useMessageAttachments } from "@rapidmx/react-shared/mail/mailDetailHooks.js";
|
|
9
10
|
import MailShell, { MailShellProps, useMailShell } from "../../shared/components/mail/layout/MailShell.js";
|
|
10
11
|
import MessageDetailPane from "../../shared/components/mail/MessageDetailPane.js";
|
|
@@ -23,10 +24,11 @@ export default function MessageDetailPage(props: MailShellProps & { params: { ui
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function MessageDetailContent({ uid }: { uid: string }) {
|
|
26
|
-
const { folders } = useMailShell();
|
|
27
|
+
const { folders, mailboxUid } = useMailShell();
|
|
27
28
|
const [message, setMessage] = useState<Message | null>(null);
|
|
28
29
|
const [loading, setLoading] = useState(true);
|
|
29
30
|
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const [labels, setLabels] = useState<Label[]>([]);
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
setLoading(true);
|
|
@@ -37,6 +39,15 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
37
39
|
.finally(() => setLoading(false));
|
|
38
40
|
}, [uid]);
|
|
39
41
|
|
|
42
|
+
// See `apps/www/index.tsx`'s identical effect's own doc comment - a failure here just hides the
|
|
43
|
+
// Labels control rather than blocking the rest of the page. No `mailboxUid` guard needed - `MailShell`
|
|
44
|
+
// never renders this component at all until `mailboxUid` has resolved.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
listLabels(mailboxUid!, { limit: 200 })
|
|
47
|
+
.then(setLabels)
|
|
48
|
+
.catch(() => setLabels([]));
|
|
49
|
+
}, [mailboxUid]);
|
|
50
|
+
|
|
40
51
|
const attachments = useMessageAttachments(message);
|
|
41
52
|
useMarkMessageRead(message, setMessage);
|
|
42
53
|
|
|
@@ -65,6 +76,9 @@ function MessageDetailContent({ uid }: { uid: string }) {
|
|
|
65
76
|
onReceiptHandled={setMessage}
|
|
66
77
|
draftsFolderUid={draftsFolderUid}
|
|
67
78
|
onScheduledSendCanceled={setMessage}
|
|
79
|
+
onArchived={setMessage}
|
|
80
|
+
labels={labels}
|
|
81
|
+
onLabelsChanged={setMessage}
|
|
68
82
|
/>
|
|
69
83
|
);
|
|
70
84
|
}
|