@remit/web-client 0.0.86 → 0.0.87
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/auth/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +155 -34
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +90 -0
- package/src/lib/organize/property-prefill.test.ts +173 -0
- package/src/lib/organize/property-prefill.ts +151 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +110 -31
- package/src/lib/organize/search-to-rule.ts +12 -4
- package/src/lib/organize/sender-fallback.ts +11 -1
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
activeSearchTerm,
|
|
5
|
+
applySearchSuggestion,
|
|
6
|
+
buildSearchSuggestions,
|
|
7
|
+
contactSuggestionValue,
|
|
8
|
+
SEARCH_SUGGESTION_LIMIT,
|
|
9
|
+
type SearchSuggestionData,
|
|
10
|
+
searchSuggestionRequest,
|
|
11
|
+
} from "./search-suggestions.js";
|
|
12
|
+
import { parseSearchTokens } from "./search-tokens.js";
|
|
13
|
+
|
|
14
|
+
const DATA: SearchSuggestionData = {
|
|
15
|
+
mailboxes: [{ value: "Archive" }, { value: "Sent Items" }],
|
|
16
|
+
accounts: [{ value: "work" }, { value: "personal" }],
|
|
17
|
+
contacts: [
|
|
18
|
+
{
|
|
19
|
+
value: "alice@example.com",
|
|
20
|
+
label: "Alice Adams",
|
|
21
|
+
hint: "alice@example.com",
|
|
22
|
+
},
|
|
23
|
+
{ value: "bob@example.com" },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const values = (suggestions: readonly { value: string }[]): string[] =>
|
|
28
|
+
suggestions.map((s) => s.value);
|
|
29
|
+
|
|
30
|
+
describe("activeSearchTerm", () => {
|
|
31
|
+
it("finds the term the caret sits in", () => {
|
|
32
|
+
const term = activeSearchTerm("invoice from:ali", 16);
|
|
33
|
+
assert.equal(term.raw, "from:ali");
|
|
34
|
+
assert.equal(term.start, 8);
|
|
35
|
+
assert.equal(term.end, 16);
|
|
36
|
+
assert.equal(term.name, "from");
|
|
37
|
+
assert.equal(term.value, "ali");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("has no token name for a bare word", () => {
|
|
41
|
+
const term = activeSearchTerm("invoice", 4);
|
|
42
|
+
assert.equal(term.name, undefined);
|
|
43
|
+
assert.equal(term.raw, "invoice");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is an empty term at the caret when the caret is on whitespace", () => {
|
|
47
|
+
const term = activeSearchTerm("invoice ", 8);
|
|
48
|
+
assert.deepEqual(term, { start: 8, end: 8, raw: "" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reads through a quoted value", () => {
|
|
52
|
+
const term = activeSearchTerm('in:"Sent It', 11);
|
|
53
|
+
assert.equal(term.name, "in");
|
|
54
|
+
assert.equal(term.value, "Sent It");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("clamps a caret past the end of the query", () => {
|
|
58
|
+
assert.equal(activeSearchTerm("is:read", 99).name, "is");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("searchSuggestionRequest", () => {
|
|
63
|
+
it("asks for token names while a bare word is typed", () => {
|
|
64
|
+
assert.deepEqual(searchSuggestionRequest("inv", 3), {
|
|
65
|
+
kind: "token",
|
|
66
|
+
query: "inv",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("names the lookup a committed token needs", () => {
|
|
71
|
+
assert.deepEqual(searchSuggestionRequest("from:ali", 8), {
|
|
72
|
+
kind: "value",
|
|
73
|
+
token: "from",
|
|
74
|
+
source: "contact",
|
|
75
|
+
query: "ali",
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(searchSuggestionRequest("in:", 3), {
|
|
78
|
+
kind: "value",
|
|
79
|
+
token: "in",
|
|
80
|
+
source: "mailbox",
|
|
81
|
+
query: "",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("asks for nothing on an unknown token", () => {
|
|
86
|
+
assert.equal(searchSuggestionRequest("label:urgent", 12), undefined);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("buildSearchSuggestions", () => {
|
|
91
|
+
it("offers the vocabulary on an empty query", () => {
|
|
92
|
+
const suggestions = buildSearchSuggestions("", 0);
|
|
93
|
+
assert.deepEqual(values(suggestions), [
|
|
94
|
+
"from:",
|
|
95
|
+
"subject:",
|
|
96
|
+
"category:",
|
|
97
|
+
"is:",
|
|
98
|
+
"has:",
|
|
99
|
+
"in:",
|
|
100
|
+
"account:",
|
|
101
|
+
"after:",
|
|
102
|
+
"before:",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("matches a bare word against token names", () => {
|
|
107
|
+
const suggestions = buildSearchSuggestions("cat", 3);
|
|
108
|
+
assert.ok(values(suggestions).includes("category:"));
|
|
109
|
+
assert.ok(values(suggestions).includes("category:personal"));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("reaches a fixed value by the word the user knows", () => {
|
|
113
|
+
const suggestions = buildSearchSuggestions("unr", 3);
|
|
114
|
+
assert.equal(suggestions[0]?.value, "is:unread");
|
|
115
|
+
assert.equal(suggestions[0]?.label, "Unread");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("offers a token's fixed values once its name is committed", () => {
|
|
119
|
+
const suggestions = buildSearchSuggestions("is:", 3);
|
|
120
|
+
assert.deepEqual(values(suggestions), [
|
|
121
|
+
"is:unread",
|
|
122
|
+
"is:read",
|
|
123
|
+
"is:starred",
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("narrows fixed values against what has been typed", () => {
|
|
128
|
+
assert.deepEqual(values(buildSearchSuggestions("category:mar", 12)), [
|
|
129
|
+
"category:marketing",
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("offers the mailboxes it was given for in:, quoted where needed", () => {
|
|
134
|
+
assert.deepEqual(values(buildSearchSuggestions("in:", 3, DATA)), [
|
|
135
|
+
"in:Archive",
|
|
136
|
+
'in:"Sent Items"',
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("offers the accounts it was given for account:", () => {
|
|
141
|
+
assert.deepEqual(values(buildSearchSuggestions("account:wo", 10, DATA)), [
|
|
142
|
+
"account:work",
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("offers contacts for from:, reading as the display name", () => {
|
|
147
|
+
const suggestions = buildSearchSuggestions("from:ali", 8, DATA);
|
|
148
|
+
assert.deepEqual(values(suggestions), ["from:alice@example.com"]);
|
|
149
|
+
assert.equal(suggestions[0]?.label, "Alice Adams");
|
|
150
|
+
assert.equal(suggestions[0]?.hint, "alice@example.com");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("offers nothing for a token whose values are typed, not chosen", () => {
|
|
154
|
+
assert.deepEqual(buildSearchSuggestions("after:2024", 10, DATA), []);
|
|
155
|
+
assert.deepEqual(buildSearchSuggestions("subject:tax", 11, DATA), []);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("offers nothing for an unknown token", () => {
|
|
159
|
+
assert.deepEqual(buildSearchSuggestions("label:urgent", 12, DATA), []);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("leads with prefix matches", () => {
|
|
163
|
+
const suggestions = buildSearchSuggestions("account:per", 11, DATA);
|
|
164
|
+
assert.deepEqual(values(suggestions), ["account:personal"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("caps the list", () => {
|
|
168
|
+
const mailboxes = Array.from({ length: 20 }, (_, i) => ({
|
|
169
|
+
value: `Folder${i}`,
|
|
170
|
+
}));
|
|
171
|
+
const suggestions = buildSearchSuggestions("in:", 3, { mailboxes });
|
|
172
|
+
assert.equal(suggestions.length, SEARCH_SUGGESTION_LIMIT);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("completes the term the caret is in, not the last one", () => {
|
|
176
|
+
const suggestions = buildSearchSuggestions("is: from:bob", 3);
|
|
177
|
+
assert.deepEqual(values(suggestions), [
|
|
178
|
+
"is:unread",
|
|
179
|
+
"is:read",
|
|
180
|
+
"is:starred",
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("offers terms that parse back to the token they came from", () => {
|
|
185
|
+
const suggestions = buildSearchSuggestions("in:", 3, DATA);
|
|
186
|
+
for (const suggestion of suggestions) {
|
|
187
|
+
const { tokens } = parseSearchTokens(suggestion.value, {
|
|
188
|
+
mailboxesByName: new Map([
|
|
189
|
+
["archive", "mailbox-1"],
|
|
190
|
+
["sent items", "mailbox-2"],
|
|
191
|
+
]),
|
|
192
|
+
});
|
|
193
|
+
assert.equal(tokens.length, 1, `${suggestion.value} did not parse`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("applySearchSuggestion", () => {
|
|
199
|
+
it("replaces the term and leaves the caret past a completed token", () => {
|
|
200
|
+
const result = applySearchSuggestion("is:unr", 6, { value: "is:unread" });
|
|
201
|
+
assert.deepEqual(result, { query: "is:unread ", cursor: 10 });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps the caret on the value of a bare token name", () => {
|
|
205
|
+
const result = applySearchSuggestion("ca", 2, { value: "category:" });
|
|
206
|
+
assert.deepEqual(result, { query: "category:", cursor: 9 });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does not double the space before the next term", () => {
|
|
210
|
+
const result = applySearchSuggestion("is:unr tax", 6, {
|
|
211
|
+
value: "is:unread",
|
|
212
|
+
});
|
|
213
|
+
assert.deepEqual(result, { query: "is:unread tax", cursor: 10 });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("leaves the rest of the query alone", () => {
|
|
217
|
+
const result = applySearchSuggestion("invoice in:arch", 15, {
|
|
218
|
+
value: 'in:"Sent Items"',
|
|
219
|
+
});
|
|
220
|
+
assert.equal(result.query, 'invoice in:"Sent Items" ');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("inserts at the caret when there is no term there", () => {
|
|
224
|
+
const result = applySearchSuggestion("invoice ", 8, { value: "is:unread" });
|
|
225
|
+
assert.deepEqual(result, { query: "invoice is:unread ", cursor: 18 });
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("contactSuggestionValue", () => {
|
|
230
|
+
it("reads as the display name over the address", () => {
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
contactSuggestionValue({
|
|
233
|
+
normalizedEmail: "alice@example.com",
|
|
234
|
+
displayName: "Alice Adams",
|
|
235
|
+
}),
|
|
236
|
+
{
|
|
237
|
+
value: "alice@example.com",
|
|
238
|
+
label: "Alice Adams",
|
|
239
|
+
hint: "alice@example.com",
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("is just the address when there is no display name", () => {
|
|
245
|
+
assert.deepEqual(contactSuggestionValue({ normalizedEmail: "b@ex.com" }), {
|
|
246
|
+
value: "b@ex.com",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to offer while a search query is being typed (#428 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* The search box takes a vocabulary of `name:value` tokens (`lib/search-tokens.ts`)
|
|
5
|
+
* that nothing on screen announces, so the tokens are only useful to someone who
|
|
6
|
+
* already knows they exist. This turns the query and the caret into the list of
|
|
7
|
+
* completions for the term under the caret: token names while a bare word is
|
|
8
|
+
* being typed, that token's values once its name is committed.
|
|
9
|
+
*
|
|
10
|
+
* Pure functions over data passed in. Values that need looking up — folders,
|
|
11
|
+
* accounts, contacts — are supplied by the caller; `searchSuggestionRequest`
|
|
12
|
+
* says which lookup the current caret position needs, so a hook can fetch
|
|
13
|
+
* exactly that and nothing per keystroke. The address lookup for `from:` is the
|
|
14
|
+
* one the compose recipient field already uses
|
|
15
|
+
* (`addressOperationsSearchAddresses`); no new endpoint exists for this.
|
|
16
|
+
*
|
|
17
|
+
* The offer is a shortcut and never a constraint: nothing here rejects or
|
|
18
|
+
* rewrites what was typed, and a term with no matches leaves the plain text box
|
|
19
|
+
* it always was.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Suggestion } from "@remit/ui";
|
|
23
|
+
import {
|
|
24
|
+
SEARCH_TOKEN_SPECS,
|
|
25
|
+
type SearchTokenName,
|
|
26
|
+
type SearchTokenSpec,
|
|
27
|
+
type SearchTokenValueSource,
|
|
28
|
+
searchTokenSpec,
|
|
29
|
+
searchTokenTerm,
|
|
30
|
+
splitSearchTerm,
|
|
31
|
+
splitSearchWords,
|
|
32
|
+
} from "./search-tokens.js";
|
|
33
|
+
|
|
34
|
+
/** How many suggestions the list shows at most. */
|
|
35
|
+
export const SEARCH_SUGGESTION_LIMIT = 8;
|
|
36
|
+
|
|
37
|
+
/** The term the caret sits in, and where it sits in the query. */
|
|
38
|
+
export interface ActiveSearchTerm {
|
|
39
|
+
/** Index of the term's first character. */
|
|
40
|
+
start: number;
|
|
41
|
+
/** Index just past the term's last character. */
|
|
42
|
+
end: number;
|
|
43
|
+
/** The term exactly as typed. Empty when the caret is on whitespace. */
|
|
44
|
+
raw: string;
|
|
45
|
+
/** The token name typed before the colon, lower-cased. Absent for a bare word. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** What has been typed of the value, unquoted. Absent for a bare word. */
|
|
48
|
+
value?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The term the caret is in. A caret on whitespace (or in an empty box) is an
|
|
53
|
+
* empty term at that position, so the caller still gets the vocabulary offered
|
|
54
|
+
* rather than nothing — whether to show it there is the UI's call.
|
|
55
|
+
*/
|
|
56
|
+
export function activeSearchTerm(
|
|
57
|
+
query: string,
|
|
58
|
+
cursor: number,
|
|
59
|
+
): ActiveSearchTerm {
|
|
60
|
+
const caret = Math.max(0, Math.min(cursor, query.length));
|
|
61
|
+
const word = splitSearchWords(query).find(
|
|
62
|
+
(w) => caret >= w.start && caret <= w.end,
|
|
63
|
+
);
|
|
64
|
+
if (!word) return { start: caret, end: caret, raw: "" };
|
|
65
|
+
const parts = splitSearchTerm(word.raw);
|
|
66
|
+
if (!parts) return { start: word.start, end: word.end, raw: word.raw };
|
|
67
|
+
return {
|
|
68
|
+
start: word.start,
|
|
69
|
+
end: word.end,
|
|
70
|
+
raw: word.raw,
|
|
71
|
+
name: parts.name,
|
|
72
|
+
value: parts.value,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the caret position needs offered: a token name, or one token's values. */
|
|
77
|
+
export interface SearchSuggestionRequest {
|
|
78
|
+
kind: "token" | "value";
|
|
79
|
+
/** The token whose values are wanted, for a `value` request. */
|
|
80
|
+
token?: SearchTokenName;
|
|
81
|
+
/** Where those values come from, for a `value` request. */
|
|
82
|
+
source?: SearchTokenValueSource;
|
|
83
|
+
/** What has been typed of the name or the value. */
|
|
84
|
+
query: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The lookup the caret position calls for, so a hook fetches folders, accounts,
|
|
89
|
+
* or addresses only where they can be offered. `undefined` when the term is an
|
|
90
|
+
* unknown `foo:bar` — a token the vocabulary does not carry has no completions,
|
|
91
|
+
* and the term stays free text.
|
|
92
|
+
*/
|
|
93
|
+
export function searchSuggestionRequest(
|
|
94
|
+
query: string,
|
|
95
|
+
cursor: number,
|
|
96
|
+
): SearchSuggestionRequest | undefined {
|
|
97
|
+
const term = activeSearchTerm(query, cursor);
|
|
98
|
+
if (term.name === undefined) return { kind: "token", query: term.raw };
|
|
99
|
+
const spec = searchTokenSpec(term.name);
|
|
100
|
+
if (!spec) return undefined;
|
|
101
|
+
return {
|
|
102
|
+
kind: "value",
|
|
103
|
+
token: spec.name,
|
|
104
|
+
source: spec.values,
|
|
105
|
+
query: term.value ?? "",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A value the caller looked up, ready to be offered for one token. */
|
|
110
|
+
export interface SearchSuggestionValue {
|
|
111
|
+
value: string;
|
|
112
|
+
/** What the row reads. Defaults to the value. */
|
|
113
|
+
label?: string;
|
|
114
|
+
/** Secondary text — the address behind a display name, say. */
|
|
115
|
+
hint?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The looked-up values available for the tokens that take them. */
|
|
119
|
+
export interface SearchSuggestionData {
|
|
120
|
+
/** Folder names, for `in:`. */
|
|
121
|
+
mailboxes?: readonly SearchSuggestionValue[];
|
|
122
|
+
/** Account names, for `account:`. */
|
|
123
|
+
accounts?: readonly SearchSuggestionValue[];
|
|
124
|
+
/** Known addresses, for `from:` — the address search's results. */
|
|
125
|
+
contacts?: readonly SearchSuggestionValue[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const SOURCE_DATA: Partial<
|
|
129
|
+
Record<SearchTokenValueSource, keyof SearchSuggestionData>
|
|
130
|
+
> = {
|
|
131
|
+
mailbox: "mailboxes",
|
|
132
|
+
account: "accounts",
|
|
133
|
+
contact: "contacts",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const valuesFor = (
|
|
137
|
+
spec: SearchTokenSpec,
|
|
138
|
+
data: SearchSuggestionData,
|
|
139
|
+
): readonly SearchSuggestionValue[] => {
|
|
140
|
+
if (spec.values === "fixed") return spec.options ?? [];
|
|
141
|
+
const key = SOURCE_DATA[spec.values];
|
|
142
|
+
// `date` and `text` values are typed, not chosen — a date has no list worth
|
|
143
|
+
// offering and free text has nothing to draw on.
|
|
144
|
+
return key ? (data[key] ?? []) : [];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const matchRank = (haystack: string, needle: string): number => {
|
|
148
|
+
if (needle === "") return 1;
|
|
149
|
+
const lower = haystack.toLowerCase();
|
|
150
|
+
if (lower.startsWith(needle)) return 0;
|
|
151
|
+
return lower.includes(needle) ? 1 : -1;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const rankOf = (needle: string, ...fields: (string | undefined)[]): number => {
|
|
155
|
+
let best = -1;
|
|
156
|
+
for (const field of fields) {
|
|
157
|
+
if (field === undefined) continue;
|
|
158
|
+
const rank = matchRank(field, needle);
|
|
159
|
+
if (rank < 0) continue;
|
|
160
|
+
if (best < 0 || rank < best) best = rank;
|
|
161
|
+
}
|
|
162
|
+
return best;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
interface Ranked {
|
|
166
|
+
rank: number;
|
|
167
|
+
suggestion: Suggestion;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const take = (ranked: Ranked[]): Suggestion[] => {
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
return ranked
|
|
173
|
+
.filter((r) => r.rank >= 0)
|
|
174
|
+
.sort((a, b) => a.rank - b.rank)
|
|
175
|
+
.filter((r) => {
|
|
176
|
+
const key = r.suggestion.value.toLowerCase();
|
|
177
|
+
if (seen.has(key)) return false;
|
|
178
|
+
seen.add(key);
|
|
179
|
+
return true;
|
|
180
|
+
})
|
|
181
|
+
.slice(0, SEARCH_SUGGESTION_LIMIT)
|
|
182
|
+
.map((r) => r.suggestion);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const nameSuggestion = (spec: SearchTokenSpec): Suggestion => ({
|
|
186
|
+
value: `${spec.name}:`,
|
|
187
|
+
label: `${spec.name}:`,
|
|
188
|
+
hint: spec.label,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Token-name completions for a bare word: one row per token, `name:`, leaving
|
|
193
|
+
* the caret ready for the value.
|
|
194
|
+
*
|
|
195
|
+
* Once something is typed, a token whose values are a fixed vocabulary also
|
|
196
|
+
* offers each value whole — `is:unread` — so the words the user knows reach the
|
|
197
|
+
* token they belong to without knowing its name. On an empty box that expansion
|
|
198
|
+
* would bury the vocabulary under its own values, so the bare names stand alone.
|
|
199
|
+
*/
|
|
200
|
+
function tokenSuggestions(typed: string): Suggestion[] {
|
|
201
|
+
const needle = typed.trim().toLowerCase();
|
|
202
|
+
if (needle === "") return SEARCH_TOKEN_SPECS.map(nameSuggestion);
|
|
203
|
+
const ranked: Ranked[] = [];
|
|
204
|
+
for (const spec of SEARCH_TOKEN_SPECS) {
|
|
205
|
+
ranked.push({
|
|
206
|
+
rank: rankOf(needle, `${spec.name}:`, spec.label),
|
|
207
|
+
suggestion: nameSuggestion(spec),
|
|
208
|
+
});
|
|
209
|
+
if (spec.values !== "fixed") continue;
|
|
210
|
+
for (const option of spec.options ?? []) {
|
|
211
|
+
const term = searchTokenTerm(spec.name, option.value);
|
|
212
|
+
ranked.push({
|
|
213
|
+
rank: rankOf(needle, term, option.label),
|
|
214
|
+
suggestion: { value: term, label: option.label ?? term, hint: term },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return take(ranked);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Value completions for a committed token name. Each offer is the whole term —
|
|
223
|
+
* `in:"Sent Items"`, quoted where the value needs it — so applying one is a
|
|
224
|
+
* replacement of the term under the caret and never a splice the caller has to
|
|
225
|
+
* quote itself.
|
|
226
|
+
*/
|
|
227
|
+
function valueSuggestions(
|
|
228
|
+
spec: SearchTokenSpec,
|
|
229
|
+
typed: string,
|
|
230
|
+
data: SearchSuggestionData,
|
|
231
|
+
): Suggestion[] {
|
|
232
|
+
const needle = typed.trim().toLowerCase();
|
|
233
|
+
const ranked = valuesFor(spec, data).map<Ranked>((option) => ({
|
|
234
|
+
rank: rankOf(needle, option.value, option.label),
|
|
235
|
+
suggestion: {
|
|
236
|
+
value: searchTokenTerm(spec.name, option.value),
|
|
237
|
+
label: option.label ?? option.value,
|
|
238
|
+
...(option.hint ? { hint: option.hint } : {}),
|
|
239
|
+
},
|
|
240
|
+
}));
|
|
241
|
+
return take(ranked);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The completions for the term under the caret. An unknown token name offers
|
|
246
|
+
* nothing: `foo:bar` is free text, and a list of guesses would suggest otherwise.
|
|
247
|
+
*/
|
|
248
|
+
export function buildSearchSuggestions(
|
|
249
|
+
query: string,
|
|
250
|
+
cursor: number,
|
|
251
|
+
data: SearchSuggestionData = {},
|
|
252
|
+
): Suggestion[] {
|
|
253
|
+
const term = activeSearchTerm(query, cursor);
|
|
254
|
+
if (term.name === undefined) return tokenSuggestions(term.raw);
|
|
255
|
+
const spec = searchTokenSpec(term.name);
|
|
256
|
+
if (!spec) return [];
|
|
257
|
+
return valueSuggestions(spec, term.value ?? "", data);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A query and where the caret lands in it, after a suggestion is applied. */
|
|
261
|
+
export interface AppliedSearchSuggestion {
|
|
262
|
+
query: string;
|
|
263
|
+
cursor: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Replace the term under the caret with the suggestion. A completed token gets a
|
|
268
|
+
* trailing space — the term is finished, the next one starts — while a bare
|
|
269
|
+
* `name:` does not, because the value still has to be typed.
|
|
270
|
+
*/
|
|
271
|
+
export function applySearchSuggestion(
|
|
272
|
+
query: string,
|
|
273
|
+
cursor: number,
|
|
274
|
+
suggestion: Suggestion,
|
|
275
|
+
): AppliedSearchSuggestion {
|
|
276
|
+
const term = activeSearchTerm(query, cursor);
|
|
277
|
+
const complete = !suggestion.value.endsWith(":");
|
|
278
|
+
const followed = /\s/.test(query.slice(term.end, term.end + 1));
|
|
279
|
+
const insert =
|
|
280
|
+
complete && !followed ? `${suggestion.value} ` : suggestion.value;
|
|
281
|
+
return {
|
|
282
|
+
query: query.slice(0, term.start) + insert + query.slice(term.end),
|
|
283
|
+
cursor: term.start + insert.length + (complete && followed ? 1 : 0),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A contact as the address search returns it, as a suggestion value. */
|
|
288
|
+
export const contactSuggestionValue = (address: {
|
|
289
|
+
normalizedEmail: string;
|
|
290
|
+
displayName?: string | null;
|
|
291
|
+
}): SearchSuggestionValue => ({
|
|
292
|
+
value: address.normalizedEmail,
|
|
293
|
+
...(address.displayName
|
|
294
|
+
? { label: address.displayName, hint: address.normalizedEmail }
|
|
295
|
+
: {}),
|
|
296
|
+
});
|
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
} from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import {
|
|
8
8
|
buildAccountNameIndex,
|
|
9
|
+
buildAccountSuggestionValues,
|
|
9
10
|
buildMailboxNameIndex,
|
|
11
|
+
buildMailboxSuggestionValues,
|
|
10
12
|
} from "./search-token-index.js";
|
|
11
13
|
|
|
12
14
|
const account = (
|
|
@@ -110,3 +112,100 @@ describe("buildMailboxNameIndex", () => {
|
|
|
110
112
|
assert.deepEqual(buildMailboxNameIndex([]), new Map());
|
|
111
113
|
});
|
|
112
114
|
});
|
|
115
|
+
|
|
116
|
+
describe("buildMailboxSuggestionValues", () => {
|
|
117
|
+
it("offers the leaf when no other folder shares it", () => {
|
|
118
|
+
const [offer] = buildMailboxSuggestionValues([
|
|
119
|
+
[mailbox({ mailboxId: "mb-1", fullPath: "INBOX/Archive" })],
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(offer?.value, "Archive");
|
|
122
|
+
assert.equal(offer?.label, "Archive");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("offers the full path where the leaf would be ambiguous", () => {
|
|
126
|
+
const offers = buildMailboxSuggestionValues([
|
|
127
|
+
[mailbox({ mailboxId: "mb-1", fullPath: "INBOX/Archive" })],
|
|
128
|
+
[mailbox({ mailboxId: "mb-2", fullPath: "Work/archive" })],
|
|
129
|
+
]);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
offers.map((offer) => offer.value),
|
|
132
|
+
["INBOX/Archive", "Work/archive"],
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("every offer resolves back through the index it was built beside", () => {
|
|
137
|
+
const lists = [
|
|
138
|
+
[
|
|
139
|
+
mailbox({ mailboxId: "mb-1", fullPath: "INBOX" }),
|
|
140
|
+
mailbox({ mailboxId: "mb-2", fullPath: "INBOX/Archive" }),
|
|
141
|
+
],
|
|
142
|
+
[mailbox({ mailboxId: "mb-3", fullPath: "Team/Archive" })],
|
|
143
|
+
];
|
|
144
|
+
const index = buildMailboxNameIndex(lists);
|
|
145
|
+
for (const offer of buildMailboxSuggestionValues(lists)) {
|
|
146
|
+
assert.ok(index.has(offer.value.toLowerCase()), offer.value);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("reads the folder's own name, a rename winning over the path", () => {
|
|
151
|
+
const [offer] = buildMailboxSuggestionValues([
|
|
152
|
+
[
|
|
153
|
+
mailbox({
|
|
154
|
+
mailboxId: "mb-1",
|
|
155
|
+
fullPath: "INBOX/Archief",
|
|
156
|
+
displayNameOverride: " Archive ",
|
|
157
|
+
}),
|
|
158
|
+
],
|
|
159
|
+
]);
|
|
160
|
+
assert.equal(offer?.value, "Archief");
|
|
161
|
+
assert.equal(offer?.label, "Archive");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("names the account only where more than one is loaded", () => {
|
|
165
|
+
const lists = [[mailbox({ mailboxId: "mb-1", fullPath: "Archive" })]];
|
|
166
|
+
const accounts = [
|
|
167
|
+
account({ accountId: "account-1", email: "me@example.com" }),
|
|
168
|
+
];
|
|
169
|
+
assert.equal(
|
|
170
|
+
buildMailboxSuggestionValues(lists, accounts)[0]?.hint,
|
|
171
|
+
undefined,
|
|
172
|
+
);
|
|
173
|
+
assert.equal(
|
|
174
|
+
buildMailboxSuggestionValues(lists, [
|
|
175
|
+
...accounts,
|
|
176
|
+
account({ accountId: "account-2", email: "work@example.com" }),
|
|
177
|
+
])[0]?.hint,
|
|
178
|
+
"me@example.com",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("buildAccountSuggestionValues", () => {
|
|
184
|
+
it("offers the local part, read as the account's own name", () => {
|
|
185
|
+
const [offer] = buildAccountSuggestionValues([
|
|
186
|
+
account({
|
|
187
|
+
accountId: "acct-1",
|
|
188
|
+
email: "work@company.com",
|
|
189
|
+
displayName: "Work",
|
|
190
|
+
}),
|
|
191
|
+
]);
|
|
192
|
+
assert.equal(offer?.value, "work");
|
|
193
|
+
assert.equal(offer?.label, "Work");
|
|
194
|
+
assert.equal(offer?.hint, "work@company.com");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("offers the whole address where the local part is ambiguous", () => {
|
|
198
|
+
const offers = buildAccountSuggestionValues([
|
|
199
|
+
account({ accountId: "acct-1", email: "work@company.com" }),
|
|
200
|
+
account({ accountId: "acct-2", email: "work@other.com" }),
|
|
201
|
+
]);
|
|
202
|
+
assert.deepEqual(
|
|
203
|
+
offers.map((offer) => offer.value),
|
|
204
|
+
["work@company.com", "work@other.com"],
|
|
205
|
+
);
|
|
206
|
+
assert.deepEqual(
|
|
207
|
+
offers.map((offer) => offer.label),
|
|
208
|
+
["work@company.com", "work@other.com"],
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|