@rapidmx/web-client 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -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
- /** Resolves one page of search hits (of type "message") into full `Message` records for display. */
24
- async function searchMessages(text: string, cursor?: string): Promise<{ messages: Message[]; nextCursor?: string }> {
25
- const page = await searchMailbox(text, { types: ["message"], cursor, limit: MESSAGE_PAGE_SIZE });
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
- page.results.map((hit) => getMessage(hit.entityUid).catch(() => null)),
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
- return { messages: resolved.filter((m): m is Message => m !== null), nextCursor: page.nextCursor };
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">{message.bodyPreview}</div>
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
  }