@remit/ui 0.0.13 → 0.0.14
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/folder-role.test.ts +72 -0
- package/src/components/folder-role.tsx +59 -0
- package/src/components/mobile-search-view.render.test.ts +78 -0
- package/src/components/mobile-search-view.stories.tsx +117 -2
- package/src/components/mobile-search-view.tsx +20 -1
- package/src/components/search-result-row.tsx +23 -0
- package/src/components/search-results.render.test.ts +181 -0
- package/src/components/search-results.stories.tsx +165 -28
- package/src/components/search-results.tsx +101 -5
- package/src/components/spam-results-offer.render.test.ts +26 -0
- package/src/components/spam-results-offer.stories.tsx +41 -0
- package/src/components/spam-results-offer.tsx +52 -0
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isVirtualFolderRole, provenanceFolderLabel } from "./folder-role.js";
|
|
4
|
+
|
|
5
|
+
describe("provenanceFolderLabel", () => {
|
|
6
|
+
it("names an appointed role by its canonical label", () => {
|
|
7
|
+
assert.equal(provenanceFolderLabel({ role: "archive" }), "Archive");
|
|
8
|
+
assert.equal(provenanceFolderLabel({ role: "sent" }), "Sent");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("reads a junk appointment as Spam whatever the server calls it", () => {
|
|
12
|
+
assert.equal(
|
|
13
|
+
provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
|
|
14
|
+
"Spam",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to the leaf of a folder nobody appointed", () => {
|
|
19
|
+
assert.equal(
|
|
20
|
+
provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
|
|
21
|
+
"Bookkeeping",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("refuses to label a view rather than a place", () => {
|
|
26
|
+
assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
|
|
27
|
+
assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("refuses to label Gmail's own reserved namespace", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
|
|
33
|
+
undefined,
|
|
34
|
+
);
|
|
35
|
+
assert.equal(
|
|
36
|
+
provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
|
|
37
|
+
undefined,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refuses the googlemail.com spelling of the same namespace", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
|
|
48
|
+
undefined,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("labels a user folder that merely mentions Gmail", () => {
|
|
53
|
+
assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("has nothing to say about a folder it knows nothing about", () => {
|
|
57
|
+
assert.equal(provenanceFolderLabel({}), undefined);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isVirtualFolderRole", () => {
|
|
62
|
+
it("counts All Mail and Starred as views", () => {
|
|
63
|
+
assert.equal(isVirtualFolderRole("all"), true);
|
|
64
|
+
assert.equal(isVirtualFolderRole("flagged"), true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("counts real folders as places", () => {
|
|
68
|
+
assert.equal(isVirtualFolderRole("inbox"), false);
|
|
69
|
+
assert.equal(isVirtualFolderRole("junk"), false);
|
|
70
|
+
assert.equal(isVirtualFolderRole("trash"), false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -54,6 +54,65 @@ export function providerLeaf(providerPath: string): string {
|
|
|
54
54
|
return parts[parts.length - 1] || providerPath;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/* ------------------------------------------------------------------ */
|
|
58
|
+
/* Provenance: where a search result actually lives */
|
|
59
|
+
/* ------------------------------------------------------------------ */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The folder a search result was read from. `role` is the account's IMAP
|
|
63
|
+
* special-use appointment (`junk` is `\Junk`); accounts that expose a folder
|
|
64
|
+
* nobody appointed carry only a `providerPath`.
|
|
65
|
+
*/
|
|
66
|
+
export interface ResultFolder {
|
|
67
|
+
role?: FolderRole;
|
|
68
|
+
/** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
|
|
69
|
+
providerPath?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Roles that name a view rather than a place a message is filed. A message in
|
|
74
|
+
* All Mail or Starred is also somewhere real, so labelling a result with one of
|
|
75
|
+
* these says nothing about where it came from.
|
|
76
|
+
*/
|
|
77
|
+
const VIRTUAL_ROLES: ReadonlySet<FolderRole> = new Set(["all", "flagged"]);
|
|
78
|
+
|
|
79
|
+
export function isVirtualFolderRole(role: FolderRole): boolean {
|
|
80
|
+
return VIRTUAL_ROLES.has(role);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Gmail exposes its views as ordinary folders under a reserved namespace. An
|
|
85
|
+
* account that appointed no role to them leaves the path as the only signal, so
|
|
86
|
+
* the namespace is matched by name — the one place a name is the honest test,
|
|
87
|
+
* because it is the provider's own reserved prefix and not a user's folder.
|
|
88
|
+
*
|
|
89
|
+
* Accounts provisioned under googlemail.com get the same namespace spelled
|
|
90
|
+
* `[Google Mail]`, so both forms count. A user folder plainly called `Gmail`
|
|
91
|
+
* does not — the brackets are what make the prefix reserved.
|
|
92
|
+
*/
|
|
93
|
+
const GMAIL_NAMESPACES: ReadonlySet<string> = new Set([
|
|
94
|
+
"[Gmail]",
|
|
95
|
+
"[Google Mail]",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Label for the folder a result came from, or `undefined` when that folder is a
|
|
100
|
+
* view rather than a place — in which case no label is better than a misleading
|
|
101
|
+
* one. An appointed role wins over the provider's spelling, so a folder the
|
|
102
|
+
* account calls `Junk` still reads as "Spam".
|
|
103
|
+
*/
|
|
104
|
+
export function provenanceFolderLabel(
|
|
105
|
+
folder: ResultFolder,
|
|
106
|
+
): string | undefined {
|
|
107
|
+
if (folder.role) {
|
|
108
|
+
if (isVirtualFolderRole(folder.role)) return undefined;
|
|
109
|
+
return canonicalRoleLabel(folder.role);
|
|
110
|
+
}
|
|
111
|
+
if (!folder.providerPath) return undefined;
|
|
112
|
+
if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
|
|
113
|
+
return providerLeaf(folder.providerPath);
|
|
114
|
+
}
|
|
115
|
+
|
|
57
116
|
export function roleIcon(role: FolderRole): ReactNode {
|
|
58
117
|
if (role === "inbox") return <Inbox className="size-4" />;
|
|
59
118
|
if (role === "drafts") return <FileText className="size-4" />;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { MobileSearchView } from "./mobile-search-view.js";
|
|
6
|
+
import type { SearchResult } from "./search-result-row.js";
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
const archived: SearchResult = {
|
|
11
|
+
id: "a1",
|
|
12
|
+
sender: "Mollie",
|
|
13
|
+
subject: "Invoice 2026-02",
|
|
14
|
+
snippet: "Payment already settled.",
|
|
15
|
+
date: "Feb 24",
|
|
16
|
+
folder: { role: "archive" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const spam: SearchResult = {
|
|
20
|
+
id: "s1",
|
|
21
|
+
sender: "billing@unknown-vendor.test",
|
|
22
|
+
subject: "URGENT invoice attached",
|
|
23
|
+
snippet: "Wire the amount below.",
|
|
24
|
+
date: "Feb 11",
|
|
25
|
+
folder: { role: "junk" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sections = [
|
|
29
|
+
{ id: "top", label: "Top matches", results: [archived, spam] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const base = {
|
|
33
|
+
value: "invoice",
|
|
34
|
+
onChange: noop,
|
|
35
|
+
onClear: noop,
|
|
36
|
+
onCancel: noop,
|
|
37
|
+
sections,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("MobileSearchView search scope", () => {
|
|
41
|
+
it("offers held-out spam on the phone tier too", () => {
|
|
42
|
+
const html = renderToString(
|
|
43
|
+
createElement(MobileSearchView, {
|
|
44
|
+
...base,
|
|
45
|
+
scope: { kind: "global" as const },
|
|
46
|
+
onScopeToSpam: noop,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
50
|
+
assert.match(html, /result from Spam/);
|
|
51
|
+
assert.match(html, /Archive/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("shows neither spam nor provenance labels when scoped", () => {
|
|
55
|
+
const html = renderToString(
|
|
56
|
+
createElement(MobileSearchView, {
|
|
57
|
+
...base,
|
|
58
|
+
scope: { kind: "folder" as const, role: "inbox" as const },
|
|
59
|
+
onScopeToSpam: noop,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
63
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
64
|
+
assert.doesNotMatch(html, /Archive/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("passes a caller-supplied spam total through", () => {
|
|
68
|
+
const html = renderToString(
|
|
69
|
+
createElement(MobileSearchView, {
|
|
70
|
+
...base,
|
|
71
|
+
scope: { kind: "global" as const },
|
|
72
|
+
spamMatchCount: 42,
|
|
73
|
+
onScopeToSpam: noop,
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
assert.match(html, />42</);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { MobileSearchView } from "./mobile-search-view.js";
|
|
9
9
|
import type { SearchChip } from "./search-chip-input.js";
|
|
10
10
|
import type { SearchResult } from "./search-result-row.js";
|
|
11
|
-
import type { SearchResultSection } from "./search-results.js";
|
|
11
|
+
import type { SearchResultSection, SearchScope } from "./search-results.js";
|
|
12
12
|
|
|
13
13
|
const phoneFrame: Decorator = (Story) => (
|
|
14
14
|
<div
|
|
@@ -97,6 +97,56 @@ const emptySections: SearchResultSection[] = [
|
|
|
97
97
|
{ id: "related", label: "Related", results: [] },
|
|
98
98
|
];
|
|
99
99
|
|
|
100
|
+
/** Matches spread across ordinary folders, each carrying where it was read from. */
|
|
101
|
+
const crossFolderMatches: SearchResult[] = [
|
|
102
|
+
{ ...topMatches[0], folder: { role: "inbox" } },
|
|
103
|
+
{ ...topMatches[1], folder: { role: "inbox" } },
|
|
104
|
+
{
|
|
105
|
+
id: "x1",
|
|
106
|
+
sender: "Mollie",
|
|
107
|
+
subject: "Invoice 2026-02 — archived",
|
|
108
|
+
snippet: "Filed last month; payment already settled.",
|
|
109
|
+
date: "Feb 24",
|
|
110
|
+
folder: { role: "archive" },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "x2",
|
|
114
|
+
sender: "Accountant",
|
|
115
|
+
subject: "Invoices for the quarter",
|
|
116
|
+
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
117
|
+
date: "Jan 30",
|
|
118
|
+
folder: { providerPath: "Projects/Bookkeeping" },
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/** Matches in the account's `\Junk` folder. */
|
|
123
|
+
const spamMatches: SearchResult[] = [
|
|
124
|
+
{
|
|
125
|
+
id: "s1",
|
|
126
|
+
sender: "billing@unknown-vendor.test",
|
|
127
|
+
subject: "URGENT invoice attached",
|
|
128
|
+
snippet: "Wire the amount below within 24 hours to avoid suspension.",
|
|
129
|
+
date: "Feb 11",
|
|
130
|
+
folder: { role: "junk" },
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "s2",
|
|
134
|
+
sender: "invoices@pay-now.test",
|
|
135
|
+
subject: "Outstanding invoice — final notice",
|
|
136
|
+
snippet: "Your account is overdue. Settle immediately.",
|
|
137
|
+
date: "Feb 4",
|
|
138
|
+
folder: { role: "junk" },
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
const acrossFoldersSections: SearchResultSection[] = [
|
|
143
|
+
{
|
|
144
|
+
id: "top",
|
|
145
|
+
label: "Top matches",
|
|
146
|
+
results: [...crossFolderMatches, ...spamMatches],
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
100
150
|
type Preset = "brief" | "inbox";
|
|
101
151
|
|
|
102
152
|
function Harness({
|
|
@@ -105,12 +155,18 @@ function Harness({
|
|
|
105
155
|
loading,
|
|
106
156
|
sections,
|
|
107
157
|
preset,
|
|
158
|
+
scope,
|
|
159
|
+
spamMatchCount,
|
|
160
|
+
onScopeToSpam,
|
|
108
161
|
}: {
|
|
109
162
|
initialValue?: string;
|
|
110
163
|
initialChips?: SearchChip[];
|
|
111
164
|
loading?: boolean;
|
|
112
165
|
sections?: SearchResultSection[];
|
|
113
166
|
preset: Preset;
|
|
167
|
+
scope?: SearchScope;
|
|
168
|
+
spamMatchCount?: number;
|
|
169
|
+
onScopeToSpam?: () => void;
|
|
114
170
|
}) {
|
|
115
171
|
const [value, setValue] = useState(initialValue);
|
|
116
172
|
const [chips, setChips] = useState<SearchChip[]>(initialChips);
|
|
@@ -183,6 +239,9 @@ function Harness({
|
|
|
183
239
|
sections={sections}
|
|
184
240
|
loading={loading}
|
|
185
241
|
onSelectResult={setOpened}
|
|
242
|
+
scope={scope}
|
|
243
|
+
spamMatchCount={spamMatchCount}
|
|
244
|
+
onScopeToSpam={onScopeToSpam}
|
|
186
245
|
/>
|
|
187
246
|
);
|
|
188
247
|
}
|
|
@@ -257,14 +316,70 @@ export const RelatedSelectable: Story = {
|
|
|
257
316
|
* top bar uses, inside the full-screen takeover's own chrome. The chip is
|
|
258
317
|
* removable in place — backspace at the start of the text reaches it just as it
|
|
259
318
|
* does on desktop.
|
|
319
|
+
*
|
|
320
|
+
* The chip and the scope say the same thing, which is the point: an `in:spam`
|
|
321
|
+
* chip is what a Spam-scoped search looks like in the bar.
|
|
260
322
|
*/
|
|
261
323
|
export const ScopedByChip: Story = {
|
|
262
324
|
render: () => (
|
|
263
325
|
<Harness
|
|
264
326
|
initialValue="invoice"
|
|
265
327
|
initialChips={[{ id: "in:spam", label: "in:spam" }]}
|
|
266
|
-
sections={
|
|
328
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
329
|
+
scope={{ kind: "folder", role: "junk" }}
|
|
330
|
+
preset="inbox"
|
|
331
|
+
/>
|
|
332
|
+
),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Global search on the phone, holding spam out and offering it above the
|
|
337
|
+
* results — the same treatment the desktop list pane gives it, because both
|
|
338
|
+
* tiers render the one `SearchResults` body. Rows name the folder they came
|
|
339
|
+
* from; the two spam matches in the same data are not among them.
|
|
340
|
+
*/
|
|
341
|
+
export const GlobalAcrossFolders: Story = {
|
|
342
|
+
render: () => (
|
|
343
|
+
<Harness
|
|
344
|
+
initialValue="invoice"
|
|
345
|
+
sections={acrossFoldersSections}
|
|
346
|
+
scope={{ kind: "global" }}
|
|
347
|
+
onScopeToSpam={() => {}}
|
|
348
|
+
preset="brief"
|
|
349
|
+
/>
|
|
350
|
+
),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The same rows scoped to the inbox. No spam, no count, no offer, and no
|
|
355
|
+
* provenance labels — the chip in the bar already says where the search is
|
|
356
|
+
* looking.
|
|
357
|
+
*/
|
|
358
|
+
export const ScopedToInbox: Story = {
|
|
359
|
+
render: () => (
|
|
360
|
+
<Harness
|
|
361
|
+
initialValue="invoice"
|
|
362
|
+
initialChips={[{ id: "in:inbox", label: "in:inbox" }]}
|
|
363
|
+
sections={acrossFoldersSections}
|
|
364
|
+
scope={{ kind: "folder", role: "inbox" }}
|
|
365
|
+
onScopeToSpam={() => {}}
|
|
267
366
|
preset="inbox"
|
|
268
367
|
/>
|
|
269
368
|
),
|
|
270
369
|
};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A global phone search whose only matches are in Spam: the offer stands above
|
|
373
|
+
* the empty state rather than leaving the search looking fruitless.
|
|
374
|
+
*/
|
|
375
|
+
export const GlobalOnlySpamMatches: Story = {
|
|
376
|
+
render: () => (
|
|
377
|
+
<Harness
|
|
378
|
+
initialValue="invoice"
|
|
379
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
380
|
+
scope={{ kind: "global" }}
|
|
381
|
+
onScopeToSpam={() => {}}
|
|
382
|
+
preset="brief"
|
|
383
|
+
/>
|
|
384
|
+
),
|
|
385
|
+
};
|
|
@@ -4,7 +4,11 @@ import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
|
|
|
4
4
|
import { SearchBar } from "./search-bar.js";
|
|
5
5
|
import type { SearchChip } from "./search-chip-input.js";
|
|
6
6
|
import type { SearchResult } from "./search-result-row.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type SearchResultSection,
|
|
9
|
+
SearchResults,
|
|
10
|
+
type SearchScope,
|
|
11
|
+
} from "./search-results.js";
|
|
8
12
|
|
|
9
13
|
export interface MobileSearchViewProps {
|
|
10
14
|
value: string;
|
|
@@ -40,6 +44,12 @@ export interface MobileSearchViewProps {
|
|
|
40
44
|
*/
|
|
41
45
|
chips?: readonly SearchChip[];
|
|
42
46
|
onRemoveChip?: (id: string) => void;
|
|
47
|
+
/** What the search covers; see `SearchResultsProps`. Defaults to global. */
|
|
48
|
+
scope?: SearchScope;
|
|
49
|
+
/** Total spam matches found; see `SearchResultsProps`. */
|
|
50
|
+
spamMatchCount?: number;
|
|
51
|
+
/** Scope the search to Spam; see `SearchResultsProps`. */
|
|
52
|
+
onScopeToSpam?: () => void;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/**
|
|
@@ -52,6 +62,9 @@ export interface MobileSearchViewProps {
|
|
|
52
62
|
* the inboxes use) so search carries identical filters; pass no `filter` to drop
|
|
53
63
|
* the chrome. Desktop reuses the same `SearchResults` body in the list pane.
|
|
54
64
|
* Presentational and prop-driven.
|
|
65
|
+
*
|
|
66
|
+
* Search scope passes straight through, so the phone tier holds spam out,
|
|
67
|
+
* offers it and labels provenance on exactly the same terms as desktop.
|
|
55
68
|
*/
|
|
56
69
|
export function MobileSearchView({
|
|
57
70
|
value,
|
|
@@ -67,6 +80,9 @@ export function MobileSearchView({
|
|
|
67
80
|
tokens,
|
|
68
81
|
chips,
|
|
69
82
|
onRemoveChip,
|
|
83
|
+
scope,
|
|
84
|
+
spamMatchCount,
|
|
85
|
+
onScopeToSpam,
|
|
70
86
|
}: MobileSearchViewProps) {
|
|
71
87
|
const body = (
|
|
72
88
|
<SearchResults
|
|
@@ -77,6 +93,9 @@ export function MobileSearchView({
|
|
|
77
93
|
loading={loading}
|
|
78
94
|
onSelectResult={onSelectResult}
|
|
79
95
|
tokens={tokens}
|
|
96
|
+
scope={scope}
|
|
97
|
+
spamMatchCount={spamMatchCount}
|
|
98
|
+
onScopeToSpam={onScopeToSpam}
|
|
80
99
|
/>
|
|
81
100
|
);
|
|
82
101
|
|
|
@@ -2,6 +2,7 @@ import { Flag } from "lucide-react";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
import { Badge } from "./badge.js";
|
|
5
|
+
import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
|
|
5
6
|
|
|
6
7
|
export type SearchResultTone =
|
|
7
8
|
| "neutral"
|
|
@@ -27,6 +28,12 @@ export interface SearchResult {
|
|
|
27
28
|
threadId?: string;
|
|
28
29
|
/** The mailbox the result lives in; paired with {@link threadId} to open it. */
|
|
29
30
|
mailboxId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The folder this row was read from. A search that reaches every folder
|
|
33
|
+
* returns rows from all over, so the row says where it came from; see
|
|
34
|
+
* {@link provenanceFolderLabel} for which folders can be named.
|
|
35
|
+
*/
|
|
36
|
+
folder?: ResultFolder;
|
|
30
37
|
/**
|
|
31
38
|
* Why a semantic ("Related") hit matched — a plain-language label derived
|
|
32
39
|
* from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
|
|
@@ -43,6 +50,12 @@ export interface SearchResultRowProps {
|
|
|
43
50
|
onClick?: () => void;
|
|
44
51
|
/** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
|
|
45
52
|
query?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Show the folder the row came from. Defaults to true. A search confined to
|
|
55
|
+
* one folder turns it off — every row would carry the same label, which is
|
|
56
|
+
* noise rather than provenance.
|
|
57
|
+
*/
|
|
58
|
+
showFolder?: boolean;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
function highlight(text: string, query?: string): ReactNode {
|
|
@@ -79,7 +92,12 @@ export function SearchResultRow({
|
|
|
79
92
|
result,
|
|
80
93
|
onClick,
|
|
81
94
|
query,
|
|
95
|
+
showFolder = true,
|
|
82
96
|
}: SearchResultRowProps) {
|
|
97
|
+
const folderLabel =
|
|
98
|
+
showFolder && result.folder
|
|
99
|
+
? provenanceFolderLabel(result.folder)
|
|
100
|
+
: undefined;
|
|
83
101
|
return (
|
|
84
102
|
<button
|
|
85
103
|
type="button"
|
|
@@ -118,6 +136,11 @@ export function SearchResultRow({
|
|
|
118
136
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
|
|
119
137
|
{highlight(result.snippet, query)}
|
|
120
138
|
</span>
|
|
139
|
+
{folderLabel && (
|
|
140
|
+
<Badge tone="neutral" className="shrink-0">
|
|
141
|
+
{folderLabel}
|
|
142
|
+
</Badge>
|
|
143
|
+
)}
|
|
121
144
|
{result.category && (
|
|
122
145
|
<Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
|
|
123
146
|
{result.category.label}
|
|
@@ -87,3 +87,184 @@ describe("SearchResults", () => {
|
|
|
87
87
|
assert.doesNotMatch(html, /Remove filter/);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
const spamResult: SearchResult = {
|
|
92
|
+
id: "s1",
|
|
93
|
+
sender: "billing@unknown-vendor.test",
|
|
94
|
+
subject: "URGENT invoice attached",
|
|
95
|
+
snippet: "Wire the amount below.",
|
|
96
|
+
date: "Feb 11",
|
|
97
|
+
folder: { role: "junk" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const archivedResult: SearchResult = {
|
|
101
|
+
...result,
|
|
102
|
+
id: "a1",
|
|
103
|
+
sender: "Mollie",
|
|
104
|
+
folder: { role: "archive" },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const mixed: SearchResultSection[] = [
|
|
108
|
+
{ id: "results", label: "Results", results: [archivedResult, spamResult] },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
describe("SearchResults spam handling", () => {
|
|
112
|
+
it("holds spam out of a global search and offers it as a count", () => {
|
|
113
|
+
const html = renderToString(
|
|
114
|
+
createElement(SearchResults, {
|
|
115
|
+
value: "invoice",
|
|
116
|
+
sections: mixed,
|
|
117
|
+
scope: { kind: "global" },
|
|
118
|
+
onScopeToSpam: noop,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
122
|
+
assert.match(html, /result from Spam/);
|
|
123
|
+
assert.match(html, /Mollie/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("makes no offer when a global search found nothing in spam", () => {
|
|
127
|
+
const html = renderToString(
|
|
128
|
+
createElement(SearchResults, {
|
|
129
|
+
value: "invoice",
|
|
130
|
+
sections: [
|
|
131
|
+
{ id: "results", label: "Results", results: [archivedResult] },
|
|
132
|
+
],
|
|
133
|
+
scope: { kind: "global" },
|
|
134
|
+
onScopeToSpam: noop,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("offers spam above the empty state when every match is spam", () => {
|
|
141
|
+
const html = renderToString(
|
|
142
|
+
createElement(SearchResults, {
|
|
143
|
+
value: "invoice",
|
|
144
|
+
sections: [{ id: "results", label: "Results", results: [spamResult] }],
|
|
145
|
+
scope: { kind: "global" },
|
|
146
|
+
onScopeToSpam: noop,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
assert.match(html, /No matches for/);
|
|
150
|
+
assert.match(html, /result from Spam/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("shows neither spam rows nor an offer when scoped elsewhere", () => {
|
|
154
|
+
const html = renderToString(
|
|
155
|
+
createElement(SearchResults, {
|
|
156
|
+
value: "invoice",
|
|
157
|
+
sections: mixed,
|
|
158
|
+
scope: { kind: "folder", role: "inbox" },
|
|
159
|
+
onScopeToSpam: noop,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
assert.doesNotMatch(html, /unknown-vendor/);
|
|
163
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
164
|
+
assert.match(html, /Mollie/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("renders spam rows normally and makes no offer when scoped to spam", () => {
|
|
168
|
+
const html = renderToString(
|
|
169
|
+
createElement(SearchResults, {
|
|
170
|
+
value: "invoice",
|
|
171
|
+
sections: mixed,
|
|
172
|
+
scope: { kind: "folder", role: "junk" },
|
|
173
|
+
onScopeToSpam: noop,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
assert.match(html, /unknown-vendor/);
|
|
177
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("makes no offer without a way to scope to spam", () => {
|
|
181
|
+
const html = renderToString(
|
|
182
|
+
createElement(SearchResults, {
|
|
183
|
+
value: "invoice",
|
|
184
|
+
sections: mixed,
|
|
185
|
+
scope: { kind: "global" },
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
assert.doesNotMatch(html, /from Spam/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("prefers a caller-supplied total over the rows held out of this page", () => {
|
|
192
|
+
const html = renderToString(
|
|
193
|
+
createElement(SearchResults, {
|
|
194
|
+
value: "invoice",
|
|
195
|
+
sections: mixed,
|
|
196
|
+
scope: { kind: "global" },
|
|
197
|
+
spamMatchCount: 42,
|
|
198
|
+
onScopeToSpam: noop,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
assert.match(html, />42</);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("SearchResults provenance labels", () => {
|
|
206
|
+
it("names the folder each row came from in a global search", () => {
|
|
207
|
+
const html = renderToString(
|
|
208
|
+
createElement(SearchResults, {
|
|
209
|
+
value: "invoice",
|
|
210
|
+
sections: [
|
|
211
|
+
{
|
|
212
|
+
id: "results",
|
|
213
|
+
label: "Results",
|
|
214
|
+
results: [
|
|
215
|
+
archivedResult,
|
|
216
|
+
{
|
|
217
|
+
...result,
|
|
218
|
+
id: "c1",
|
|
219
|
+
folder: { providerPath: "Projects/Books" },
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
scope: { kind: "global" },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
assert.match(html, /Archive/);
|
|
228
|
+
assert.match(html, /Books/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("drops the labels when the search is scoped to one folder", () => {
|
|
232
|
+
const html = renderToString(
|
|
233
|
+
createElement(SearchResults, {
|
|
234
|
+
value: "invoice",
|
|
235
|
+
sections: [
|
|
236
|
+
{ id: "results", label: "Results", results: [archivedResult] },
|
|
237
|
+
],
|
|
238
|
+
scope: { kind: "folder", role: "archive" },
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
assert.doesNotMatch(html, /Archive/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("leaves a row from a view rather than a folder unlabelled", () => {
|
|
245
|
+
const html = renderToString(
|
|
246
|
+
createElement(SearchResults, {
|
|
247
|
+
value: "invoice",
|
|
248
|
+
sections: [
|
|
249
|
+
{
|
|
250
|
+
id: "results",
|
|
251
|
+
label: "Results",
|
|
252
|
+
results: [
|
|
253
|
+
{ ...result, id: "v1", folder: { role: "all" } },
|
|
254
|
+
{ ...result, id: "v2", folder: { role: "flagged" } },
|
|
255
|
+
{
|
|
256
|
+
...result,
|
|
257
|
+
id: "v3",
|
|
258
|
+
folder: { providerPath: "[Gmail]/Important" },
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
scope: { kind: "global" },
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
assert.doesNotMatch(html, /All Mail/);
|
|
267
|
+
assert.doesNotMatch(html, /Starred/);
|
|
268
|
+
assert.doesNotMatch(html, /Important/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -73,43 +73,71 @@ const resultSections: SearchResultSection[] = [
|
|
|
73
73
|
];
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Matches from outside the inbox — Archive, Sent
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Matches from outside the inbox — Archive, Sent and a custom folder — each
|
|
77
|
+
* carrying the folder it was read from. These are the rows a search reaching
|
|
78
|
+
* every folder returns that an INBOX-only one could not.
|
|
79
79
|
*/
|
|
80
80
|
const crossFolderMatches: SearchResult[] = [
|
|
81
81
|
{
|
|
82
82
|
id: "x1",
|
|
83
83
|
sender: "Mollie",
|
|
84
84
|
subject: "Invoice 2026-02 — archived",
|
|
85
|
-
snippet: "Filed
|
|
85
|
+
snippet: "Filed last month; payment already settled.",
|
|
86
86
|
date: "Feb 24",
|
|
87
|
+
folder: { role: "archive" },
|
|
87
88
|
category: { label: "Receipt", tone: "positive" },
|
|
88
89
|
},
|
|
89
90
|
{
|
|
90
91
|
id: "x2",
|
|
91
92
|
sender: "me",
|
|
92
93
|
subject: "Re: invoice query",
|
|
93
|
-
snippet: "
|
|
94
|
+
snippet: "Attaching the invoice you asked for.",
|
|
94
95
|
date: "Feb 18",
|
|
96
|
+
folder: { role: "sent" },
|
|
95
97
|
},
|
|
96
98
|
{
|
|
97
|
-
id: "
|
|
99
|
+
id: "x4",
|
|
100
|
+
sender: "Accountant",
|
|
101
|
+
subject: "Invoices for the quarter",
|
|
102
|
+
snippet: "The quarterly set, filed with the rest of the bookkeeping.",
|
|
103
|
+
date: "Jan 30",
|
|
104
|
+
folder: { providerPath: "Projects/Bookkeeping" },
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Matches that live in the account's `\Junk` folder. A global search holds
|
|
110
|
+
* these out of the sections entirely and offers them as a count instead.
|
|
111
|
+
*/
|
|
112
|
+
const spamMatches: SearchResult[] = [
|
|
113
|
+
{
|
|
114
|
+
id: "s1",
|
|
98
115
|
sender: "billing@unknown-vendor.test",
|
|
99
116
|
subject: "URGENT invoice attached",
|
|
100
|
-
snippet: "
|
|
117
|
+
snippet: "Wire the amount below within 24 hours to avoid suspension.",
|
|
101
118
|
date: "Feb 11",
|
|
102
|
-
|
|
119
|
+
folder: { role: "junk" },
|
|
103
120
|
},
|
|
104
121
|
{
|
|
105
|
-
id: "
|
|
106
|
-
sender: "
|
|
107
|
-
subject: "
|
|
108
|
-
snippet: "
|
|
109
|
-
date: "
|
|
122
|
+
id: "s2",
|
|
123
|
+
sender: "invoices@pay-now.test",
|
|
124
|
+
subject: "Outstanding invoice — final notice",
|
|
125
|
+
snippet: "Your account is overdue. Settle immediately.",
|
|
126
|
+
date: "Feb 4",
|
|
127
|
+
folder: { role: "junk" },
|
|
110
128
|
},
|
|
111
129
|
];
|
|
112
130
|
|
|
131
|
+
/** The literal section a global search returns: inbox rows plus everywhere else. */
|
|
132
|
+
const globalTopMatches: SearchResult[] = [
|
|
133
|
+
...topMatches.map((result) => ({
|
|
134
|
+
...result,
|
|
135
|
+
folder: { role: "inbox" as const },
|
|
136
|
+
})),
|
|
137
|
+
...crossFolderMatches,
|
|
138
|
+
...spamMatches,
|
|
139
|
+
];
|
|
140
|
+
|
|
113
141
|
const emptySections: SearchResultSection[] = [
|
|
114
142
|
{ id: "top", label: "Top matches", results: [] },
|
|
115
143
|
];
|
|
@@ -151,23 +179,45 @@ export const Idle: Story = {
|
|
|
151
179
|
|
|
152
180
|
/**
|
|
153
181
|
* The daily brief's unscoped search: no scope chip in the bar, and the literal
|
|
154
|
-
* section carries matches from every folder — Archive, Sent
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* than the bar promised.
|
|
182
|
+
* section carries matches from every folder — Archive, Sent and custom folders
|
|
183
|
+
* alongside the inbox. Each row says which folder it came from, because with
|
|
184
|
+
* nothing scoping the search the folder is the only thing placing the result.
|
|
158
185
|
*
|
|
159
|
-
* The
|
|
160
|
-
*
|
|
186
|
+
* The two spam matches in the same data are not in this list. They are held out
|
|
187
|
+
* and offered above it as a count.
|
|
161
188
|
*/
|
|
162
|
-
export const
|
|
189
|
+
export const GlobalAcrossFolders: Story = {
|
|
163
190
|
render: () => (
|
|
164
191
|
<Harness
|
|
165
192
|
value="invoice"
|
|
193
|
+
scope={{ kind: "global" }}
|
|
194
|
+
onScopeToSpam={() => {}}
|
|
195
|
+
sections={[
|
|
196
|
+
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
197
|
+
{ id: "related", label: "Related", results: related },
|
|
198
|
+
]}
|
|
199
|
+
/>
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The same global search over an account whose Spam folder holds nothing
|
|
205
|
+
* matching. No spam rows to hold out, so no offer — the offer only ever appears
|
|
206
|
+
* because there is something behind it.
|
|
207
|
+
*/
|
|
208
|
+
export const GlobalWithoutSpamMatches: Story = {
|
|
209
|
+
render: () => (
|
|
210
|
+
<Harness
|
|
211
|
+
value="invoice"
|
|
212
|
+
scope={{ kind: "global" }}
|
|
213
|
+
onScopeToSpam={() => {}}
|
|
166
214
|
sections={[
|
|
167
215
|
{
|
|
168
216
|
id: "top",
|
|
169
217
|
label: "Top matches",
|
|
170
|
-
results:
|
|
218
|
+
results: globalTopMatches.filter(
|
|
219
|
+
(result) => result.folder?.role !== "junk",
|
|
220
|
+
),
|
|
171
221
|
},
|
|
172
222
|
{ id: "related", label: "Related", results: related },
|
|
173
223
|
]}
|
|
@@ -176,18 +226,105 @@ export const UnscopedAcrossFolders: Story = {
|
|
|
176
226
|
};
|
|
177
227
|
|
|
178
228
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
|
|
229
|
+
* An account with no junk folder at all. Nothing is appointed `\Junk`, so no
|
|
230
|
+
* row can be spam and the component behaves exactly as it does when Spam is
|
|
231
|
+
* simply empty — there is no separate case to handle.
|
|
232
|
+
*/
|
|
233
|
+
export const GlobalAccountWithoutSpamFolder: Story = {
|
|
234
|
+
render: () => (
|
|
235
|
+
<Harness
|
|
236
|
+
value="invoice"
|
|
237
|
+
scope={{ kind: "global" }}
|
|
238
|
+
onScopeToSpam={() => {}}
|
|
239
|
+
sections={[
|
|
240
|
+
{ id: "top", label: "Top matches", results: crossFolderMatches },
|
|
241
|
+
]}
|
|
242
|
+
/>
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A global search whose only matches are in Spam. The sections are empty, so
|
|
248
|
+
* the empty state stands — with the offer above it, which is the whole reason
|
|
249
|
+
* the user is not left thinking the search found nothing.
|
|
250
|
+
*/
|
|
251
|
+
export const GlobalOnlySpamMatches: Story = {
|
|
252
|
+
render: () => (
|
|
253
|
+
<Harness
|
|
254
|
+
value="invoice"
|
|
255
|
+
scope={{ kind: "global" }}
|
|
256
|
+
onScopeToSpam={() => {}}
|
|
257
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
258
|
+
/>
|
|
259
|
+
),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Scoped to the inbox (its `in:inbox` chip in the bar), given the very same
|
|
264
|
+
* rows as the global story — spam matches included. Nothing about Spam appears:
|
|
265
|
+
* no rows, no count, no offer. A scoped search shows its own scope and no more,
|
|
266
|
+
* and that asymmetry with the global view is deliberate.
|
|
267
|
+
*
|
|
268
|
+
* The rows also drop their folder labels here. Every row is in the scoped
|
|
269
|
+
* folder, so naming it on each one repeats the chip.
|
|
270
|
+
*/
|
|
271
|
+
export const ScopedToInbox: Story = {
|
|
272
|
+
render: () => (
|
|
273
|
+
<Harness
|
|
274
|
+
value="invoice"
|
|
275
|
+
scope={{ kind: "folder", role: "inbox" }}
|
|
276
|
+
onScopeToSpam={() => {}}
|
|
277
|
+
sections={[
|
|
278
|
+
{ id: "top", label: "Top matches", results: globalTopMatches },
|
|
279
|
+
{ id: "related", label: "Related", results: related },
|
|
280
|
+
]}
|
|
281
|
+
/>
|
|
282
|
+
),
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Scoped to Spam — where taking the offer lands. Ordinary rows, rendered
|
|
287
|
+
* normally, and no offer, because the user is already here. This is the same
|
|
288
|
+
* scoped search reached by navigating to Spam with the query carried over; the
|
|
289
|
+
* offer is a shortcut into it, not a mode of its own.
|
|
290
|
+
*/
|
|
291
|
+
export const ScopedToSpam: Story = {
|
|
292
|
+
render: () => (
|
|
293
|
+
<Harness
|
|
294
|
+
value="invoice"
|
|
295
|
+
scope={{ kind: "folder", role: "junk" }}
|
|
296
|
+
onScopeToSpam={() => {}}
|
|
297
|
+
sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
|
|
298
|
+
/>
|
|
299
|
+
),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A folder a search result can be in but never labelled with. All Mail and
|
|
304
|
+
* Starred are views over mail filed elsewhere, and Gmail exposes them as
|
|
305
|
+
* ordinary folders, so a row read from one carries no provenance label rather
|
|
306
|
+
* than a misleading one. The other rows keep theirs.
|
|
183
307
|
*/
|
|
184
|
-
export const
|
|
308
|
+
export const VirtualFoldersGoUnlabelled: Story = {
|
|
185
309
|
render: () => (
|
|
186
310
|
<Harness
|
|
187
311
|
value="invoice"
|
|
312
|
+
scope={{ kind: "global" }}
|
|
188
313
|
sections={[
|
|
189
|
-
{
|
|
190
|
-
|
|
314
|
+
{
|
|
315
|
+
id: "top",
|
|
316
|
+
label: "Top matches",
|
|
317
|
+
results: [
|
|
318
|
+
{ ...topMatches[0], id: "v1", folder: { role: "all" } },
|
|
319
|
+
{ ...topMatches[1], id: "v2", folder: { role: "flagged" } },
|
|
320
|
+
{
|
|
321
|
+
...topMatches[2],
|
|
322
|
+
id: "v3",
|
|
323
|
+
folder: { providerPath: "[Gmail]/Important" },
|
|
324
|
+
},
|
|
325
|
+
...crossFolderMatches.slice(0, 1),
|
|
326
|
+
],
|
|
327
|
+
},
|
|
191
328
|
]}
|
|
192
329
|
/>
|
|
193
330
|
),
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ChevronDown, Clock } from "lucide-react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import type { FolderRole } from "./folder-role.js";
|
|
4
5
|
import { type SearchResult, SearchResultRow } from "./search-result-row.js";
|
|
5
6
|
import { SearchTokenChips } from "./search-token-chip.js";
|
|
7
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
6
8
|
|
|
7
9
|
/** Rows shown before the "Show N more" expander kicks in. */
|
|
8
10
|
const SECTION_ROW_CAP = 6;
|
|
@@ -15,6 +17,44 @@ export interface SearchResultSection {
|
|
|
15
17
|
initialCollapsed?: boolean;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* What the search currently covers. `global` is the unscoped search the daily
|
|
22
|
+
* brief runs — every account, every folder, no chip in the bar. `folder` is a
|
|
23
|
+
* search the sidebar narrowed to one place, which the bar shows as a chip.
|
|
24
|
+
*/
|
|
25
|
+
export type SearchScope =
|
|
26
|
+
| { kind: "global" }
|
|
27
|
+
| { kind: "folder"; role?: FolderRole };
|
|
28
|
+
|
|
29
|
+
const GLOBAL_SCOPE: SearchScope = { kind: "global" };
|
|
30
|
+
|
|
31
|
+
const isSpamScope = (scope: SearchScope): boolean =>
|
|
32
|
+
scope.kind === "folder" && scope.role === "junk";
|
|
33
|
+
|
|
34
|
+
const isSpamResult = (result: SearchResult): boolean =>
|
|
35
|
+
result.folder?.role === "junk";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Split spam matches out of the rows a search returned.
|
|
39
|
+
*
|
|
40
|
+
* Spam is identified by the account's `\Junk` special-use appointment, never by
|
|
41
|
+
* folder name, so an account that calls it `Junk`, `Bulk Mail` or nothing at
|
|
42
|
+
* all behaves the same, and an account with no junk folder simply never yields
|
|
43
|
+
* a spam row.
|
|
44
|
+
*/
|
|
45
|
+
export function partitionSpamResults(results: SearchResult[]): {
|
|
46
|
+
kept: SearchResult[];
|
|
47
|
+
spam: SearchResult[];
|
|
48
|
+
} {
|
|
49
|
+
const kept: SearchResult[] = [];
|
|
50
|
+
const spam: SearchResult[] = [];
|
|
51
|
+
for (const result of results) {
|
|
52
|
+
if (isSpamResult(result)) spam.push(result);
|
|
53
|
+
else kept.push(result);
|
|
54
|
+
}
|
|
55
|
+
return { kept, spam };
|
|
56
|
+
}
|
|
57
|
+
|
|
18
58
|
export interface SearchResultsProps {
|
|
19
59
|
/** The current query — narrows what's shown and bolds literal matches. */
|
|
20
60
|
value: string;
|
|
@@ -32,6 +72,19 @@ export interface SearchResultsProps {
|
|
|
32
72
|
* query has no recognized tokens.
|
|
33
73
|
*/
|
|
34
74
|
tokens?: { label: string; onRemove: () => void }[];
|
|
75
|
+
/** What the search covers. Defaults to the unscoped, global search. */
|
|
76
|
+
scope?: SearchScope;
|
|
77
|
+
/**
|
|
78
|
+
* Total spam matches the search found, for when the app knows more than the
|
|
79
|
+
* rows on this page. Defaults to the number of spam rows held out of
|
|
80
|
+
* {@link sections}.
|
|
81
|
+
*/
|
|
82
|
+
spamMatchCount?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Scope the search to Spam. Without it there is no offer, so an app that has
|
|
85
|
+
* nowhere to navigate simply never makes one.
|
|
86
|
+
*/
|
|
87
|
+
onScopeToSpam?: () => void;
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
/**
|
|
@@ -44,10 +97,12 @@ function CollapsibleResultSection({
|
|
|
44
97
|
section,
|
|
45
98
|
query,
|
|
46
99
|
onSelectResult,
|
|
100
|
+
showFolder,
|
|
47
101
|
}: {
|
|
48
102
|
section: SearchResultSection;
|
|
49
103
|
query?: string;
|
|
50
104
|
onSelectResult?: (result: SearchResult) => void;
|
|
105
|
+
showFolder?: boolean;
|
|
51
106
|
}) {
|
|
52
107
|
const [collapsed, setCollapsed] = useState(section.initialCollapsed ?? false);
|
|
53
108
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -87,6 +142,7 @@ function CollapsibleResultSection({
|
|
|
87
142
|
key={result.id}
|
|
88
143
|
result={result}
|
|
89
144
|
query={query}
|
|
145
|
+
showFolder={showFolder}
|
|
90
146
|
onClick={
|
|
91
147
|
onSelectResult ? () => onSelectResult(result) : undefined
|
|
92
148
|
}
|
|
@@ -115,6 +171,18 @@ function CollapsibleResultSection({
|
|
|
115
171
|
* sections — one implementation so both tiers render identical rows. The caller
|
|
116
172
|
* owns the surrounding chrome and scroll container (a `FilterSheet`, the
|
|
117
173
|
* takeover header, or the list-pane body). Presentational and prop-driven.
|
|
174
|
+
*
|
|
175
|
+
* Spam is the one folder that does not inline, and it behaves differently by
|
|
176
|
+
* scope:
|
|
177
|
+
*
|
|
178
|
+
* - **Global** — spam rows are held out of the sections and offered instead, as
|
|
179
|
+
* a count with a way into a Spam-scoped search.
|
|
180
|
+
* - **Scoped to anything else** — spam rows are held out and nothing is offered.
|
|
181
|
+
* A scoped search shows its own scope and no more.
|
|
182
|
+
* - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
|
|
183
|
+
*
|
|
184
|
+
* Provenance labels follow the same logic: only a global search names the
|
|
185
|
+
* folder each row came from, because a scoped one would repeat itself.
|
|
118
186
|
*/
|
|
119
187
|
export function SearchResults({
|
|
120
188
|
value,
|
|
@@ -124,11 +192,11 @@ export function SearchResults({
|
|
|
124
192
|
loading,
|
|
125
193
|
onSelectResult,
|
|
126
194
|
tokens,
|
|
195
|
+
scope = GLOBAL_SCOPE,
|
|
196
|
+
spamMatchCount,
|
|
197
|
+
onScopeToSpam,
|
|
127
198
|
}: SearchResultsProps) {
|
|
128
199
|
const hasQuery = value.trim().length > 0;
|
|
129
|
-
const hasResults = (sections ?? []).some(
|
|
130
|
-
(section) => section.results.length > 0,
|
|
131
|
-
);
|
|
132
200
|
|
|
133
201
|
if (!hasQuery) {
|
|
134
202
|
if (recentSearches && recentSearches.length > 0) {
|
|
@@ -181,10 +249,36 @@ export function SearchResults({
|
|
|
181
249
|
);
|
|
182
250
|
}
|
|
183
251
|
|
|
252
|
+
const spamScoped = isSpamScope(scope);
|
|
253
|
+
const isGlobal = scope.kind === "global";
|
|
254
|
+
|
|
255
|
+
const partitioned = (sections ?? []).map((section) => {
|
|
256
|
+
if (spamScoped) return { section, spam: [] as SearchResult[] };
|
|
257
|
+
const { kept, spam } = partitionSpamResults(section.results);
|
|
258
|
+
return { section: { ...section, results: kept }, spam };
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const visibleSections = partitioned.map((entry) => entry.section);
|
|
262
|
+
const heldOutSpamCount = partitioned.reduce(
|
|
263
|
+
(total, entry) => total + entry.spam.length,
|
|
264
|
+
0,
|
|
265
|
+
);
|
|
266
|
+
const spamCount = spamMatchCount ?? heldOutSpamCount;
|
|
267
|
+
const spamOffer = isGlobal &&
|
|
268
|
+
spamCount > 0 &&
|
|
269
|
+
onScopeToSpam !== undefined && (
|
|
270
|
+
<SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const hasResults = visibleSections.some(
|
|
274
|
+
(section) => section.results.length > 0,
|
|
275
|
+
);
|
|
276
|
+
|
|
184
277
|
if (!hasResults) {
|
|
185
278
|
return (
|
|
186
279
|
<div className="flex flex-col">
|
|
187
280
|
{chips}
|
|
281
|
+
{spamOffer}
|
|
188
282
|
<div className="px-row-inset py-10 text-center">
|
|
189
283
|
<p className="text-sm font-medium text-fg">
|
|
190
284
|
No matches for “{value}”
|
|
@@ -200,13 +294,15 @@ export function SearchResults({
|
|
|
200
294
|
return (
|
|
201
295
|
<div className="flex flex-col">
|
|
202
296
|
{chips}
|
|
203
|
-
{
|
|
204
|
-
|
|
297
|
+
{spamOffer}
|
|
298
|
+
{visibleSections
|
|
299
|
+
.filter((section) => section.results.length > 0)
|
|
205
300
|
.map((section) => (
|
|
206
301
|
<CollapsibleResultSection
|
|
207
302
|
key={section.id}
|
|
208
303
|
section={section}
|
|
209
304
|
query={value}
|
|
305
|
+
showFolder={isGlobal}
|
|
210
306
|
onSelectResult={onSelectResult}
|
|
211
307
|
/>
|
|
212
308
|
))}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("SpamResultsOffer", () => {
|
|
10
|
+
it("states the count and offers a way into Spam", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(SpamResultsOffer, { count: 3, onScopeToSpam: noop }),
|
|
13
|
+
);
|
|
14
|
+
assert.match(html, />3</);
|
|
15
|
+
assert.match(html, /results from Spam/);
|
|
16
|
+
assert.match(html, /View them/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads in the singular for one match", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(SpamResultsOffer, { count: 1, onScopeToSpam: noop }),
|
|
22
|
+
);
|
|
23
|
+
assert.match(html, /result from Spam/);
|
|
24
|
+
assert.doesNotMatch(html, /results from Spam/);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SpamResultsOffer } from "./spam-results-offer.js";
|
|
3
|
+
|
|
4
|
+
/** Frames the offer at the width of the list pane it sits at the top of. */
|
|
5
|
+
const listPaneFrame: Decorator = (Story) => (
|
|
6
|
+
<div
|
|
7
|
+
className="overflow-hidden rounded-lg border border-line bg-canvas"
|
|
8
|
+
style={{ width: 360 }}
|
|
9
|
+
>
|
|
10
|
+
<Story />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof SpamResultsOffer> = {
|
|
15
|
+
title: "Components/SpamResultsOffer",
|
|
16
|
+
component: SpamResultsOffer,
|
|
17
|
+
parameters: { layout: "centered" },
|
|
18
|
+
decorators: [listPaneFrame],
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
type Story = StoryObj<typeof SpamResultsOffer>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Spam matches a global search held out of its results. Deliberately quiet —
|
|
26
|
+
* an offer, not a warning — and the action scopes the search to Spam rather
|
|
27
|
+
* than opening a view of its own.
|
|
28
|
+
*/
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: { count: 3, onScopeToSpam: () => {} },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** One match reads in the singular. */
|
|
34
|
+
export const SingleResult: Story = {
|
|
35
|
+
args: { count: 1, onScopeToSpam: () => {} },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Large counts stay on one line; the figure is tabular so it does not jitter. */
|
|
39
|
+
export const ManyResults: Story = {
|
|
40
|
+
args: { count: 128, onScopeToSpam: () => {} },
|
|
41
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Banner } from "./banner.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
|
|
4
|
+
export interface SpamResultsOfferProps {
|
|
5
|
+
/** How many matches the search found in Spam. */
|
|
6
|
+
count: number;
|
|
7
|
+
/**
|
|
8
|
+
* Scope the search to Spam. This is a shortcut to the state reached by
|
|
9
|
+
* navigating to Spam with the query carried over — the same scoped search,
|
|
10
|
+
* with the same `in:spam` chip — not a separate result mode.
|
|
11
|
+
*/
|
|
12
|
+
onScopeToSpam: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const plural = (n: number): string => (n === 1 ? "result" : "results");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Spam matches held out of a global search, offered rather than mixed in. Spam
|
|
19
|
+
* is the one folder a search that reaches everywhere does not inline, because
|
|
20
|
+
* the whole point of the folder is that its contents are unwanted until asked
|
|
21
|
+
* for. Taking the offer scopes the search to Spam.
|
|
22
|
+
*
|
|
23
|
+
* Quiet on purpose: this is an offer, not a warning. Presentational — the
|
|
24
|
+
* caller owns what "scope to spam" does.
|
|
25
|
+
*/
|
|
26
|
+
export function SpamResultsOffer({
|
|
27
|
+
count,
|
|
28
|
+
onScopeToSpam,
|
|
29
|
+
}: SpamResultsOfferProps) {
|
|
30
|
+
return (
|
|
31
|
+
<Banner
|
|
32
|
+
tone="info"
|
|
33
|
+
variant="soft"
|
|
34
|
+
className="items-center justify-between gap-3 rounded-none border-b border-line"
|
|
35
|
+
>
|
|
36
|
+
<div className="flex items-center justify-between gap-3">
|
|
37
|
+
<p className="min-w-0 text-xs text-fg-muted">
|
|
38
|
+
<span className="font-semibold text-fg tabular-nums">{count}</span>{" "}
|
|
39
|
+
{`${plural(count)} from Spam`}
|
|
40
|
+
</p>
|
|
41
|
+
<Button
|
|
42
|
+
variant="ghost"
|
|
43
|
+
size="sm"
|
|
44
|
+
onClick={onScopeToSpam}
|
|
45
|
+
className="shrink-0 text-accent"
|
|
46
|
+
>
|
|
47
|
+
View them
|
|
48
|
+
</Button>
|
|
49
|
+
</div>
|
|
50
|
+
</Banner>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -121,7 +121,10 @@ export {
|
|
|
121
121
|
export {
|
|
122
122
|
canonicalRoleLabel,
|
|
123
123
|
type FolderRole,
|
|
124
|
+
isVirtualFolderRole,
|
|
125
|
+
provenanceFolderLabel,
|
|
124
126
|
providerLeaf,
|
|
127
|
+
type ResultFolder,
|
|
125
128
|
roleIcon,
|
|
126
129
|
} from "./components/folder-role.js";
|
|
127
130
|
export {
|
|
@@ -304,9 +307,11 @@ export {
|
|
|
304
307
|
type SearchResultTone,
|
|
305
308
|
} from "./components/search-result-row.js";
|
|
306
309
|
export {
|
|
310
|
+
partitionSpamResults,
|
|
307
311
|
type SearchResultSection,
|
|
308
312
|
SearchResults,
|
|
309
313
|
type SearchResultsProps,
|
|
314
|
+
type SearchScope,
|
|
310
315
|
} from "./components/search-results.js";
|
|
311
316
|
export {
|
|
312
317
|
SearchChipRow,
|
|
@@ -358,6 +363,10 @@ export {
|
|
|
358
363
|
SlidePanel,
|
|
359
364
|
type SlidePanelProps,
|
|
360
365
|
} from "./components/slide-panel.js";
|
|
366
|
+
export {
|
|
367
|
+
SpamResultsOffer,
|
|
368
|
+
type SpamResultsOfferProps,
|
|
369
|
+
} from "./components/spam-results-offer.js";
|
|
361
370
|
export {
|
|
362
371
|
commitPeek,
|
|
363
372
|
SwipeableRow,
|