@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
|
@@ -2,11 +2,18 @@ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-clien
|
|
|
2
2
|
import { useMutation } from "@tanstack/react-query";
|
|
3
3
|
import { useCallback, useEffect } from "react";
|
|
4
4
|
import { buildOrganizeInput } from "@/lib/organize/organize-model";
|
|
5
|
+
import { isEvaluablePredicate } from "@/lib/organize/rule-model";
|
|
5
6
|
import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
6
7
|
|
|
7
8
|
interface SearchFilterSeed {
|
|
8
9
|
/** The live count for the converted literal predicate, seeding the editor. */
|
|
9
10
|
seedCount?: number;
|
|
11
|
+
/**
|
|
12
|
+
* The converted predicate is one the vector-free matcher refuses — a free-text
|
|
13
|
+
* search kept as a `HasWords` clause. There is no count to seed and no request
|
|
14
|
+
* to make.
|
|
15
|
+
*/
|
|
16
|
+
uncountable: boolean;
|
|
10
17
|
isPending: boolean;
|
|
11
18
|
isError: boolean;
|
|
12
19
|
error: unknown;
|
|
@@ -18,6 +25,14 @@ interface SearchFilterSeed {
|
|
|
18
25
|
* literal predicate. One `POST /organize/preview` under the account the filter
|
|
19
26
|
* targets — the count on screen is the set a literal-only filter applies to.
|
|
20
27
|
*
|
|
28
|
+
* A search whose terms convert to a `HasWords` clause has no count to seed: the
|
|
29
|
+
* vector-free matcher reads no message bodies and rejects the predicate outright
|
|
30
|
+
* (`assertNoBodyContentClause`), so asking is a 500 and a 500 is not a count.
|
|
31
|
+
* That search is still a legitimate standing filter — the index-time matcher
|
|
32
|
+
* does read bodies — so the editor opens on the uncountable reason and holds only
|
|
33
|
+
* the one-time apply, the same way {@link useRulePreview} handles the clause
|
|
34
|
+
* being added by hand.
|
|
35
|
+
*
|
|
21
36
|
* The deployment's semantic reach is not probed here; it is read from the
|
|
22
37
|
* search's own "Related" results on the surface that opens the editor (RFC 038
|
|
23
38
|
* D5), a direct signal that needs no request and cannot hit the wrong account.
|
|
@@ -28,9 +43,10 @@ export const useSearchFilterSeed = (
|
|
|
28
43
|
): SearchFilterSeed => {
|
|
29
44
|
const seed = useMutation(organizeOperationsPreviewOrganizeMutation());
|
|
30
45
|
const { mutate, reset } = seed;
|
|
46
|
+
const countable = isEvaluablePredicate(literalPredicate);
|
|
31
47
|
|
|
32
48
|
const run = useCallback(() => {
|
|
33
|
-
if (!accountId) return;
|
|
49
|
+
if (!accountId || !countable) return;
|
|
34
50
|
reset();
|
|
35
51
|
mutate({
|
|
36
52
|
path: { accountId },
|
|
@@ -41,6 +57,7 @@ export const useSearchFilterSeed = (
|
|
|
41
57
|
});
|
|
42
58
|
}, [
|
|
43
59
|
accountId,
|
|
60
|
+
countable,
|
|
44
61
|
literalPredicate.matchOperator,
|
|
45
62
|
literalPredicate.literalClauses,
|
|
46
63
|
mutate,
|
|
@@ -53,8 +70,9 @@ export const useSearchFilterSeed = (
|
|
|
53
70
|
|
|
54
71
|
return {
|
|
55
72
|
seedCount: seed.data?.matchedCount,
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
uncountable: !countable,
|
|
74
|
+
isPending: countable && (seed.isPending || seed.data === undefined),
|
|
75
|
+
isError: countable && seed.isError,
|
|
58
76
|
error: seed.error,
|
|
59
77
|
retry: run,
|
|
60
78
|
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the search box offers for the term under the caret (#428 follow-up).
|
|
3
|
+
*
|
|
4
|
+
* The decisions are all in `lib/search-suggestions.ts`; this is the wiring that
|
|
5
|
+
* gets them their data. A caret position names exactly one lookup — token names
|
|
6
|
+
* for a bare word, one token's values once its name is committed — so only that
|
|
7
|
+
* lookup runs, and the address search behind `from:` runs on a debounce rather
|
|
8
|
+
* than per keystroke.
|
|
9
|
+
*
|
|
10
|
+
* Folders and accounts are already loaded: the mailbox lists come from the same
|
|
11
|
+
* per-account query `useMailboxNameIndex` and `useResultFolderIndex` take, on
|
|
12
|
+
* the same key with the same infinite stale time, so react-query serves them
|
|
13
|
+
* from cache and nothing is fetched a second time.
|
|
14
|
+
*
|
|
15
|
+
* `in:` is offered only where the route resolves it. On a scoped view the parser
|
|
16
|
+
* leaves the token as free text (see `useSearchTokenContext`), so offering
|
|
17
|
+
* folders there would advertise a filter that does nothing.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
addressOperationsSearchAddressesOptions,
|
|
21
|
+
mailboxOperationsListMailboxesOptions,
|
|
22
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
23
|
+
import type { Suggestion } from "@remit/ui";
|
|
24
|
+
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
25
|
+
import { useMemo } from "react";
|
|
26
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
27
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
28
|
+
import {
|
|
29
|
+
buildSearchSuggestions,
|
|
30
|
+
contactSuggestionValue,
|
|
31
|
+
SEARCH_SUGGESTION_LIMIT,
|
|
32
|
+
searchSuggestionRequest,
|
|
33
|
+
} from "@/lib/search-suggestions";
|
|
34
|
+
import {
|
|
35
|
+
buildAccountSuggestionValues,
|
|
36
|
+
buildMailboxSuggestionValues,
|
|
37
|
+
} from "@/lib/search-token-index";
|
|
38
|
+
import { useDebouncedValue } from "./useDebouncedValue";
|
|
39
|
+
|
|
40
|
+
/** How long typing settles before the address lookup fires. */
|
|
41
|
+
export const SEARCH_CONTACT_DEBOUNCE_MS = 250;
|
|
42
|
+
|
|
43
|
+
/** The shortest contact query worth a round-trip. */
|
|
44
|
+
const MIN_CONTACT_QUERY_LENGTH = 2;
|
|
45
|
+
|
|
46
|
+
export interface SearchSuggestionsInput {
|
|
47
|
+
/** The query exactly as typed. */
|
|
48
|
+
query: string;
|
|
49
|
+
/** Where the caret sits in it. */
|
|
50
|
+
cursor: number;
|
|
51
|
+
/** Offer nothing while the field is not being typed in. */
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useSearchSuggestions({
|
|
56
|
+
query,
|
|
57
|
+
cursor,
|
|
58
|
+
enabled,
|
|
59
|
+
}: SearchSuggestionsInput): Suggestion[] {
|
|
60
|
+
const { accounts } = useMailContext();
|
|
61
|
+
const { mailboxesByName } = useSearchTokenContext();
|
|
62
|
+
const offersFolders = mailboxesByName !== undefined;
|
|
63
|
+
|
|
64
|
+
const request = enabled ? searchSuggestionRequest(query, cursor) : undefined;
|
|
65
|
+
const contactQuery =
|
|
66
|
+
request?.source === "contact" ? request.query.trim() : "";
|
|
67
|
+
const debouncedContactQuery = useDebouncedValue(
|
|
68
|
+
contactQuery,
|
|
69
|
+
SEARCH_CONTACT_DEBOUNCE_MS,
|
|
70
|
+
);
|
|
71
|
+
const { data: addresses } = useQuery({
|
|
72
|
+
...addressOperationsSearchAddressesOptions({
|
|
73
|
+
query: { q: debouncedContactQuery, limit: SEARCH_SUGGESTION_LIMIT },
|
|
74
|
+
}),
|
|
75
|
+
enabled: debouncedContactQuery.length >= MIN_CONTACT_QUERY_LENGTH,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const mailboxQueries = useQueries({
|
|
79
|
+
queries: accounts.map((account) => ({
|
|
80
|
+
...mailboxOperationsListMailboxesOptions({
|
|
81
|
+
path: { accountId: account.accountId },
|
|
82
|
+
}),
|
|
83
|
+
staleTime: Infinity,
|
|
84
|
+
})),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const mailboxes = useMemo(
|
|
88
|
+
() =>
|
|
89
|
+
offersFolders
|
|
90
|
+
? buildMailboxSuggestionValues(
|
|
91
|
+
mailboxQueries.map(
|
|
92
|
+
(mailboxQuery) => mailboxQuery.data?.items ?? [],
|
|
93
|
+
),
|
|
94
|
+
accounts,
|
|
95
|
+
)
|
|
96
|
+
: [],
|
|
97
|
+
[offersFolders, mailboxQueries, accounts],
|
|
98
|
+
);
|
|
99
|
+
const accountValues = useMemo(
|
|
100
|
+
() => buildAccountSuggestionValues(accounts),
|
|
101
|
+
[accounts],
|
|
102
|
+
);
|
|
103
|
+
const contacts = useMemo(
|
|
104
|
+
() => (addresses?.items ?? []).map(contactSuggestionValue),
|
|
105
|
+
[addresses],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return useMemo(() => {
|
|
109
|
+
if (!enabled) return [];
|
|
110
|
+
const suggestions = buildSearchSuggestions(query, cursor, {
|
|
111
|
+
mailboxes,
|
|
112
|
+
accounts: accountValues,
|
|
113
|
+
contacts,
|
|
114
|
+
});
|
|
115
|
+
if (offersFolders) return suggestions;
|
|
116
|
+
return suggestions.filter((suggestion) => suggestion.value !== "in:");
|
|
117
|
+
}, [
|
|
118
|
+
enabled,
|
|
119
|
+
query,
|
|
120
|
+
cursor,
|
|
121
|
+
mailboxes,
|
|
122
|
+
accountValues,
|
|
123
|
+
contacts,
|
|
124
|
+
offersFolders,
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
Binary file
|
|
@@ -6,7 +6,7 @@ import { useRouterState } from "@tanstack/react-router";
|
|
|
6
6
|
import { useMailContext } from "@/lib/mail-context";
|
|
7
7
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
8
8
|
import { semanticMailboxScope } from "@/lib/search-scope";
|
|
9
|
-
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
9
|
+
import { parseSearchTokens, type SearchToken } from "@/lib/search-tokens";
|
|
10
10
|
import { useSearchTokenContext } from "./useSearchTokenContext";
|
|
11
11
|
|
|
12
12
|
/** Cap the "Related" section; the literal "Top matches" is the primary surface. */
|
|
@@ -22,6 +22,17 @@ const toCategoryParam = (
|
|
|
22
22
|
? (filterCategory as (typeof MessageCategory)[keyof typeof MessageCategory])
|
|
23
23
|
: undefined;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* `isRead` for the read-state tokens. `is:unread` and `is:read` are opposites,
|
|
27
|
+
* so a query carrying both asks for nothing; unread wins, being the narrower
|
|
28
|
+
* ask and the one the chips also offer.
|
|
29
|
+
*/
|
|
30
|
+
const readStateParam = (tokens: SearchToken[]): boolean | undefined => {
|
|
31
|
+
if (tokens.some((t) => t.type === "isUnread")) return false;
|
|
32
|
+
if (tokens.some((t) => t.type === "isRead")) return true;
|
|
33
|
+
return undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
25
36
|
interface UseSemanticSearchParams {
|
|
26
37
|
/**
|
|
27
38
|
* Restrict results to a single mailbox. A mailbox route pins the scope to its
|
|
@@ -43,10 +54,11 @@ interface UseSemanticSearchParams {
|
|
|
43
54
|
* chip means this engine respects it like every other. Disabled until the query
|
|
44
55
|
* is non-empty so an empty field issues no request.
|
|
45
56
|
*
|
|
46
|
-
* Filter tokens (`has:attachment`, `is:unread`, `
|
|
47
|
-
* the query map onto the search
|
|
48
|
-
*
|
|
49
|
-
*
|
|
57
|
+
* Filter tokens (`has:attachment`, `is:unread`/`is:read`, `is:starred`,
|
|
58
|
+
* `category:`, `before:`/`after:`) parsed from the query map onto the search
|
|
59
|
+
* API's own filter params. `from:`, `subject:` and `account:` have no
|
|
60
|
+
* equivalent on `GET /search/semantic` (no sender, subject or
|
|
61
|
+
* account filter) — all still render as chips and narrow the literal engine,
|
|
50
62
|
* but never reach the semantic request (`account:` is a documented gap, see
|
|
51
63
|
* doc/design/flows/06-search.md — the semantic index is per account config).
|
|
52
64
|
* `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
|
|
@@ -77,11 +89,17 @@ export function useSemanticSearch({
|
|
|
77
89
|
// to rank.
|
|
78
90
|
const enabled = freeText.length > 0;
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
// The chip wins over the token: it is the visible narrowing, and the two can
|
|
93
|
+
// only disagree when the user set both.
|
|
94
|
+
const categoryToken = tokens.find((t) => t.type === "category");
|
|
95
|
+
const category = toCategoryParam(filterCategory) ?? categoryToken?.category;
|
|
81
96
|
const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
|
|
82
97
|
? true
|
|
83
98
|
: undefined;
|
|
84
|
-
const
|
|
99
|
+
const hasStars = tokens.some((t) => t.type === "isStarred")
|
|
100
|
+
? true
|
|
101
|
+
: undefined;
|
|
102
|
+
const isRead = readStateParam(tokens);
|
|
85
103
|
const afterToken = tokens.find((t) => t.type === "after");
|
|
86
104
|
const beforeToken = tokens.find((t) => t.type === "before");
|
|
87
105
|
const inToken = tokens.find((t) => t.type === "in");
|
|
@@ -100,6 +118,7 @@ export function useSemanticSearch({
|
|
|
100
118
|
limit: SEMANTIC_RESULT_LIMIT,
|
|
101
119
|
...(category !== undefined ? { category } : {}),
|
|
102
120
|
...(hasAttachment !== undefined ? { hasAttachment } : {}),
|
|
121
|
+
...(hasStars !== undefined ? { hasStars } : {}),
|
|
103
122
|
...(isRead !== undefined ? { isRead } : {}),
|
|
104
123
|
...(afterToken ? { sentDateFrom: afterToken.epochSeconds } : {}),
|
|
105
124
|
...(beforeToken ? { sentDateTo: beforeToken.epochSeconds } : {}),
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* Delete, move, star and the compose requests (reply / reply-all / forward),
|
|
5
5
|
* over the same mutation hooks the mailbox list uses. The mailbox view keys
|
|
6
6
|
* them by its route; the brief and Flagged are cross-account, so they key by
|
|
7
|
-
* the open thread's own `mailboxId` / `
|
|
7
|
+
* the open thread's own `mailboxId` / `accountId` (#149).
|
|
8
8
|
*/
|
|
9
9
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
10
|
import { useCallback, useState } from "react";
|
|
11
11
|
import type { ComposeMode } from "@/components/compose/ComposeProvider";
|
|
12
12
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
13
|
+
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
13
14
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
14
15
|
import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
|
|
15
16
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
@@ -42,7 +43,15 @@ export const useThreadActions = ({
|
|
|
42
43
|
onAfterOptimisticRemove,
|
|
43
44
|
}: UseThreadActionsOptions): ThreadActions => {
|
|
44
45
|
const resolvedMailboxId = mailboxId ?? thread?.mailboxId;
|
|
45
|
-
|
|
46
|
+
// `accountConfigId` is the caller's own identity, not an account: every
|
|
47
|
+
// `/accounts/{accountId}/…` call made with it 404s. The row carries a real
|
|
48
|
+
// `accountId` only when it came from the unified listing, so anything else
|
|
49
|
+
// resolves through the mailbox cache.
|
|
50
|
+
const knownAccountId = accountId ?? thread?.accountId;
|
|
51
|
+
const { accountId: mailboxAccountId } = useMailboxAccount(
|
|
52
|
+
knownAccountId ? undefined : resolvedMailboxId,
|
|
53
|
+
);
|
|
54
|
+
const resolvedAccountId = knownAccountId ?? mailboxAccountId;
|
|
46
55
|
const threadMessageIds = useThreadMessageIds();
|
|
47
56
|
|
|
48
57
|
const { deleteMessages } = useDeleteMessages({
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -433,6 +433,73 @@ describe("matchesSearchTokens", () => {
|
|
|
433
433
|
);
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
test("is:read requires isRead true", () => {
|
|
437
|
+
const isRead: SearchToken = { type: "isRead", raw: "is:read" };
|
|
438
|
+
assert.strictEqual(
|
|
439
|
+
matchesSearchTokens(row({ id: "1", isRead: true }), [isRead]),
|
|
440
|
+
true,
|
|
441
|
+
);
|
|
442
|
+
assert.strictEqual(
|
|
443
|
+
matchesSearchTokens(row({ id: "1", isRead: false }), [isRead]),
|
|
444
|
+
false,
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("is:starred requires starred true", () => {
|
|
449
|
+
const isStarred: SearchToken = { type: "isStarred", raw: "is:starred" };
|
|
450
|
+
assert.strictEqual(
|
|
451
|
+
matchesSearchTokens(row({ id: "1", starred: true }), [isStarred]),
|
|
452
|
+
true,
|
|
453
|
+
);
|
|
454
|
+
assert.strictEqual(
|
|
455
|
+
matchesSearchTokens(row({ id: "1" }), [isStarred]),
|
|
456
|
+
false,
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("subject: matches the subject, case-insensitively", () => {
|
|
461
|
+
const subject: SearchToken = {
|
|
462
|
+
type: "subject",
|
|
463
|
+
raw: "subject:roadmap",
|
|
464
|
+
value: "RoadMap",
|
|
465
|
+
};
|
|
466
|
+
assert.strictEqual(
|
|
467
|
+
matchesSearchTokens(row({ id: "1", subject: "Q3 roadmap" }), [subject]),
|
|
468
|
+
true,
|
|
469
|
+
);
|
|
470
|
+
assert.strictEqual(
|
|
471
|
+
matchesSearchTokens(row({ id: "2", subject: "Invoice" }), [subject]),
|
|
472
|
+
false,
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("category: matches the row's category, unclassified included", () => {
|
|
477
|
+
const personal: SearchToken = {
|
|
478
|
+
type: "category",
|
|
479
|
+
raw: "category:personal",
|
|
480
|
+
value: "personal",
|
|
481
|
+
category: "personal",
|
|
482
|
+
};
|
|
483
|
+
const unclassified: SearchToken = {
|
|
484
|
+
type: "category",
|
|
485
|
+
raw: "category:unclassified",
|
|
486
|
+
value: "unclassified",
|
|
487
|
+
category: "uncategorized",
|
|
488
|
+
};
|
|
489
|
+
assert.strictEqual(
|
|
490
|
+
matchesSearchTokens(row({ id: "1", category: "personal" }), [personal]),
|
|
491
|
+
true,
|
|
492
|
+
);
|
|
493
|
+
assert.strictEqual(
|
|
494
|
+
matchesSearchTokens(row({ id: "2", category: "marketing" }), [personal]),
|
|
495
|
+
false,
|
|
496
|
+
);
|
|
497
|
+
assert.strictEqual(
|
|
498
|
+
matchesSearchTokens(row({ id: "3" }), [unclassified]),
|
|
499
|
+
true,
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
436
503
|
test("after:/before: compare against sentDate (ms)", () => {
|
|
437
504
|
const jan15 = row({
|
|
438
505
|
id: "1",
|
package/src/lib/brief.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function toThreadRowData(
|
|
|
54
54
|
const suspicious = thread.authenticity?.dkimMismatch === true;
|
|
55
55
|
return {
|
|
56
56
|
id: thread.messageId,
|
|
57
|
-
accountId: thread.accountId
|
|
57
|
+
accountId: thread.accountId,
|
|
58
58
|
mailboxId: thread.mailboxId,
|
|
59
59
|
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
60
60
|
fromEmail: thread.fromEmail ?? "",
|
|
@@ -212,10 +212,20 @@ export function matchesSearchTokens(
|
|
|
212
212
|
t.fromName.toLowerCase().includes(needle)
|
|
213
213
|
);
|
|
214
214
|
}
|
|
215
|
+
case "subject":
|
|
216
|
+
return t.subject.toLowerCase().includes(token.value.toLowerCase());
|
|
217
|
+
// A row with no category is `uncategorized` — the pending state has a
|
|
218
|
+
// name (issue #45), so `category:unclassified` finds it.
|
|
219
|
+
case "category":
|
|
220
|
+
return (t.category ?? "uncategorized") === token.category;
|
|
215
221
|
case "hasAttachment":
|
|
216
222
|
return t.hasAttachment === true;
|
|
217
223
|
case "isUnread":
|
|
218
224
|
return !t.isRead;
|
|
225
|
+
case "isRead":
|
|
226
|
+
return t.isRead === true;
|
|
227
|
+
case "isStarred":
|
|
228
|
+
return t.starred === true;
|
|
219
229
|
case "after":
|
|
220
230
|
return t.sentDate != null && t.sentDate >= token.epochSeconds * 1000;
|
|
221
231
|
case "before":
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -124,6 +124,35 @@ export const runChunkedAction = async (
|
|
|
124
124
|
return { done, failedIds, cancelled: false };
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The same bounded run, adapted to the optimistic mutation hooks (#453).
|
|
129
|
+
*
|
|
130
|
+
* A selection is not always assembled by a surface that owns a progress bar —
|
|
131
|
+
* the daily brief's toolbar and sheet hand a raw id list to `useDeleteMessages`,
|
|
132
|
+
* `useMoveMessages`, `useToggleReadFor` and `useApplyLabel`, and select-all over
|
|
133
|
+
* a 200-row search would send all 200 in one call, which the endpoint rejects
|
|
134
|
+
* outright. Splitting the send inside those hooks puts the cap where the call is
|
|
135
|
+
* made, so every caller is covered by construction rather than by remembering.
|
|
136
|
+
*
|
|
137
|
+
* Each chunk is a full mutation of its own, so it keeps that hook's optimistic
|
|
138
|
+
* patch, rollback and error banner. The run stops at the first rejected chunk
|
|
139
|
+
* and reports nothing itself: the hook that owns the call has already raised it.
|
|
140
|
+
*/
|
|
141
|
+
export const runChunkedMutation = async (
|
|
142
|
+
ids: readonly string[],
|
|
143
|
+
send: (chunk: string[]) => Promise<unknown>,
|
|
144
|
+
): Promise<void> => {
|
|
145
|
+
await runChunkedAction(
|
|
146
|
+
ids,
|
|
147
|
+
async (chunk) => {
|
|
148
|
+
await send(chunk);
|
|
149
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
150
|
+
},
|
|
151
|
+
() => {},
|
|
152
|
+
() => false,
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
127
156
|
export interface FetchIdsPageResult {
|
|
128
157
|
ids: string[];
|
|
129
158
|
continuationToken?: string;
|
package/src/lib/drafts.ts
CHANGED
|
@@ -56,7 +56,7 @@ export function toImapDraftRowData(
|
|
|
56
56
|
): ThreadRowData {
|
|
57
57
|
return {
|
|
58
58
|
id: thread.messageId,
|
|
59
|
-
accountId: thread.
|
|
59
|
+
accountId: thread.accountId,
|
|
60
60
|
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
61
61
|
fromEmail: thread.fromEmail ?? "",
|
|
62
62
|
subject: thread.subject ?? "(No subject)",
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildClauseSuggestions,
|
|
5
|
+
CLAUSE_SUGGESTION_LIMIT,
|
|
6
|
+
fieldTakesAddressSuggestions,
|
|
7
|
+
type KnownAddress,
|
|
8
|
+
} from "./clause-suggestions";
|
|
9
|
+
|
|
10
|
+
const selection: KnownAddress[] = [
|
|
11
|
+
{ email: "receipts@stripe.com", displayName: "Stripe", fromSelection: true },
|
|
12
|
+
{ email: "rides@lyft.com", fromSelection: true },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe("fieldTakesAddressSuggestions", () => {
|
|
16
|
+
it("offers values for the address fields only", () => {
|
|
17
|
+
assert.equal(fieldTakesAddressSuggestions("From"), true);
|
|
18
|
+
assert.equal(fieldTakesAddressSuggestions("FromDomain"), true);
|
|
19
|
+
assert.equal(fieldTakesAddressSuggestions("Subject"), false);
|
|
20
|
+
assert.equal(fieldTakesAddressSuggestions("HasWords"), false);
|
|
21
|
+
assert.equal(fieldTakesAddressSuggestions("ListId"), false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("buildClauseSuggestions", () => {
|
|
26
|
+
it("offers the selection's addresses before anything is typed", () => {
|
|
27
|
+
const suggestions = buildClauseSuggestions("From", "", selection);
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
suggestions.map((s) => s.value),
|
|
30
|
+
["receipts@stripe.com", "rides@lyft.com"],
|
|
31
|
+
);
|
|
32
|
+
assert.equal(suggestions[0].label, "Stripe");
|
|
33
|
+
assert.equal(suggestions[0].hint, "receipts@stripe.com");
|
|
34
|
+
assert.equal(suggestions[0].source, "selected");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("leaves a free-text field with nothing to offer", () => {
|
|
38
|
+
assert.deepEqual(buildClauseSuggestions("Subject", "", selection), []);
|
|
39
|
+
assert.deepEqual(buildClauseSuggestions("HasWords", "str", selection), []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("collapses an address to its registrable domain for a domain clause", () => {
|
|
43
|
+
const suggestions = buildClauseSuggestions("FromDomain", "", [
|
|
44
|
+
{ email: "receipts@mail.stripe.com" },
|
|
45
|
+
{ email: "invoices@stripe.com" },
|
|
46
|
+
{ email: "rides@lyft.co.uk" },
|
|
47
|
+
]);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
suggestions.map((s) => s.value),
|
|
50
|
+
["stripe.com", "lyft.co.uk"],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("drops an address it cannot resolve a domain for", () => {
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
buildClauseSuggestions("FromDomain", "", [{ email: "postmaster" }]),
|
|
57
|
+
[],
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("matches on the typed text, against the address and the display name", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
buildClauseSuggestions("From", "stri", selection).map((s) => s.value),
|
|
64
|
+
["receipts@stripe.com"],
|
|
65
|
+
);
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
buildClauseSuggestions("From", "LYFT", selection).map((s) => s.value),
|
|
68
|
+
["rides@lyft.com"],
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("offers nothing when the typed value matches none of them", () => {
|
|
73
|
+
assert.deepEqual(buildClauseSuggestions("From", "nobody", selection), []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("drops an offer the user has already typed in full", () => {
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
buildClauseSuggestions("From", " Receipts@Stripe.com ", selection).map(
|
|
79
|
+
(s) => s.value,
|
|
80
|
+
),
|
|
81
|
+
[],
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps the first occurrence of a repeated value, marking and all", () => {
|
|
86
|
+
const suggestions = buildClauseSuggestions("From", "", [
|
|
87
|
+
...selection,
|
|
88
|
+
{ email: "Receipts@stripe.com", displayName: "Stripe Billing" },
|
|
89
|
+
]);
|
|
90
|
+
assert.equal(suggestions.length, 2);
|
|
91
|
+
assert.equal(suggestions[0].source, "selected");
|
|
92
|
+
assert.equal(suggestions[0].label, "Stripe");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("skips a blank address", () => {
|
|
96
|
+
assert.deepEqual(
|
|
97
|
+
buildClauseSuggestions("From", "", [{ email: " " }, ...selection]).map(
|
|
98
|
+
(s) => s.value,
|
|
99
|
+
),
|
|
100
|
+
["receipts@stripe.com", "rides@lyft.com"],
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("caps the list so it stays a shortcut rather than a directory", () => {
|
|
105
|
+
const many: KnownAddress[] = Array.from({ length: 20 }, (_, index) => ({
|
|
106
|
+
email: `sender-${index}@example.com`,
|
|
107
|
+
}));
|
|
108
|
+
assert.equal(
|
|
109
|
+
buildClauseSuggestions("From", "", many).length,
|
|
110
|
+
CLAUSE_SUGGESTION_LIMIT,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ClauseField, Suggestion } from "@remit/ui";
|
|
2
|
+
import { senderDomain } from "./sender-fallback";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Values worth offering while a clause is being typed. The rule editor's value
|
|
6
|
+
* field used to be bare text entry, so a `From` clause meant recalling an
|
|
7
|
+
* address exactly and a `FromDomain` clause meant knowing what the matcher
|
|
8
|
+
* considers a registrable domain.
|
|
9
|
+
*
|
|
10
|
+
* The offer is a shortcut and never a constraint: nothing here can reject or
|
|
11
|
+
* rewrite what the user typed, and a field with no matches is the plain text box
|
|
12
|
+
* it always was.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** An address a suggestion can be built from. */
|
|
16
|
+
export interface KnownAddress {
|
|
17
|
+
email: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The address came off the messages the user selected, rather than a lookup.
|
|
21
|
+
* These are the likeliest answers, so they lead the list and say where they
|
|
22
|
+
* came from.
|
|
23
|
+
*/
|
|
24
|
+
fromSelection?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** How many suggestions the list shows at most. */
|
|
28
|
+
export const CLAUSE_SUGGESTION_LIMIT = 8;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a clause field takes values that come from addresses. `Subject`,
|
|
32
|
+
* `HasWords`, and `ListId` are free text with nothing to draw on, and stay so.
|
|
33
|
+
*/
|
|
34
|
+
export const fieldTakesAddressSuggestions = (field: ClauseField): boolean =>
|
|
35
|
+
field === "From" || field === "FromDomain";
|
|
36
|
+
|
|
37
|
+
const addressSuggestion = (address: KnownAddress): Suggestion => ({
|
|
38
|
+
value: address.email,
|
|
39
|
+
label: address.displayName ?? address.email,
|
|
40
|
+
...(address.displayName ? { hint: address.email } : {}),
|
|
41
|
+
...(address.fromSelection ? { source: "selected" } : {}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const domainSuggestion = (address: KnownAddress): Suggestion | undefined => {
|
|
45
|
+
const domain = senderDomain(address.email);
|
|
46
|
+
if (domain === null) return undefined;
|
|
47
|
+
return {
|
|
48
|
+
value: domain,
|
|
49
|
+
...(address.fromSelection ? { source: "selected" } : {}),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const matches = (suggestion: Suggestion, query: string): boolean =>
|
|
54
|
+
suggestion.value.toLowerCase().includes(query) ||
|
|
55
|
+
(suggestion.label ?? "").toLowerCase().includes(query);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The suggestions for one clause draft. `addresses` is given in priority order —
|
|
59
|
+
* the selection's own senders first — and duplicates collapse onto the first
|
|
60
|
+
* occurrence, so a selected address keeps its "selected" marking even when a
|
|
61
|
+
* lookup returns it too.
|
|
62
|
+
*
|
|
63
|
+
* An empty query offers the lot, which is what makes the selection's addresses
|
|
64
|
+
* available before a single key is pressed. A query that exactly matches an
|
|
65
|
+
* offer drops it: there is nothing left to shortcut.
|
|
66
|
+
*/
|
|
67
|
+
export const buildClauseSuggestions = (
|
|
68
|
+
field: ClauseField,
|
|
69
|
+
query: string,
|
|
70
|
+
addresses: readonly KnownAddress[],
|
|
71
|
+
): Suggestion[] => {
|
|
72
|
+
if (!fieldTakesAddressSuggestions(field)) return [];
|
|
73
|
+
const needle = query.trim().toLowerCase();
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const out: Suggestion[] = [];
|
|
76
|
+
for (const address of addresses) {
|
|
77
|
+
if (address.email.trim() === "") continue;
|
|
78
|
+
const suggestion =
|
|
79
|
+
field === "From" ? addressSuggestion(address) : domainSuggestion(address);
|
|
80
|
+
if (!suggestion) continue;
|
|
81
|
+
const key = suggestion.value.toLowerCase();
|
|
82
|
+
if (key === "" || seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
if (key === needle) continue;
|
|
85
|
+
if (needle !== "" && !matches(suggestion, needle)) continue;
|
|
86
|
+
out.push(suggestion);
|
|
87
|
+
if (out.length === CLAUSE_SUGGESTION_LIMIT) break;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
};
|