@remit/web-client 0.0.22 → 0.0.23
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/package.json +1 -1
- package/src/components/mail/DailyBrief.tsx +35 -15
- package/src/components/mail/MailboxPane.tsx +9 -12
- package/src/hooks/useSemanticSearch.ts +23 -8
- package/src/lib/brief.test.ts +79 -0
- package/src/lib/brief.ts +27 -0
- package/src/lib/search-scope.test.ts +93 -0
- package/src/lib/search-scope.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
groupBriefSections,
|
|
48
48
|
matchesBriefSearch,
|
|
49
49
|
matchesSearchTokens,
|
|
50
|
+
mergeSearchRows,
|
|
50
51
|
toThreadRowData,
|
|
51
52
|
} from "@/lib/brief";
|
|
52
53
|
import { isServerError } from "@/lib/error-classifier";
|
|
@@ -57,6 +58,10 @@ import { MailListHeader } from "./MailListHeader";
|
|
|
57
58
|
|
|
58
59
|
/* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
|
|
59
60
|
ids) so the phone search takeover narrows results the same way the list does. */
|
|
61
|
+
/* Page size for the unscoped cross-folder search. One page is what the takeover
|
|
62
|
+
and the "Top matches" list render; the server caps it at 500. */
|
|
63
|
+
const UNSCOPED_SEARCH_PAGE_SIZE = 200;
|
|
64
|
+
|
|
60
65
|
const BRIEF_SEARCH_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
61
66
|
unread: (t) => !t.isRead,
|
|
62
67
|
attachment: (t) => t.hasAttachment === true,
|
|
@@ -264,6 +269,21 @@ export function DailyBrief({
|
|
|
264
269
|
tokenContext,
|
|
265
270
|
);
|
|
266
271
|
|
|
272
|
+
// --- Unscoped search query ---
|
|
273
|
+
// The brief's list is the unified INBOX, so filtering it client-side can only
|
|
274
|
+
// ever find inbox mail. The same endpoint in search mode (`query`) widens to
|
|
275
|
+
// every non-muted folder of every account — Archive, Sent, Spam, custom
|
|
276
|
+
// folders — and matches subject/From in the query. Its rows are merged with
|
|
277
|
+
// the client-side pass below, which still contributes snippet matches the
|
|
278
|
+
// server does not index.
|
|
279
|
+
const { data: searchData, isFetching: searchFetching } = useQuery({
|
|
280
|
+
...unifiedThreadOperationsListAllThreadsOptions({
|
|
281
|
+
query: { query: sq, limit: UNSCOPED_SEARCH_PAGE_SIZE },
|
|
282
|
+
}),
|
|
283
|
+
enabled: sq.length > 0,
|
|
284
|
+
staleTime: 30_000,
|
|
285
|
+
});
|
|
286
|
+
|
|
267
287
|
// Convert API rows to ThreadRowData, narrowing only by the selected account
|
|
268
288
|
// and the free-text search plus any filter tokens (`from:`, `has:attachment`,
|
|
269
289
|
// `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
|
|
@@ -271,20 +291,20 @@ export function DailyBrief({
|
|
|
271
291
|
// `BriefSections` filter row's job, so the full per-category sections are
|
|
272
292
|
// handed to it; it groups, narrows, and flattens.
|
|
273
293
|
const filteredRows = useMemo<ThreadRowData[]>(() => {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}, [threadsData, selectedAccountId, sq, queryTokens]);
|
|
294
|
+
const briefRows = (threadsData?.items ?? []).map(toThreadRowData);
|
|
295
|
+
// No free text: the brief list as it comes, order untouched.
|
|
296
|
+
const rows = sq
|
|
297
|
+
? mergeSearchRows(
|
|
298
|
+
briefRows.filter((t) => matchesBriefSearch(t, sq)),
|
|
299
|
+
(searchData?.items ?? []).map(toThreadRowData),
|
|
300
|
+
)
|
|
301
|
+
: briefRows;
|
|
302
|
+
return rows.filter(
|
|
303
|
+
(t) =>
|
|
304
|
+
(selectedAccountId === "all" || t.accountId === selectedAccountId) &&
|
|
305
|
+
matchesSearchTokens(t, queryTokens),
|
|
306
|
+
);
|
|
307
|
+
}, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
|
|
288
308
|
|
|
289
309
|
const sections = useMemo<ThreadSection[]>(
|
|
290
310
|
() => groupBriefSections(filteredRows),
|
|
@@ -433,7 +453,7 @@ export function DailyBrief({
|
|
|
433
453
|
footer={isDesktop ? <KeyboardHintBar /> : undefined}
|
|
434
454
|
searchFilter={searchFilterConfig}
|
|
435
455
|
searchResults={searchResults}
|
|
436
|
-
searchLoading={isLoading}
|
|
456
|
+
searchLoading={isLoading || searchFetching}
|
|
437
457
|
relatedResults={relatedResults}
|
|
438
458
|
relatedLoading={relatedLoading}
|
|
439
459
|
onSelectSearchResult={onSelectSearchResult}
|
|
@@ -947,14 +947,13 @@ function MailboxList() {
|
|
|
947
947
|
() => threads.map(threadToSearchResult),
|
|
948
948
|
[threads],
|
|
949
949
|
);
|
|
950
|
-
// The route scopes this view
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
// section, not a silent widening of the chip. Results are deduped by thread,
|
|
956
|
-
// so a thread never shows in both.
|
|
950
|
+
// The route scopes this view and the top bar's chip says so, so every engine
|
|
951
|
+
// here respects it: the literal engine searches this mailbox, and the
|
|
952
|
+
// semantic engine takes the same `mailboxId`. No chip means global; a chip
|
|
953
|
+
// means nothing on the route reaches past it. Results are deduped by thread,
|
|
954
|
+
// so a thread never shows in both sections.
|
|
957
955
|
const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
|
|
956
|
+
mailboxId,
|
|
958
957
|
filterCategory,
|
|
959
958
|
});
|
|
960
959
|
const relatedResults = useMemo(
|
|
@@ -969,9 +968,9 @@ function MailboxList() {
|
|
|
969
968
|
(result: SearchResult) =>
|
|
970
969
|
navigate({
|
|
971
970
|
to: "/mail/$mailboxId",
|
|
972
|
-
//
|
|
973
|
-
// mailbox
|
|
974
|
-
//
|
|
971
|
+
// Both sections are scoped to this mailbox, so a result's own
|
|
972
|
+
// mailbox is normally this one; keep reading it off the result so a
|
|
973
|
+
// row can never open under a mailbox it does not belong to.
|
|
975
974
|
params: { mailboxId: result.mailboxId ?? mailboxId },
|
|
976
975
|
search: (prev: Record<string, unknown>) => ({
|
|
977
976
|
...prev,
|
|
@@ -1066,8 +1065,6 @@ function MailboxList() {
|
|
|
1066
1065
|
relatedResults={relatedResults}
|
|
1067
1066
|
relatedLoading={relatedLoading}
|
|
1068
1067
|
onSelectSearchResult={handleSelectSearchResult}
|
|
1069
|
-
searchResultsLabel={`In ${listTitle}`}
|
|
1070
|
-
relatedResultsLabel="Everywhere"
|
|
1071
1068
|
>
|
|
1072
1069
|
{body}
|
|
1073
1070
|
</MailViewChrome>
|
|
@@ -2,8 +2,10 @@ import { semanticSearchOperationsSemanticSearchOptions } from "@remit/api-http-c
|
|
|
2
2
|
import type { RemitImapSemanticSearchResult } from "@remit/api-http-client/types.gen.ts";
|
|
3
3
|
import { MessageCategory } from "@remit/domain-enums";
|
|
4
4
|
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
5
6
|
import { useMailContext } from "@/lib/mail-context";
|
|
6
7
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
8
|
+
import { semanticMailboxScope } from "@/lib/search-scope";
|
|
7
9
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
8
10
|
import { useSearchTokenContext } from "./useSearchTokenContext";
|
|
9
11
|
|
|
@@ -22,8 +24,9 @@ const toCategoryParam = (
|
|
|
22
24
|
|
|
23
25
|
interface UseSemanticSearchParams {
|
|
24
26
|
/**
|
|
25
|
-
* Restrict results to a single mailbox
|
|
26
|
-
*
|
|
27
|
+
* Restrict results to a single mailbox. A mailbox route pins the scope to its
|
|
28
|
+
* own mailbox regardless of what is passed here, so this only carries a scope
|
|
29
|
+
* a route does not already imply.
|
|
27
30
|
*/
|
|
28
31
|
mailboxId?: string;
|
|
29
32
|
/**
|
|
@@ -35,9 +38,10 @@ interface UseSemanticSearchParams {
|
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Fetch semantic-search hits for the active query (one source of truth: the
|
|
38
|
-
* debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
+
* debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search
|
|
42
|
+
* and is taken from the route (`semanticMailboxScope`): no chip means global, a
|
|
43
|
+
* chip means this engine respects it like every other. Disabled until the query
|
|
44
|
+
* is non-empty so an empty field issues no request.
|
|
41
45
|
*
|
|
42
46
|
* Filter tokens (`has:attachment`, `is:unread`, `before:`/`after:`) parsed from
|
|
43
47
|
* the query map onto the search API's own filter params. `from:` and
|
|
@@ -50,7 +54,7 @@ interface UseSemanticSearchParams {
|
|
|
50
54
|
* `useSearchTokenContext` does not resolve the term there at all — the term
|
|
51
55
|
* stays free text and no engine, and no chip, treats it as a filter.
|
|
52
56
|
*
|
|
53
|
-
*
|
|
57
|
+
* With no resolved mailbox the request spans every mailbox of every account the
|
|
54
58
|
* caller owns: the backend partitions the vector index by accountConfigId (one
|
|
55
59
|
* per signed-in user, not per mail account), so unscoped is genuinely global.
|
|
56
60
|
*/
|
|
@@ -63,9 +67,15 @@ export function useSemanticSearch({
|
|
|
63
67
|
} {
|
|
64
68
|
const { searchQuery } = useMailContext();
|
|
65
69
|
const tokenContext = useSearchTokenContext();
|
|
70
|
+
const matches = useRouterState({ select: (s) => s.matches });
|
|
66
71
|
const normalizedQuery = normalizeSearchQuery(searchQuery);
|
|
67
72
|
const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
|
|
68
|
-
|
|
73
|
+
// `freeText`, not the raw query: a query of nothing but tokens
|
|
74
|
+
// (`has:attachment`, `in:archive`) parses to empty free text, and asking a
|
|
75
|
+
// vector index what an empty string means has no answer to give. The literal
|
|
76
|
+
// engines still apply those tokens; the semantic section simply has nothing
|
|
77
|
+
// to rank.
|
|
78
|
+
const enabled = freeText.length > 0;
|
|
69
79
|
|
|
70
80
|
const category = toCategoryParam(filterCategory);
|
|
71
81
|
const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
|
|
@@ -75,7 +85,12 @@ export function useSemanticSearch({
|
|
|
75
85
|
const afterToken = tokens.find((t) => t.type === "after");
|
|
76
86
|
const beforeToken = tokens.find((t) => t.type === "before");
|
|
77
87
|
const inToken = tokens.find((t) => t.type === "in");
|
|
78
|
-
|
|
88
|
+
// The route decides the scope, not the call site — see `semanticMailboxScope`.
|
|
89
|
+
const effectiveMailboxId = semanticMailboxScope({
|
|
90
|
+
matches,
|
|
91
|
+
callerMailboxId: mailboxId,
|
|
92
|
+
inTokenMailboxId: inToken?.mailboxId,
|
|
93
|
+
});
|
|
79
94
|
|
|
80
95
|
const { data, isLoading } = useQuery({
|
|
81
96
|
...semanticSearchOperationsSemanticSearchOptions({
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
groupBriefSections,
|
|
7
7
|
matchesBriefSearch,
|
|
8
8
|
matchesSearchTokens,
|
|
9
|
+
mergeSearchRows,
|
|
9
10
|
toThreadRowData,
|
|
10
11
|
} from "./brief.js";
|
|
11
12
|
import type { SearchToken } from "./search-tokens.js";
|
|
@@ -433,3 +434,81 @@ describe("matchesSearchTokens", () => {
|
|
|
433
434
|
);
|
|
434
435
|
});
|
|
435
436
|
});
|
|
437
|
+
|
|
438
|
+
// #49: the brief's list is the unified INBOX, so filtering it client-side found
|
|
439
|
+
// only inbox mail. The server's cross-folder search supplies the rest, and the
|
|
440
|
+
// two are merged.
|
|
441
|
+
describe("mergeSearchRows", () => {
|
|
442
|
+
test("keeps rows the server found in other folders", () => {
|
|
443
|
+
const merged = mergeSearchRows(
|
|
444
|
+
[row({ id: "inbox-hit", sentDate: 300 })],
|
|
445
|
+
[
|
|
446
|
+
row({ id: "archive-hit", sentDate: 200 }),
|
|
447
|
+
row({ id: "spam-hit", sentDate: 100 }),
|
|
448
|
+
],
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
assert.deepEqual(
|
|
452
|
+
merged.map((r) => r.id),
|
|
453
|
+
["inbox-hit", "archive-hit", "spam-hit"],
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("keeps a snippet-only match the server cannot see", () => {
|
|
458
|
+
const merged = mergeSearchRows(
|
|
459
|
+
[row({ id: "snippet-only", sentDate: 100 })],
|
|
460
|
+
[],
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
assert.deepEqual(
|
|
464
|
+
merged.map((r) => r.id),
|
|
465
|
+
["snippet-only"],
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("the two sources overlap on INBOX, so rows are deduped", () => {
|
|
470
|
+
const merged = mergeSearchRows(
|
|
471
|
+
[row({ id: "shared", sentDate: 200, subject: "from the brief" })],
|
|
472
|
+
[
|
|
473
|
+
row({ id: "shared", sentDate: 200, subject: "from the search" }),
|
|
474
|
+
row({ id: "archive-hit", sentDate: 100 }),
|
|
475
|
+
],
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
assert.deepEqual(
|
|
479
|
+
merged.map((r) => r.id),
|
|
480
|
+
["shared", "archive-hit"],
|
|
481
|
+
);
|
|
482
|
+
assert.equal(merged[0].subject, "from the brief");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("the union reads newest first, interleaving both sources", () => {
|
|
486
|
+
const merged = mergeSearchRows(
|
|
487
|
+
[
|
|
488
|
+
row({ id: "brief-new", sentDate: 400 }),
|
|
489
|
+
row({ id: "brief-old", sentDate: 200 }),
|
|
490
|
+
],
|
|
491
|
+
[
|
|
492
|
+
row({ id: "search-mid", sentDate: 300 }),
|
|
493
|
+
row({ id: "search-oldest", sentDate: 100 }),
|
|
494
|
+
],
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
assert.deepEqual(
|
|
498
|
+
merged.map((r) => r.id),
|
|
499
|
+
["brief-new", "search-mid", "brief-old", "search-oldest"],
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("a row without a sentDate sorts last", () => {
|
|
504
|
+
const merged = mergeSearchRows(
|
|
505
|
+
[row({ id: "undated" })],
|
|
506
|
+
[row({ id: "dated", sentDate: 100 })],
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
assert.deepEqual(
|
|
510
|
+
merged.map((r) => r.id),
|
|
511
|
+
["dated", "undated"],
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
});
|
package/src/lib/brief.ts
CHANGED
|
@@ -67,6 +67,33 @@ export function toThreadRowData(
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Union of the brief's own rows with the rows the server's cross-folder search
|
|
72
|
+
* returned, newest first.
|
|
73
|
+
*
|
|
74
|
+
* Both lists are needed. The server matches subject and From only, so a row
|
|
75
|
+
* whose snippet carries the term is found only by the client-side pass over the
|
|
76
|
+
* loaded brief rows; the server pass is the only one that reaches Archive,
|
|
77
|
+
* Sent, Spam and custom folders, which the brief list never loads. The two
|
|
78
|
+
* overlap on INBOX, so rows are deduped by id, the first occurrence winning.
|
|
79
|
+
*
|
|
80
|
+
* Rows without a `sentDate` sort last; the brief's own list is already newest
|
|
81
|
+
* first, so this only has to re-interleave the two sources.
|
|
82
|
+
*/
|
|
83
|
+
export function mergeSearchRows(
|
|
84
|
+
briefRows: ThreadRowData[],
|
|
85
|
+
searchRows: ThreadRowData[],
|
|
86
|
+
): ThreadRowData[] {
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
const merged: ThreadRowData[] = [];
|
|
89
|
+
for (const row of [...briefRows, ...searchRows]) {
|
|
90
|
+
if (seen.has(row.id)) continue;
|
|
91
|
+
seen.add(row.id);
|
|
92
|
+
merged.push(row);
|
|
93
|
+
}
|
|
94
|
+
return merged.sort((a, b) => (b.sentDate ?? 0) - (a.sentDate ?? 0));
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
/**
|
|
71
98
|
* Category sections in fixed display order. The `id`/`label` drive the rendered
|
|
72
99
|
* section; `category` is the row category that routes into it.
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
SEARCH_SCOPE_CHIP_ID,
|
|
12
12
|
scopeLabelForMailboxName,
|
|
13
13
|
searchScopeForRoute,
|
|
14
|
+
semanticMailboxScope,
|
|
14
15
|
} from "./search-scope";
|
|
15
16
|
|
|
16
17
|
const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
|
|
@@ -102,3 +103,95 @@ describe("scopeLabelForMailboxName", () => {
|
|
|
102
103
|
assert.equal(scopeLabelForMailboxName("Work Stuff"), 'in:"work stuff"');
|
|
103
104
|
});
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
// The invariant: no chip means global, a chip means every engine on that route
|
|
108
|
+
// respects it. The semantic engine used to run unscoped inside a mailbox under
|
|
109
|
+
// an "Everywhere" heading, contradicting the `in:` chip the same bar showed.
|
|
110
|
+
describe("semanticMailboxScope — a chip binds every engine on the route", () => {
|
|
111
|
+
const mailbox = (mailboxId: string) => [
|
|
112
|
+
{ routeId: "/mail" },
|
|
113
|
+
{ routeId: MAIL_MAILBOX_ROUTE_ID, params: { mailboxId } },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
it("pins a mailbox route to its own mailbox", () => {
|
|
117
|
+
assert.equal(
|
|
118
|
+
semanticMailboxScope({ matches: mailbox("mbx-spam") }),
|
|
119
|
+
"mbx-spam",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("a mailbox route ignores a caller asking for somewhere else", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
semanticMailboxScope({
|
|
126
|
+
matches: mailbox("mbx-spam"),
|
|
127
|
+
callerMailboxId: "mbx-archive",
|
|
128
|
+
}),
|
|
129
|
+
"mbx-spam",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a mailbox route ignores a typed in: term", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
semanticMailboxScope({
|
|
136
|
+
matches: mailbox("mbx-spam"),
|
|
137
|
+
inTokenMailboxId: "mbx-archive",
|
|
138
|
+
}),
|
|
139
|
+
"mbx-spam",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a mailbox route never widens to global", () => {
|
|
144
|
+
const scope = semanticMailboxScope({ matches: mailbox("mbx-spam") });
|
|
145
|
+
assert.notEqual(scope, undefined, "a scoped route must carry a scope");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Flagged and outbox are scoped to a collection, which the mailbox-scoped
|
|
149
|
+
// semantic API cannot express — so neither runs a semantic section, and
|
|
150
|
+
// neither may fall back to a typed `in:` under its own chip.
|
|
151
|
+
for (const routeId of [MAIL_FLAGGED_ROUTE_ID, MAIL_OUTBOX_ROUTE_ID]) {
|
|
152
|
+
it(`${routeId} never honours a typed in: term`, () => {
|
|
153
|
+
assert.equal(
|
|
154
|
+
semanticMailboxScope({
|
|
155
|
+
matches: matches(routeId),
|
|
156
|
+
inTokenMailboxId: "mbx-archive",
|
|
157
|
+
}),
|
|
158
|
+
undefined,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it("the daily brief is global — no route scope, no chip", () => {
|
|
164
|
+
assert.equal(
|
|
165
|
+
semanticMailboxScope({ matches: matches(MAIL_BRIEF_ROUTE_ID) }),
|
|
166
|
+
undefined,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("the daily brief is the one route a typed in: narrows", () => {
|
|
171
|
+
assert.equal(
|
|
172
|
+
semanticMailboxScope({
|
|
173
|
+
matches: matches(MAIL_BRIEF_ROUTE_ID),
|
|
174
|
+
inTokenMailboxId: "mbx-archive",
|
|
175
|
+
}),
|
|
176
|
+
"mbx-archive",
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("every scoped route resolves a scope or refuses to widen", () => {
|
|
181
|
+
// Pins the invariant across the route table rather than one case at a
|
|
182
|
+
// time: on a scoped route the resolved scope is either the route's own
|
|
183
|
+
// mailbox, or nothing that came from the typed query.
|
|
184
|
+
const scoped = [
|
|
185
|
+
{ matches: mailbox("mbx-spam"), expected: "mbx-spam" },
|
|
186
|
+
{ matches: matches(MAIL_FLAGGED_ROUTE_ID), expected: undefined },
|
|
187
|
+
{ matches: matches(MAIL_OUTBOX_ROUTE_ID), expected: undefined },
|
|
188
|
+
];
|
|
189
|
+
for (const { matches: m, expected } of scoped) {
|
|
190
|
+
assert.equal(isScopedRoute(m), true);
|
|
191
|
+
assert.equal(
|
|
192
|
+
semanticMailboxScope({ matches: m, inTokenMailboxId: "mbx-elsewhere" }),
|
|
193
|
+
expected,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
package/src/lib/search-scope.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isFlaggedRoute,
|
|
27
27
|
isMailboxRoute,
|
|
28
28
|
isOutboxRoute,
|
|
29
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
29
30
|
type MailRouteMatch,
|
|
30
31
|
} from "./mail-route";
|
|
31
32
|
|
|
@@ -75,6 +76,45 @@ export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
|
75
76
|
);
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The mailbox id carried by a mailbox route, or `undefined` on any other route.
|
|
81
|
+
*/
|
|
82
|
+
export function routeMailboxId(
|
|
83
|
+
matches: readonly MailRouteMatch[],
|
|
84
|
+
): string | undefined {
|
|
85
|
+
return matches.find((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID)?.params
|
|
86
|
+
?.mailboxId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The mailbox every engine on the active route must be pinned to.
|
|
91
|
+
*
|
|
92
|
+
* No chip means global; a chip means nothing on the route reaches past it. The
|
|
93
|
+
* semantic engine used to run unscoped inside a mailbox under an "Everywhere"
|
|
94
|
+
* heading, which contradicted the `in:` chip the same bar was showing, so the
|
|
95
|
+
* scope is now resolved from the route here rather than left to each caller:
|
|
96
|
+
*
|
|
97
|
+
* - A mailbox route pins to its own mailbox. It beats both the caller's
|
|
98
|
+
* argument and a typed `in:`, so no call site can widen or redirect it.
|
|
99
|
+
* - Any other scoped route (flagged, outbox) never falls back to a typed
|
|
100
|
+
* `in:`. Its scope is a collection rather than a folder, which the semantic
|
|
101
|
+
* API cannot express — which is why neither view runs a semantic section
|
|
102
|
+
* today. Honouring `in:` there would be a search reaching past a chip that
|
|
103
|
+
* promises otherwise.
|
|
104
|
+
* - An unscoped route (the daily brief) is global unless a typed `in:` narrows
|
|
105
|
+
* it, which is the one place `useSearchTokenContext` resolves that term.
|
|
106
|
+
*/
|
|
107
|
+
export function semanticMailboxScope(args: {
|
|
108
|
+
matches: readonly MailRouteMatch[];
|
|
109
|
+
callerMailboxId?: string;
|
|
110
|
+
inTokenMailboxId?: string;
|
|
111
|
+
}): string | undefined {
|
|
112
|
+
const fromRoute = routeMailboxId(args.matches);
|
|
113
|
+
if (fromRoute) return fromRoute;
|
|
114
|
+
if (isScopedRoute(args.matches)) return args.callerMailboxId;
|
|
115
|
+
return args.callerMailboxId ?? args.inTokenMailboxId;
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
/**
|
|
79
119
|
* The scope of the active route.
|
|
80
120
|
*
|