@remit/web-client 0.0.85 → 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/components/settings/TlsRootCaDownload.render.test.ts +37 -0
- package/src/components/settings/TlsRootCaDownload.tsx +33 -0
- 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
- package/src/routes/settings/advanced.tsx +2 -0
- package/src/runtime-config.test.ts +26 -0
- package/src/runtime-config.ts +15 -0
package/src/lib/search-tokens.ts
CHANGED
|
@@ -4,11 +4,34 @@
|
|
|
4
4
|
* into removable chips. Only tokens the search APIs can actually honor are
|
|
5
5
|
* recognized — everything else is left untouched as ordinary free-text words.
|
|
6
6
|
*
|
|
7
|
+
* The vocabulary is the search parameters `threadOperationsSearchThreads` and
|
|
8
|
+
* `semanticSearchOperationsSemanticSearch` take, said in words:
|
|
9
|
+
*
|
|
10
|
+
* | token | thread search | semantic search |
|
|
11
|
+
* | ---------------------- | ------------------ | ---------------------- |
|
|
12
|
+
* | `from:` | `from` | — |
|
|
13
|
+
* | `subject:` | `subject` | — |
|
|
14
|
+
* | `category:` | `category[]` | `category` |
|
|
15
|
+
* | `is:unread` / `is:read`| `unread` | `isRead` |
|
|
16
|
+
* | `is:starred` | `starred` | `hasStars` |
|
|
17
|
+
* | `has:attachment` | `attachments` | `hasAttachment` |
|
|
18
|
+
* | `before:` / `after:` | — | `sentDateTo` / `From` |
|
|
19
|
+
* | `in:` | route scope | `mailboxId` |
|
|
20
|
+
* | `account:` | — | — |
|
|
21
|
+
*
|
|
22
|
+
* A token an engine has no parameter for is applied client-side over the rows
|
|
23
|
+
* that engine returned (`matchesSearchTokens` in `lib/brief.ts`), never dropped.
|
|
24
|
+
*
|
|
7
25
|
* `in:<mailbox>` and `account:<name>` resolve against a name index the caller
|
|
8
26
|
* supplies via `SearchTokenContext` (built from the loaded mailboxes/accounts —
|
|
9
27
|
* see `lib/search-token-index.ts`). An unresolvable name (no index, or no
|
|
10
28
|
* match in it) is left as free text rather than becoming a chip for a filter
|
|
11
|
-
* that can't actually apply.
|
|
29
|
+
* that can't actually apply. `category:` resolves the same way against a fixed
|
|
30
|
+
* vocabulary, so `category:nonsense` stays free text too.
|
|
31
|
+
*
|
|
32
|
+
* Token names are case-insensitive. A value containing whitespace is written in
|
|
33
|
+
* double quotes — `in:"Sent Items"` — and an unterminated quote runs to the end
|
|
34
|
+
* of the input, so a value stays readable while it is still being typed.
|
|
12
35
|
*
|
|
13
36
|
* Pure functions only: no React, no fetch — the name index is injected, not
|
|
14
37
|
* fetched here. `MailListHeader` renders the chips and the callers that issue
|
|
@@ -17,10 +40,22 @@
|
|
|
17
40
|
* actually supports.
|
|
18
41
|
*/
|
|
19
42
|
|
|
43
|
+
import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
|
|
44
|
+
import { MessageCategory } from "@remit/domain-enums";
|
|
45
|
+
|
|
20
46
|
export type SearchToken =
|
|
21
47
|
| { type: "from"; raw: string; value: string }
|
|
48
|
+
| { type: "subject"; raw: string; value: string }
|
|
49
|
+
| {
|
|
50
|
+
type: "category";
|
|
51
|
+
raw: string;
|
|
52
|
+
value: string;
|
|
53
|
+
category: RemitImapMessageCategory;
|
|
54
|
+
}
|
|
22
55
|
| { type: "hasAttachment"; raw: string }
|
|
23
56
|
| { type: "isUnread"; raw: string }
|
|
57
|
+
| { type: "isRead"; raw: string }
|
|
58
|
+
| { type: "isStarred"; raw: string }
|
|
24
59
|
| { type: "before"; raw: string; value: string; epochSeconds: number }
|
|
25
60
|
| { type: "after"; raw: string; value: string; epochSeconds: number }
|
|
26
61
|
| { type: "in"; raw: string; value: string; mailboxId: string }
|
|
@@ -40,8 +75,200 @@ export interface SearchTokenContext {
|
|
|
40
75
|
accountsByName?: ReadonlyMap<string, string>;
|
|
41
76
|
}
|
|
42
77
|
|
|
78
|
+
/** The word before the colon. `is`/`has` carry their meaning in the value. */
|
|
79
|
+
export type SearchTokenName =
|
|
80
|
+
| "from"
|
|
81
|
+
| "subject"
|
|
82
|
+
| "category"
|
|
83
|
+
| "is"
|
|
84
|
+
| "has"
|
|
85
|
+
| "before"
|
|
86
|
+
| "after"
|
|
87
|
+
| "in"
|
|
88
|
+
| "account";
|
|
89
|
+
|
|
90
|
+
/** One option of a token's fixed value vocabulary. */
|
|
91
|
+
export interface SearchTokenValueOption {
|
|
92
|
+
value: string;
|
|
93
|
+
/** What the value reads as in prose. Defaults to the value. */
|
|
94
|
+
label?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where a token's values come from. `fixed` is the vocabulary carried here;
|
|
99
|
+
* the rest are looked up by the caller (mailboxes, accounts, the address
|
|
100
|
+
* search) or typed freely.
|
|
101
|
+
*/
|
|
102
|
+
export type SearchTokenValueSource =
|
|
103
|
+
| "fixed"
|
|
104
|
+
| "contact"
|
|
105
|
+
| "mailbox"
|
|
106
|
+
| "account"
|
|
107
|
+
| "date"
|
|
108
|
+
| "text";
|
|
109
|
+
|
|
110
|
+
/** A token name, what it narrows by, and where its values come from. */
|
|
111
|
+
export interface SearchTokenSpec {
|
|
112
|
+
name: SearchTokenName;
|
|
113
|
+
/** What the token narrows by, in the user's words. */
|
|
114
|
+
label: string;
|
|
115
|
+
values: SearchTokenValueSource;
|
|
116
|
+
/** The vocabulary, when `values` is `fixed`. */
|
|
117
|
+
options?: readonly SearchTokenValueOption[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Category labels, keyed by the API enum. `uncategorized` reads "Unclassified"
|
|
122
|
+
* — the same word the filter chips use (issue #45): mail the classifier has not
|
|
123
|
+
* reached is a pending state with a name, never the absence of one.
|
|
124
|
+
*/
|
|
125
|
+
const CATEGORY_LABELS: Record<RemitImapMessageCategory, string> = {
|
|
126
|
+
personal: "Personal",
|
|
127
|
+
uncategorized: "Unclassified",
|
|
128
|
+
transactional: "Transactional",
|
|
129
|
+
newsletter: "Newsletter",
|
|
130
|
+
marketing: "Marketing",
|
|
131
|
+
social: "Social",
|
|
132
|
+
automated: "Automated",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const CATEGORY_OPTIONS: readonly SearchTokenValueOption[] = Object.values(
|
|
136
|
+
MessageCategory,
|
|
137
|
+
).map((value) => ({ value, label: CATEGORY_LABELS[value] }));
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Spellings that resolve to a category beyond the enum values themselves — the
|
|
141
|
+
* label the chips show, so what the user reads is also what the user can type.
|
|
142
|
+
*/
|
|
143
|
+
const CATEGORY_ALIASES: Record<string, RemitImapMessageCategory> = {
|
|
144
|
+
unclassified: "uncategorized",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The whole vocabulary, in the order a suggestion list offers it. */
|
|
148
|
+
export const SEARCH_TOKEN_SPECS: readonly SearchTokenSpec[] = [
|
|
149
|
+
{ name: "from", label: "Sender", values: "contact" },
|
|
150
|
+
{ name: "subject", label: "Subject", values: "text" },
|
|
151
|
+
{
|
|
152
|
+
name: "category",
|
|
153
|
+
label: "Category",
|
|
154
|
+
values: "fixed",
|
|
155
|
+
options: CATEGORY_OPTIONS,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "is",
|
|
159
|
+
label: "State",
|
|
160
|
+
values: "fixed",
|
|
161
|
+
options: [
|
|
162
|
+
{ value: "unread", label: "Unread" },
|
|
163
|
+
{ value: "read", label: "Read" },
|
|
164
|
+
{ value: "starred", label: "Starred" },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "has",
|
|
169
|
+
label: "Contents",
|
|
170
|
+
values: "fixed",
|
|
171
|
+
options: [{ value: "attachment", label: "Has attachment" }],
|
|
172
|
+
},
|
|
173
|
+
{ name: "in", label: "Folder", values: "mailbox" },
|
|
174
|
+
{ name: "account", label: "Account", values: "account" },
|
|
175
|
+
{ name: "after", label: "Sent on or after", values: "date" },
|
|
176
|
+
{ name: "before", label: "Sent before", values: "date" },
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const SPECS_BY_NAME = new Map<string, SearchTokenSpec>(
|
|
180
|
+
SEARCH_TOKEN_SPECS.map((spec) => [spec.name, spec]),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/** The spec for a token name, or `undefined` when the name is not one of ours. */
|
|
184
|
+
export const searchTokenSpec = (name: string): SearchTokenSpec | undefined =>
|
|
185
|
+
SPECS_BY_NAME.get(name.toLowerCase());
|
|
186
|
+
|
|
43
187
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
44
188
|
|
|
189
|
+
const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A value as it is written in a query: quoted when it carries whitespace, bare
|
|
193
|
+
* otherwise. The inverse of the unquoting the parser does, so a suggestion the
|
|
194
|
+
* user picks parses back to the value it was built from.
|
|
195
|
+
*/
|
|
196
|
+
export const quoteSearchTokenValue = (value: string): string =>
|
|
197
|
+
needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
|
|
198
|
+
|
|
199
|
+
/** `name:value`, quoted as needed — the text a query carries for one token. */
|
|
200
|
+
export const searchTokenTerm = (name: string, value: string): string =>
|
|
201
|
+
`${name}:${quoteSearchTokenValue(value)}`;
|
|
202
|
+
|
|
203
|
+
const unquote = (value: string): string => {
|
|
204
|
+
if (!value.startsWith('"')) return value;
|
|
205
|
+
const inner = value.slice(1);
|
|
206
|
+
return inner.endsWith('"') ? inner.slice(0, -1) : inner;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** One whitespace-separated term of a query, with where it sits in the input. */
|
|
210
|
+
export interface SearchQueryWord {
|
|
211
|
+
/** The term exactly as typed, quotes included. */
|
|
212
|
+
raw: string;
|
|
213
|
+
/** Index of the term's first character in the query. */
|
|
214
|
+
start: number;
|
|
215
|
+
/** Index just past the term's last character. */
|
|
216
|
+
end: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Split a query into terms on whitespace, except inside double quotes, so
|
|
221
|
+
* `in:"Sent Items"` is one term. An unterminated quote runs to the end of the
|
|
222
|
+
* input — a half-typed value is still one term, not a broken one.
|
|
223
|
+
*/
|
|
224
|
+
export function splitSearchWords(query: string): SearchQueryWord[] {
|
|
225
|
+
const words: SearchQueryWord[] = [];
|
|
226
|
+
let start = -1;
|
|
227
|
+
let quoted = false;
|
|
228
|
+
for (let i = 0; i < query.length; i++) {
|
|
229
|
+
const char = query[i] as string;
|
|
230
|
+
if (char === '"') {
|
|
231
|
+
quoted = !quoted;
|
|
232
|
+
if (start < 0) start = i;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!quoted && /\s/.test(char)) {
|
|
236
|
+
if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
|
|
237
|
+
start = -1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (start < 0) start = i;
|
|
241
|
+
}
|
|
242
|
+
if (start >= 0) {
|
|
243
|
+
words.push({ raw: query.slice(start), start, end: query.length });
|
|
244
|
+
}
|
|
245
|
+
return words;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A term split at its first colon: the token name and the value as typed. */
|
|
249
|
+
export interface SearchTermParts {
|
|
250
|
+
name: string;
|
|
251
|
+
/** The value with its quotes removed. */
|
|
252
|
+
value: string;
|
|
253
|
+
/** The value exactly as typed, quotes included. */
|
|
254
|
+
rawValue: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Split `name:value` at the first colon. A term with no colon, or one starting
|
|
259
|
+
* with a colon, is not a token attempt and returns `undefined`.
|
|
260
|
+
*/
|
|
261
|
+
export function splitSearchTerm(word: string): SearchTermParts | undefined {
|
|
262
|
+
const colon = word.indexOf(":");
|
|
263
|
+
if (colon <= 0) return undefined;
|
|
264
|
+
const rawValue = word.slice(colon + 1);
|
|
265
|
+
return {
|
|
266
|
+
name: word.slice(0, colon).toLowerCase(),
|
|
267
|
+
value: unquote(rawValue),
|
|
268
|
+
rawValue,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
45
272
|
function parseDateToken(
|
|
46
273
|
type: "before" | "after",
|
|
47
274
|
raw: string,
|
|
@@ -53,39 +280,72 @@ function parseDateToken(
|
|
|
53
280
|
return { type, raw, value, epochSeconds: Math.floor(epochMs / 1000) };
|
|
54
281
|
}
|
|
55
282
|
|
|
283
|
+
/** The category a `category:` value names, by enum value or by chip label. */
|
|
284
|
+
export function resolveCategory(
|
|
285
|
+
value: string,
|
|
286
|
+
): RemitImapMessageCategory | undefined {
|
|
287
|
+
const lower = value.toLowerCase();
|
|
288
|
+
const alias = CATEGORY_ALIASES[lower];
|
|
289
|
+
if (alias) return alias;
|
|
290
|
+
const known = Object.values(MessageCategory).find((c) => c === lower);
|
|
291
|
+
return known;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseStateToken(raw: string, value: string): SearchToken | undefined {
|
|
295
|
+
switch (value.toLowerCase()) {
|
|
296
|
+
case "unread":
|
|
297
|
+
return { type: "isUnread", raw };
|
|
298
|
+
case "read":
|
|
299
|
+
return { type: "isRead", raw };
|
|
300
|
+
// `flagged` is the wire name for IMAP \Flagged; "starred" is the word the
|
|
301
|
+
// chips use. Both are typed, so both parse.
|
|
302
|
+
case "starred":
|
|
303
|
+
case "flagged":
|
|
304
|
+
return { type: "isStarred", raw };
|
|
305
|
+
default:
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
56
310
|
function parseWord(
|
|
57
311
|
word: string,
|
|
58
312
|
context: SearchTokenContext,
|
|
59
313
|
): SearchToken | undefined {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
314
|
+
const parts = splitSearchTerm(word);
|
|
315
|
+
if (!parts || parts.value.length === 0) return undefined;
|
|
316
|
+
const { name, value } = parts;
|
|
317
|
+
switch (name) {
|
|
318
|
+
case "from":
|
|
319
|
+
return { type: "from", raw: word, value };
|
|
320
|
+
case "subject":
|
|
321
|
+
return { type: "subject", raw: word, value };
|
|
322
|
+
case "category": {
|
|
323
|
+
const category = resolveCategory(value);
|
|
324
|
+
if (!category) return undefined;
|
|
325
|
+
return { type: "category", raw: word, value, category };
|
|
326
|
+
}
|
|
327
|
+
case "is":
|
|
328
|
+
return parseStateToken(word, value);
|
|
329
|
+
case "has":
|
|
330
|
+
return value.toLowerCase() === "attachment"
|
|
331
|
+
? { type: "hasAttachment", raw: word }
|
|
332
|
+
: undefined;
|
|
333
|
+
case "before":
|
|
334
|
+
case "after":
|
|
335
|
+
return parseDateToken(name, word, value);
|
|
336
|
+
case "in": {
|
|
337
|
+
const mailboxId = context.mailboxesByName?.get(value.toLowerCase());
|
|
338
|
+
if (!mailboxId) return undefined;
|
|
339
|
+
return { type: "in", raw: word, value, mailboxId };
|
|
340
|
+
}
|
|
341
|
+
case "account": {
|
|
342
|
+
const accountId = context.accountsByName?.get(value.toLowerCase());
|
|
343
|
+
if (!accountId) return undefined;
|
|
344
|
+
return { type: "account", raw: word, value, accountId };
|
|
345
|
+
}
|
|
346
|
+
default:
|
|
347
|
+
return undefined;
|
|
87
348
|
}
|
|
88
|
-
return undefined;
|
|
89
349
|
}
|
|
90
350
|
|
|
91
351
|
/** Split a query into recognized filter tokens and the remaining free text. */
|
|
@@ -93,22 +353,21 @@ export function parseSearchTokens(
|
|
|
93
353
|
query: string,
|
|
94
354
|
context: SearchTokenContext = {},
|
|
95
355
|
): ParsedSearchQuery {
|
|
96
|
-
const words = query.split(/\s+/).filter((w) => w.length > 0);
|
|
97
356
|
const tokens: SearchToken[] = [];
|
|
98
357
|
const freeWords: string[] = [];
|
|
99
|
-
for (const word of
|
|
100
|
-
const token = parseWord(word, context);
|
|
358
|
+
for (const word of splitSearchWords(query)) {
|
|
359
|
+
const token = parseWord(word.raw, context);
|
|
101
360
|
if (token) tokens.push(token);
|
|
102
|
-
else freeWords.push(word);
|
|
361
|
+
else freeWords.push(word.raw);
|
|
103
362
|
}
|
|
104
363
|
return { freeText: freeWords.join(" "), tokens };
|
|
105
364
|
}
|
|
106
365
|
|
|
107
366
|
/** Remove one parsed token's raw text from the original query string. */
|
|
108
367
|
export function removeSearchToken(query: string, token: SearchToken): string {
|
|
109
|
-
return query
|
|
110
|
-
.
|
|
111
|
-
.
|
|
368
|
+
return splitSearchWords(query)
|
|
369
|
+
.filter((word) => word.raw !== token.raw)
|
|
370
|
+
.map((word) => word.raw)
|
|
112
371
|
.join(" ");
|
|
113
372
|
}
|
|
114
373
|
|
|
@@ -117,10 +376,18 @@ export function searchTokenLabel(token: SearchToken): string {
|
|
|
117
376
|
switch (token.type) {
|
|
118
377
|
case "from":
|
|
119
378
|
return `From: ${token.value}`;
|
|
379
|
+
case "subject":
|
|
380
|
+
return `Subject: ${token.value}`;
|
|
381
|
+
case "category":
|
|
382
|
+
return `Category: ${CATEGORY_LABELS[token.category]}`;
|
|
120
383
|
case "hasAttachment":
|
|
121
384
|
return "Has attachment";
|
|
122
385
|
case "isUnread":
|
|
123
386
|
return "Unread";
|
|
387
|
+
case "isRead":
|
|
388
|
+
return "Read";
|
|
389
|
+
case "isStarred":
|
|
390
|
+
return "Starred";
|
|
124
391
|
case "before":
|
|
125
392
|
return `Before ${token.value}`;
|
|
126
393
|
case "after":
|
package/src/lib/thread-cache.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const isInfiniteThreadData = (
|
|
|
37
37
|
data !== null &&
|
|
38
38
|
Array.isArray((data as { pages?: unknown }).pages);
|
|
39
39
|
|
|
40
|
-
const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
|
|
40
|
+
export const isThreadItemsPage = (data: unknown): data is ThreadItemsPage =>
|
|
41
41
|
typeof data === "object" &&
|
|
42
42
|
data !== null &&
|
|
43
43
|
Array.isArray((data as { items?: unknown }).items);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { InboxFilterParams } from "./inbox-filters.js";
|
|
5
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
6
|
+
import {
|
|
7
|
+
applyResidualTokens,
|
|
8
|
+
threadSearchTokens,
|
|
9
|
+
} from "./thread-search-tokens.js";
|
|
10
|
+
|
|
11
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
12
|
+
parseSearchTokens(query, {
|
|
13
|
+
accountsByName: new Map([["alice", "acc_1"]]),
|
|
14
|
+
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
15
|
+
}).tokens;
|
|
16
|
+
|
|
17
|
+
const split = (query: string, chips: InboxFilterParams = {}) =>
|
|
18
|
+
threadSearchTokens(tokensOf(query), chips);
|
|
19
|
+
|
|
20
|
+
const residualTypes = (query: string, chips: InboxFilterParams = {}) =>
|
|
21
|
+
split(query, chips).residual.map((token) => token.type);
|
|
22
|
+
|
|
23
|
+
const thread = (
|
|
24
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
25
|
+
): RemitImapThreadMessageResponse => ({
|
|
26
|
+
threadId: "t1",
|
|
27
|
+
threadMessageId: "tm1",
|
|
28
|
+
messageId: "m1",
|
|
29
|
+
accountConfigId: "cfg_1",
|
|
30
|
+
accountId: "acc_1",
|
|
31
|
+
mailboxId: "mb1",
|
|
32
|
+
fromName: "Sender",
|
|
33
|
+
fromEmail: "sender@example.com",
|
|
34
|
+
subject: "Subject",
|
|
35
|
+
snippet: "Snippet",
|
|
36
|
+
category: "uncategorized",
|
|
37
|
+
sentDate: Date.parse("2026-03-01T00:00:00Z"),
|
|
38
|
+
isRead: false,
|
|
39
|
+
isDeleted: false,
|
|
40
|
+
hasAttachment: false,
|
|
41
|
+
hasStars: false,
|
|
42
|
+
star: "none",
|
|
43
|
+
senderTrust: "unknown",
|
|
44
|
+
muted: false,
|
|
45
|
+
createdAt: 0,
|
|
46
|
+
updatedAt: 0,
|
|
47
|
+
...overrides,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("threadSearchTokens", () => {
|
|
51
|
+
test("every token the endpoint has a parameter for reaches the request", () => {
|
|
52
|
+
const { params, residual } = split(
|
|
53
|
+
'from:dhl subject:"invoice due" category:newsletter is:starred has:attachment',
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(params, {
|
|
56
|
+
from: "dhl",
|
|
57
|
+
subject: "invoice due",
|
|
58
|
+
category: ["newsletter"],
|
|
59
|
+
starred: true,
|
|
60
|
+
attachments: true,
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(residual, []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("is:unread and is:read are the two sides of `unread`", () => {
|
|
66
|
+
assert.equal(split("is:unread").params.unread, true);
|
|
67
|
+
assert.equal(split("is:read").params.unread, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("is:flagged is the same filter as is:starred", () => {
|
|
71
|
+
assert.equal(split("is:flagged").params.starred, true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a category label resolves to the enum the request carries", () => {
|
|
75
|
+
assert.deepEqual(split("category:unclassified").params.category, [
|
|
76
|
+
"uncategorized",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("no tokens set no parameters", () => {
|
|
81
|
+
assert.deepEqual(split("just words").params, {});
|
|
82
|
+
assert.deepEqual(split("just words").residual, []);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("threadSearchTokens residue", () => {
|
|
87
|
+
test("dates and the account have no parameter on this endpoint", () => {
|
|
88
|
+
assert.deepEqual(residualTypes("after:2026-01-01 before:2026-02-01"), [
|
|
89
|
+
"after",
|
|
90
|
+
"before",
|
|
91
|
+
]);
|
|
92
|
+
assert.deepEqual(residualTypes("account:alice"), ["account"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a mailbox term is residue where it resolves at all", () => {
|
|
96
|
+
assert.deepEqual(residualTypes("in:sent"), ["in"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a second value for a single-valued parameter is residue", () => {
|
|
100
|
+
const { params, residual } = split("from:dhl from:ups");
|
|
101
|
+
assert.equal(params.from, "dhl");
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
residual.map((token) => token.raw),
|
|
104
|
+
["from:ups"],
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(split("subject:one subject:two").params.subject, "one");
|
|
107
|
+
assert.deepEqual(residualTypes("subject:one subject:two"), ["subject"]);
|
|
108
|
+
assert.deepEqual(residualTypes("category:social category:personal"), [
|
|
109
|
+
"category",
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("repeating a token asks nothing new, so nothing is left over", () => {
|
|
114
|
+
assert.deepEqual(residualTypes("from:dhl from:DHL"), []);
|
|
115
|
+
assert.deepEqual(residualTypes("is:starred is:starred"), []);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("contradicting state tokens cannot both be sent", () => {
|
|
119
|
+
const { params, residual } = split("is:unread is:read");
|
|
120
|
+
assert.equal(params.unread, true);
|
|
121
|
+
assert.deepEqual(
|
|
122
|
+
residual.map((token) => token.type),
|
|
123
|
+
["isRead"],
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("threadSearchTokens against the filter chips", () => {
|
|
129
|
+
test("a chip that already asks for it leaves nothing over", () => {
|
|
130
|
+
assert.deepEqual(residualTypes("is:starred", { starred: true }), []);
|
|
131
|
+
assert.deepEqual(residualTypes("is:unread", { unread: true }), []);
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
residualTypes("category:personal", { category: ["personal"] }),
|
|
134
|
+
[],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("the visible chip wins the parameter and the token becomes residue", () => {
|
|
139
|
+
assert.deepEqual(residualTypes("is:read", { unread: true }), ["isRead"]);
|
|
140
|
+
assert.deepEqual(
|
|
141
|
+
residualTypes("category:social", { category: ["personal"] }),
|
|
142
|
+
["category"],
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("applyResidualTokens", () => {
|
|
148
|
+
test("no residue leaves the rows untouched", () => {
|
|
149
|
+
const rows = [thread()];
|
|
150
|
+
assert.equal(applyResidualTokens(rows, []), rows);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a date the request could not carry is applied over the rows", () => {
|
|
154
|
+
const rows = [
|
|
155
|
+
thread({
|
|
156
|
+
messageId: "old",
|
|
157
|
+
sentDate: Date.parse("2026-01-01T00:00:00Z"),
|
|
158
|
+
}),
|
|
159
|
+
thread({
|
|
160
|
+
messageId: "new",
|
|
161
|
+
sentDate: Date.parse("2026-04-01T00:00:00Z"),
|
|
162
|
+
}),
|
|
163
|
+
];
|
|
164
|
+
const { residual } = split("after:2026-02-01");
|
|
165
|
+
assert.deepEqual(
|
|
166
|
+
applyResidualTokens(rows, residual).map((row) => row.messageId),
|
|
167
|
+
["new"],
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a contradiction the request could not express matches nothing", () => {
|
|
172
|
+
const rows = [thread({ isRead: false })];
|
|
173
|
+
const { residual } = split("is:unread is:read");
|
|
174
|
+
assert.deepEqual(applyResidualTokens(rows, residual), []);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("an account other than the row's drops the row", () => {
|
|
178
|
+
const { residual } = split("account:alice");
|
|
179
|
+
assert.equal(applyResidualTokens([thread()], residual).length, 1);
|
|
180
|
+
assert.equal(
|
|
181
|
+
applyResidualTokens([thread({ accountId: "acc_2" })], residual).length,
|
|
182
|
+
0,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a per-mailbox row takes the account of the view it is listed in", () => {
|
|
187
|
+
const rows = [thread({ accountId: undefined })];
|
|
188
|
+
const { residual } = split("account:alice");
|
|
189
|
+
assert.equal(applyResidualTokens(rows, residual, "acc_1").length, 1);
|
|
190
|
+
assert.equal(applyResidualTokens(rows, residual, "acc_2").length, 0);
|
|
191
|
+
assert.equal(applyResidualTokens(rows, residual).length, 0);
|
|
192
|
+
});
|
|
193
|
+
});
|