@remit/web-client 0.0.247 → 0.0.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/FlaggedList.tsx +78 -28
- package/src/components/mail/MailListHeader.tsx +12 -4
- package/src/components/mail/MailViewChrome.tsx +2 -1
- package/src/components/mail/MailboxPane.tsx +33 -15
- package/src/components/mail/MessageList.tsx +10 -17
- package/src/hooks/useResultCount.render.test.ts +161 -0
- package/src/hooks/useResultCount.ts +68 -0
- package/src/hooks/useStarredThreads.test.ts +76 -0
- package/src/hooks/useStarredThreads.ts +99 -8
- package/src/lib/flagged-criteria.test.ts +99 -0
- package/src/lib/flagged-criteria.ts +61 -0
- package/src/lib/inbox-filters.ts +24 -3
- package/src/lib/result-count.test.ts +97 -0
- package/src/lib/result-count.ts +57 -0
- package/src/lib/starred-rows.ts +10 -5
- package/src/lib/thread-search-tokens.ts +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.249",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
* the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
|
|
13
13
|
* `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
|
|
14
14
|
* with a consumer-supplied `listBody` so the real rows render at every width.
|
|
15
|
+
*
|
|
16
|
+
* The category and attribute chips are query parameters, and the header's
|
|
17
|
+
* unread count is the server's own (#308). Both used to be computed over the
|
|
18
|
+
* pages the user happened to have loaded, so a category whose mail sat below
|
|
19
|
+
* the newest page showed an empty list, and the count grew with every press of
|
|
20
|
+
* "load more" while being presented as a total.
|
|
15
21
|
*/
|
|
16
22
|
import {
|
|
17
23
|
flaggedFilterConfig,
|
|
24
|
+
type MessageListFilter,
|
|
18
25
|
MessageListPane,
|
|
19
26
|
type SearchResult,
|
|
20
27
|
type ThreadRowData,
|
|
@@ -24,14 +31,21 @@ import { type RefObject, useCallback, useMemo, useState } from "react";
|
|
|
24
31
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
25
32
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
26
33
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
27
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
useStarredTextSearch,
|
|
36
|
+
useStarredThreads,
|
|
37
|
+
useStarredUnreadCount,
|
|
38
|
+
} from "@/hooks/useStarredThreads";
|
|
28
39
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
29
40
|
import {
|
|
30
41
|
matchesBriefSearch,
|
|
31
42
|
matchesSearchTokens,
|
|
43
|
+
mergeSearchRows,
|
|
32
44
|
toThreadRowData,
|
|
33
45
|
} from "@/lib/brief";
|
|
34
46
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
47
|
+
import { flaggedCriteria } from "@/lib/flagged-criteria";
|
|
48
|
+
import { categoryLabel } from "@/lib/inbox-filters";
|
|
35
49
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
36
50
|
import { useMailContext } from "@/lib/mail-context";
|
|
37
51
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
@@ -51,10 +65,8 @@ import {
|
|
|
51
65
|
useThreadListSelection,
|
|
52
66
|
} from "./ThreadListInteraction";
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
attachment: (t) => t.hasAttachment === true,
|
|
57
|
-
};
|
|
68
|
+
/** One page of the server-filtered text search, merged with the listing below. */
|
|
69
|
+
const TEXT_SEARCH_PAGE_SIZE = 200;
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
72
|
* The wizard this view's verbs walk, and the one its search entry lands on.
|
|
@@ -138,6 +150,26 @@ export function FlaggedList({
|
|
|
138
150
|
setActiveFilters(new Set());
|
|
139
151
|
}, []);
|
|
140
152
|
|
|
153
|
+
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
154
|
+
searchQuery.trim().toLowerCase(),
|
|
155
|
+
tokenContext,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// The chips and the tokens together, as query parameters: a category typed as
|
|
159
|
+
// `category:personal` narrows the request exactly as the chip does.
|
|
160
|
+
const { criteria, residual: residualTokens } = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
flaggedCriteria(
|
|
163
|
+
{ category: selectedCategory, attributes: activeFilters },
|
|
164
|
+
queryTokens,
|
|
165
|
+
),
|
|
166
|
+
[selectedCategory, activeFilters, queryTokens],
|
|
167
|
+
);
|
|
168
|
+
const textCriteria = useMemo(
|
|
169
|
+
() => (sq ? { ...criteria, query: sq } : criteria),
|
|
170
|
+
[criteria, sq],
|
|
171
|
+
);
|
|
172
|
+
|
|
141
173
|
const {
|
|
142
174
|
threads,
|
|
143
175
|
isLoading,
|
|
@@ -147,27 +179,27 @@ export function FlaggedList({
|
|
|
147
179
|
fetchNextPage,
|
|
148
180
|
hasNextPage,
|
|
149
181
|
isFetchingNextPage,
|
|
150
|
-
} = useStarredThreads();
|
|
151
|
-
|
|
152
|
-
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
153
|
-
searchQuery.trim().toLowerCase(),
|
|
154
|
-
tokenContext,
|
|
155
|
-
);
|
|
182
|
+
} = useStarredThreads(criteria);
|
|
183
|
+
const textMatches = useStarredTextSearch(textCriteria, TEXT_SEARCH_PAGE_SIZE);
|
|
156
184
|
|
|
157
185
|
const rows = useMemo<ThreadRowData[]>(() => {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
186
|
+
const listed = dedupeByThread(threads).map(toThreadRowData);
|
|
187
|
+
// No free text: the server-filtered listing as it comes.
|
|
188
|
+
const matched = sq
|
|
189
|
+
? mergeSearchRows(
|
|
190
|
+
// The snippet half, over the rows already loaded. It only ever adds
|
|
191
|
+
// to the server's set — it is never what decides membership.
|
|
192
|
+
listed.filter((t) => matchesBriefSearch(t, sq)),
|
|
193
|
+
dedupeByThread(textMatches).map(toThreadRowData),
|
|
194
|
+
)
|
|
195
|
+
: listed;
|
|
196
|
+
// What is left is what no parameter can carry: `before:`, `after:`, `in:`,
|
|
197
|
+
// `account:`, and a token the chips overruled. The collapse runs last so
|
|
198
|
+
// the two halves of a text search cannot land one conversation twice.
|
|
199
|
+
return dedupeByThread(matched).filter((t) =>
|
|
200
|
+
matchesSearchTokens(t, residualTokens),
|
|
201
|
+
);
|
|
202
|
+
}, [threads, textMatches, sq, residualTokens]);
|
|
171
203
|
|
|
172
204
|
const openRow = useCallback(
|
|
173
205
|
(id: string, options?: OpenMessageOptions) => {
|
|
@@ -197,10 +229,24 @@ export function FlaggedList({
|
|
|
197
229
|
[rows, resultFolderIndex],
|
|
198
230
|
);
|
|
199
231
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
232
|
+
// The server's count over the whole collection under the active criteria.
|
|
233
|
+
// `null` while it is in flight and `null` when it cannot be had, and the
|
|
234
|
+
// header then shows no number — never a page length dressed as a total.
|
|
235
|
+
const unreadCount = useStarredUnreadCount(textCriteria) ?? null;
|
|
236
|
+
|
|
237
|
+
// An empty list has to say how much was looked at, and the answer comes off
|
|
238
|
+
// the request. Every chip and every carried token is a column on the row, so
|
|
239
|
+
// the server answered over the whole collection; a residual token, or the
|
|
240
|
+
// snippet half of a free-text search, only ever saw the pages loaded so far.
|
|
241
|
+
const filterLabel = categoryLabel(selectedCategory);
|
|
242
|
+
const listFilter: MessageListFilter | undefined = filterLabel
|
|
243
|
+
? {
|
|
244
|
+
label: filterLabel,
|
|
245
|
+
reach:
|
|
246
|
+
residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
|
|
247
|
+
onClear: clearFilters,
|
|
248
|
+
}
|
|
249
|
+
: undefined;
|
|
204
250
|
|
|
205
251
|
const listState = isLoading
|
|
206
252
|
? "loading"
|
|
@@ -277,6 +323,10 @@ export function FlaggedList({
|
|
|
277
323
|
flatList
|
|
278
324
|
hideHeader
|
|
279
325
|
listState={chrome.searchResults ? "ready" : listState}
|
|
326
|
+
listFilter={listFilter}
|
|
327
|
+
// Flagged spans accounts and folders, so it is a collection and
|
|
328
|
+
// never "this mailbox". It names itself.
|
|
329
|
+
listScopeLabel="Starred"
|
|
280
330
|
searchQuery={sq ? searchQuery : undefined}
|
|
281
331
|
errorMessage={isError ? formatErrorMessage(error) : undefined}
|
|
282
332
|
onRetry={() => refetch()}
|
|
@@ -95,7 +95,13 @@ import {
|
|
|
95
95
|
|
|
96
96
|
export interface MailListHeaderProps {
|
|
97
97
|
title: string;
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Unread count beside the title. `null` is the one way to say the number is
|
|
100
|
+
* not known, and the header then shows none: a count the server has not
|
|
101
|
+
* answered is never replaced by a figure derived from the rows that happen to
|
|
102
|
+
* be loaded (#308). Required so a caller that has no number says so.
|
|
103
|
+
*/
|
|
104
|
+
unreadCount: number | null;
|
|
99
105
|
/** The list body (filter sheet / sections / virtualized rows). */
|
|
100
106
|
children: ReactNode;
|
|
101
107
|
/**
|
|
@@ -442,9 +448,11 @@ export function MailListHeader({
|
|
|
442
448
|
),
|
|
443
449
|
titleMeta: (
|
|
444
450
|
<>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
451
|
+
{unreadCount === null ? null : (
|
|
452
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
453
|
+
{unreadCount.toLocaleString()} unread
|
|
454
|
+
</span>
|
|
455
|
+
)}
|
|
448
456
|
<FilterToggle />
|
|
449
457
|
{refreshControl}
|
|
450
458
|
</>
|
|
@@ -30,7 +30,8 @@ import { MailListHeader } from "./MailListHeader";
|
|
|
30
30
|
|
|
31
31
|
interface MailViewChromeProps {
|
|
32
32
|
title: string;
|
|
33
|
-
|
|
33
|
+
/** Unread count beside the title; `null` shows no number — see `MailListHeader`. */
|
|
34
|
+
unreadCount: number | null;
|
|
34
35
|
preset: FilterPreset;
|
|
35
36
|
selectedCategory: string;
|
|
36
37
|
activeFilters: ReadonlySet<string>;
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
ReadingPaneEmpty,
|
|
31
31
|
RefreshButton,
|
|
32
32
|
type RescueCandidate,
|
|
33
|
+
type ResultCount,
|
|
33
34
|
type SearchResult,
|
|
34
35
|
} from "@remit/ui";
|
|
35
36
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
@@ -87,6 +88,7 @@ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
|
87
88
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
88
89
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
89
90
|
import { useRescueCandidates } from "@/hooks/useRescueCandidates";
|
|
91
|
+
import { useResultCount } from "@/hooks/useResultCount";
|
|
90
92
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
91
93
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
92
94
|
import { useThreadActions } from "@/hooks/useThreadActions";
|
|
@@ -98,6 +100,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
|
98
100
|
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
99
101
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
100
102
|
import {
|
|
103
|
+
categoryLabel,
|
|
101
104
|
filterReach,
|
|
102
105
|
hasInboxFilter,
|
|
103
106
|
type InboxFilterCriteria,
|
|
@@ -110,6 +113,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
110
113
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
111
114
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
112
115
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
116
|
+
import { shouldRequestResultCount } from "@/lib/result-count";
|
|
113
117
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
114
118
|
import {
|
|
115
119
|
relatedSearchResults,
|
|
@@ -156,7 +160,8 @@ interface MailboxPaneContextValue {
|
|
|
156
160
|
mailboxAccountId: string | undefined;
|
|
157
161
|
mailboxAccountLoading: boolean;
|
|
158
162
|
mailboxName: string | null;
|
|
159
|
-
|
|
163
|
+
/** The mailbox's unseen total, or null until it resolves — never a page length. */
|
|
164
|
+
unreadCount: number | null;
|
|
160
165
|
isDraftsMailbox: boolean;
|
|
161
166
|
// Rescue-from-Spam: true on the account's Junk/Spam folder, with the
|
|
162
167
|
// suspected-safe messages `useRescueCandidates` fetched. Drives the rescue
|
|
@@ -202,6 +207,8 @@ interface MailboxPaneContextValue {
|
|
|
202
207
|
* header, not enough to reproduce the query server-side.
|
|
203
208
|
*/
|
|
204
209
|
searchPredicate: EscalationSearchQuery | undefined;
|
|
210
|
+
/** The server's count of the whole match set, for the result header. */
|
|
211
|
+
resultCount: ResultCount;
|
|
205
212
|
// List actions
|
|
206
213
|
onDeleteMessages: (ids: string[]) => void;
|
|
207
214
|
onMoveMessages: (ids: string[], dest: string) => void;
|
|
@@ -240,14 +247,6 @@ interface MailboxPaneContextValue {
|
|
|
240
247
|
* filtered path pages like the unfiltered one. */
|
|
241
248
|
const THREADS_PAGE_SIZE = 50;
|
|
242
249
|
|
|
243
|
-
/** Chip id → the label the empty state names the filter by. `all` is absent:
|
|
244
|
-
* it is how the category is cleared, not a category. */
|
|
245
|
-
const CATEGORY_LABELS = new Map(
|
|
246
|
-
inboxFilterConfig()
|
|
247
|
-
.categories.filter((category) => category.id !== "all")
|
|
248
|
-
.map((category) => [category.id, category.label]),
|
|
249
|
-
);
|
|
250
|
-
|
|
251
250
|
const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
|
|
252
251
|
|
|
253
252
|
function useMailboxPane(): MailboxPaneContextValue {
|
|
@@ -322,15 +321,21 @@ function MailboxPaneProvider({
|
|
|
322
321
|
searchTokens,
|
|
323
322
|
filterParams,
|
|
324
323
|
);
|
|
325
|
-
|
|
324
|
+
// What the list is asking about, with nothing about paging in it. The count
|
|
325
|
+
// is a property of the criteria alone, so it keys on this and a page fetch
|
|
326
|
+
// can never trigger one.
|
|
327
|
+
const searchCriteria = {
|
|
326
328
|
order: "desc" as const,
|
|
327
|
-
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
328
|
-
// so switching paths without it multiplies the page size by ten.
|
|
329
|
-
limit: THREADS_PAGE_SIZE,
|
|
330
329
|
...(freeText ? { query: freeText } : {}),
|
|
331
330
|
...tokenParams,
|
|
332
331
|
...filterParams,
|
|
333
332
|
};
|
|
333
|
+
const searchThreadsQuery = {
|
|
334
|
+
...searchCriteria,
|
|
335
|
+
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
336
|
+
// so switching paths without it multiplies the page size by ten.
|
|
337
|
+
limit: THREADS_PAGE_SIZE,
|
|
338
|
+
};
|
|
334
339
|
// What the request actually narrows by, whoever set it. `placeholderData`
|
|
335
340
|
// keeps the previous rows only under the same predicate, and a token
|
|
336
341
|
// narrowing the list is as much that predicate as a chip is.
|
|
@@ -393,6 +398,16 @@ function MailboxPaneProvider({
|
|
|
393
398
|
: undefined,
|
|
394
399
|
});
|
|
395
400
|
|
|
401
|
+
const resultCount = useResultCount({
|
|
402
|
+
mailboxId,
|
|
403
|
+
criteria: searchCriteria,
|
|
404
|
+
enabled: shouldRequestResultCount({
|
|
405
|
+
hasSearchQuery,
|
|
406
|
+
freeText,
|
|
407
|
+
residualTokenCount: residualTokens.length,
|
|
408
|
+
}),
|
|
409
|
+
});
|
|
410
|
+
|
|
396
411
|
const { accountId: mailboxAccountId, isLoading: mailboxAccountLoading } =
|
|
397
412
|
useMailboxAccount(mailboxId);
|
|
398
413
|
const mailboxName = useCurrentMailboxName({ accounts });
|
|
@@ -433,7 +448,7 @@ function MailboxPaneProvider({
|
|
|
433
448
|
// The empty state has to say how much was read, and the reach comes off the
|
|
434
449
|
// request rather than the call site: the day a chip is answered over a window
|
|
435
450
|
// instead of the whole mailbox, the sentence changes with it.
|
|
436
|
-
const filterLabel =
|
|
451
|
+
const filterLabel = categoryLabel(filterCategory);
|
|
437
452
|
const listFilter: MessageListFilter | undefined = filterLabel
|
|
438
453
|
? {
|
|
439
454
|
label: filterLabel,
|
|
@@ -512,7 +527,7 @@ function MailboxPaneProvider({
|
|
|
512
527
|
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
513
528
|
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
514
529
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
515
|
-
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ??
|
|
530
|
+
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? null;
|
|
516
531
|
|
|
517
532
|
const toolbarActions = useThreadActions({
|
|
518
533
|
thread: selectedThread,
|
|
@@ -796,6 +811,7 @@ function MailboxPaneProvider({
|
|
|
796
811
|
hasSearchQuery && residualTokens.length === 0
|
|
797
812
|
? searchThreadsQuery
|
|
798
813
|
: undefined,
|
|
814
|
+
resultCount,
|
|
799
815
|
onDeleteMessages: handleDeleteMessages,
|
|
800
816
|
onMoveMessages: handleMoveMessages,
|
|
801
817
|
isDeleting,
|
|
@@ -860,6 +876,7 @@ function MailboxList() {
|
|
|
860
876
|
onClearFilters,
|
|
861
877
|
listFilter,
|
|
862
878
|
searchPredicate,
|
|
879
|
+
resultCount,
|
|
863
880
|
} = useMailboxPane();
|
|
864
881
|
const { searchQuery, searchInput, accounts, resultFolderIndex } =
|
|
865
882
|
useMailContext();
|
|
@@ -984,6 +1001,7 @@ function MailboxList() {
|
|
|
984
1001
|
onRetry={onRetry}
|
|
985
1002
|
searchQuery={searchQuery}
|
|
986
1003
|
searchPredicate={searchPredicate}
|
|
1004
|
+
resultCount={resultCount}
|
|
987
1005
|
onDeleteMessages={onDeleteMessages}
|
|
988
1006
|
isDeleting={isDeleting}
|
|
989
1007
|
isMoving={isMoving}
|
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
cn,
|
|
5
5
|
type Density,
|
|
6
6
|
deriveIsMultiSelectMode,
|
|
7
|
+
ListResultHeader,
|
|
7
8
|
type MessageListFilter,
|
|
8
9
|
MessageListLoadingMore,
|
|
9
10
|
MessageListPane,
|
|
10
11
|
nextFocusId,
|
|
12
|
+
type ResultCount,
|
|
11
13
|
type SelectionModifiers,
|
|
12
14
|
SelectionTopBar,
|
|
13
15
|
useSelection,
|
|
@@ -15,7 +17,6 @@ import {
|
|
|
15
17
|
} from "@remit/ui";
|
|
16
18
|
import { useBlocker } from "@tanstack/react-router";
|
|
17
19
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
18
|
-
import { Search } from "lucide-react";
|
|
19
20
|
import type { RefObject } from "react";
|
|
20
21
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
21
22
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
@@ -113,6 +114,12 @@ interface MessageListProps {
|
|
|
113
114
|
* only the display string.
|
|
114
115
|
*/
|
|
115
116
|
searchPredicate?: EscalationSearchQuery;
|
|
117
|
+
/**
|
|
118
|
+
* How many messages the search matches, as the server counted them — the
|
|
119
|
+
* whole match set, not the pages loaded. Omitted where the count was never
|
|
120
|
+
* asked for, which renders the header without a number.
|
|
121
|
+
*/
|
|
122
|
+
resultCount?: ResultCount;
|
|
116
123
|
onDeleteMessages?: (messageIds: string[]) => void;
|
|
117
124
|
isDeleting?: boolean;
|
|
118
125
|
isMoving?: boolean;
|
|
@@ -188,21 +195,6 @@ const readStoredDensity = (): Density => {
|
|
|
188
195
|
return "comfortable";
|
|
189
196
|
};
|
|
190
197
|
|
|
191
|
-
/**
|
|
192
|
-
* Names what the list is showing. No number: the only figure available here is
|
|
193
|
-
* the length of the loaded pages, and a page length presented as a result total
|
|
194
|
-
* contradicts the completeness the filtered empty state states in the same view
|
|
195
|
-
* (#306). The exact count is #307's.
|
|
196
|
-
*/
|
|
197
|
-
const SearchResultsHeader = ({ query }: { query: string }) => (
|
|
198
|
-
<div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
|
|
199
|
-
<Search className="size-4 text-fg-muted" />
|
|
200
|
-
<span className="text-sm text-fg-muted">
|
|
201
|
-
Results for “{query}”
|
|
202
|
-
</span>
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
|
|
206
198
|
/**
|
|
207
199
|
* Why Move is withheld from a selection, in the toolbar's own words. Rows from
|
|
208
200
|
* a per-mailbox endpoint carry no account of their own, so the list's own
|
|
@@ -230,6 +222,7 @@ export const MessageList = ({
|
|
|
230
222
|
onRetry,
|
|
231
223
|
searchQuery,
|
|
232
224
|
searchPredicate,
|
|
225
|
+
resultCount = { kind: "unknown" },
|
|
233
226
|
onDeleteMessages,
|
|
234
227
|
isDeleting = false,
|
|
235
228
|
isMoving = false,
|
|
@@ -1227,7 +1220,7 @@ export const MessageList = ({
|
|
|
1227
1220
|
const virtualBody = (
|
|
1228
1221
|
<>
|
|
1229
1222
|
{isSearching && searchQuery && (
|
|
1230
|
-
<
|
|
1223
|
+
<ListResultHeader query={searchQuery} count={resultCount} />
|
|
1231
1224
|
)}
|
|
1232
1225
|
<div
|
|
1233
1226
|
ref={parentRef}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The result count is one question asked of the server, not a walk of the
|
|
3
|
+
* pages (#307).
|
|
4
|
+
*
|
|
5
|
+
* The header used to render `threads.length`, which understated every search
|
|
6
|
+
* matching more than one page and climbed as the reader scrolled. These mount
|
|
7
|
+
* the real hook against the real fetch seam and read what actually left: the
|
|
8
|
+
* whole match set from one request, no second request when the list pages, and
|
|
9
|
+
* nothing at all for a query still being typed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, describe, it } from "node:test";
|
|
14
|
+
import type { ResultCount } from "@remit/ui";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
17
|
+
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
18
|
+
import { type ThreadSearchCriteria, useResultCount } from "./useResultCount.js";
|
|
19
|
+
|
|
20
|
+
const INBOX = "mbx-inbox";
|
|
21
|
+
/** Far more than a page holds — the number the header must not derive itself. */
|
|
22
|
+
const TOTAL_MATCHES = 1284;
|
|
23
|
+
|
|
24
|
+
let harness: DomHarness | undefined;
|
|
25
|
+
let http: HttpMock | undefined;
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
harness?.close();
|
|
29
|
+
harness = undefined;
|
|
30
|
+
http?.restore();
|
|
31
|
+
http = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Count-only requests, in order — the ones that asked for a number. */
|
|
35
|
+
const countRequests = () =>
|
|
36
|
+
(http?.to("/threads/search") ?? []).filter(
|
|
37
|
+
(call) =>
|
|
38
|
+
new URL(call.url, "http://localhost").searchParams.get("count") ===
|
|
39
|
+
"true",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
interface Probe {
|
|
43
|
+
count: () => ResultCount;
|
|
44
|
+
/**
|
|
45
|
+
* Render again on the same client, so a second search reaches the cache the
|
|
46
|
+
* first one filled — which is the only place a stale count can come from.
|
|
47
|
+
*/
|
|
48
|
+
rerender: (next?: {
|
|
49
|
+
criteria?: ThreadSearchCriteria;
|
|
50
|
+
enabled?: boolean;
|
|
51
|
+
}) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mount = async (
|
|
55
|
+
criteria: ThreadSearchCriteria,
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
answer: unknown = { items: [], count: TOTAL_MATCHES },
|
|
58
|
+
): Promise<Probe> => {
|
|
59
|
+
http = mockFetch((call) =>
|
|
60
|
+
call.path.endsWith("/threads/search") ? answer : { items: [] },
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
let value: ResultCount | undefined;
|
|
64
|
+
const CountProbe = ({
|
|
65
|
+
search,
|
|
66
|
+
on,
|
|
67
|
+
}: {
|
|
68
|
+
search: ThreadSearchCriteria;
|
|
69
|
+
on: boolean;
|
|
70
|
+
}) => {
|
|
71
|
+
value = useResultCount({ mailboxId: INBOX, criteria: search, enabled: on });
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const mounted = createDomHarness();
|
|
76
|
+
harness = mounted;
|
|
77
|
+
let search = criteria;
|
|
78
|
+
let on = enabled;
|
|
79
|
+
const render: Probe["rerender"] = async (next) => {
|
|
80
|
+
if (next?.criteria) search = next.criteria;
|
|
81
|
+
if (next?.enabled !== undefined) on = next.enabled;
|
|
82
|
+
mounted.renderApp(createElement(CountProbe, { search, on }));
|
|
83
|
+
await mounted.flush();
|
|
84
|
+
await mounted.wait(20);
|
|
85
|
+
await mounted.flush();
|
|
86
|
+
};
|
|
87
|
+
await render();
|
|
88
|
+
return {
|
|
89
|
+
count: () => {
|
|
90
|
+
if (!value) throw new Error("hook did not render");
|
|
91
|
+
return value;
|
|
92
|
+
},
|
|
93
|
+
rerender: render,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("useResultCount (#307)", () => {
|
|
98
|
+
it("answers with the whole match set the server counted", async () => {
|
|
99
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
|
|
102
|
+
assert.equal(countRequests().length, 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("asks with no cursor, so paging the list never re-counts", async () => {
|
|
106
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
107
|
+
await probe.rerender();
|
|
108
|
+
await probe.rerender();
|
|
109
|
+
|
|
110
|
+
assert.equal(
|
|
111
|
+
countRequests().length,
|
|
112
|
+
1,
|
|
113
|
+
"the count was asked again for a predicate that did not change",
|
|
114
|
+
);
|
|
115
|
+
const asked = new URL(countRequests()[0]?.url ?? "", "http://localhost")
|
|
116
|
+
.searchParams;
|
|
117
|
+
assert.equal(asked.get("continuationToken"), null);
|
|
118
|
+
assert.equal(asked.get("results"), "false");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("issues no request at all while it is disabled", async () => {
|
|
122
|
+
const probe = await mount({ query: "in" }, false);
|
|
123
|
+
|
|
124
|
+
assert.equal(countRequests().length, 0);
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
probe.count(),
|
|
127
|
+
{ kind: "unknown" },
|
|
128
|
+
"an uncounted search reported a number",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `enabled` gates the request, not the cache. Two searches reach the same key
|
|
134
|
+
* whenever what separates them never reaches the request — `invoice` and
|
|
135
|
+
* `invoice before:2020` send identical criteria — so the second must not read
|
|
136
|
+
* the first one's answer back out of the cache.
|
|
137
|
+
*/
|
|
138
|
+
it("forgets the number the moment it stops being asked for", async () => {
|
|
139
|
+
const probe = await mount({ query: "invoice" }, true);
|
|
140
|
+
assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
|
|
141
|
+
|
|
142
|
+
await probe.rerender({ enabled: false });
|
|
143
|
+
|
|
144
|
+
assert.deepEqual(
|
|
145
|
+
probe.count(),
|
|
146
|
+
{ kind: "unknown" },
|
|
147
|
+
"a count nobody asked for was served from the cache",
|
|
148
|
+
);
|
|
149
|
+
assert.equal(countRequests().length, 1, "the gate stopped gating requests");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reports no number when the server withholds the count", async () => {
|
|
153
|
+
const probe = await mount({ query: "invoice" }, true, { items: [] });
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
probe.count(),
|
|
157
|
+
{ kind: "unknown" },
|
|
158
|
+
"an absent count became a zero",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { threadOperationsSearchThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { threadOperationsSearchThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
3
|
+
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import type { ResultCount } from "@remit/ui";
|
|
5
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { toResultCount } from "@/lib/result-count";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the list is asking about, with nothing about paging in it. The count is
|
|
10
|
+
* a property of the criteria alone, so a page fetch can never trigger one.
|
|
11
|
+
*/
|
|
12
|
+
export type ThreadSearchCriteria = Omit<
|
|
13
|
+
NonNullable<ThreadOperationsSearchThreadsData["query"]>,
|
|
14
|
+
"continuationToken" | "limit" | "count" | "results"
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export interface UseResultCountOptions {
|
|
18
|
+
mailboxId: string;
|
|
19
|
+
criteria: ThreadSearchCriteria;
|
|
20
|
+
/**
|
|
21
|
+
* Whether this search is worth counting at all — see
|
|
22
|
+
* `shouldRequestResultCount`. Disabled, the surface renders no number rather
|
|
23
|
+
* than a stale one from the search before it.
|
|
24
|
+
*/
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A minute: long enough that returning to a search just left costs nothing. */
|
|
29
|
+
const COUNT_STALE_TIME_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many messages the search matches, from the server that resolves it —
|
|
33
|
+
* one request against the criteria, never a walk of the pages (#307).
|
|
34
|
+
*
|
|
35
|
+
* Its own query, keyed on the criteria without the cursor, so the list pages
|
|
36
|
+
* without re-counting. An answer that carries no count — off-row criteria, per
|
|
37
|
+
* #305 — stays absent rather than becoming a zero.
|
|
38
|
+
*/
|
|
39
|
+
export const useResultCount = ({
|
|
40
|
+
mailboxId,
|
|
41
|
+
criteria,
|
|
42
|
+
enabled,
|
|
43
|
+
}: UseResultCountOptions): ResultCount => {
|
|
44
|
+
const query = { ...criteria, count: true, results: false };
|
|
45
|
+
const { data } = useQuery({
|
|
46
|
+
queryKey: threadOperationsSearchThreadsQueryKey({
|
|
47
|
+
path: { mailboxId },
|
|
48
|
+
query,
|
|
49
|
+
}),
|
|
50
|
+
queryFn: async () => {
|
|
51
|
+
const { data: response } = await threadOperationsSearchThreads({
|
|
52
|
+
path: { mailboxId },
|
|
53
|
+
query,
|
|
54
|
+
throwOnError: true,
|
|
55
|
+
});
|
|
56
|
+
return response;
|
|
57
|
+
},
|
|
58
|
+
enabled,
|
|
59
|
+
staleTime: COUNT_STALE_TIME_MS,
|
|
60
|
+
});
|
|
61
|
+
// `enabled` gates the request, not the cache: a criteria set that asks for no
|
|
62
|
+
// count still reads whatever the last identical key left behind. Two searches
|
|
63
|
+
// share a key whenever the terms that separate them reach the request as
|
|
64
|
+
// nothing — `before:`/`after:`/`account:` are stripped from the free text and
|
|
65
|
+
// carry no param — so a disabled count that read `data` would render the
|
|
66
|
+
// previous search's total over this one's rows.
|
|
67
|
+
return enabled ? toResultCount(data?.count) : { kind: "unknown" };
|
|
68
|
+
};
|
|
@@ -22,6 +22,7 @@ import { renderToString } from "react-dom/server";
|
|
|
22
22
|
import {
|
|
23
23
|
starredThreadsQueryKey,
|
|
24
24
|
useStarredThreads,
|
|
25
|
+
useStarredUnreadCount,
|
|
25
26
|
} from "./useStarredThreads.js";
|
|
26
27
|
|
|
27
28
|
interface Page {
|
|
@@ -141,3 +142,78 @@ describe("useStarredThreads", () => {
|
|
|
141
142
|
assert.deepEqual(threads, []);
|
|
142
143
|
});
|
|
143
144
|
});
|
|
145
|
+
|
|
146
|
+
// #308: the chips were a pass over the pages loaded so far, and the header
|
|
147
|
+
// count was their length. Both are the server's answer now.
|
|
148
|
+
describe("starred criteria", () => {
|
|
149
|
+
test("carries the chips as query parameters", () => {
|
|
150
|
+
const [params] = starredThreadsQueryKey({
|
|
151
|
+
category: ["social"],
|
|
152
|
+
unread: true,
|
|
153
|
+
attachments: true,
|
|
154
|
+
}) as [
|
|
155
|
+
{
|
|
156
|
+
query?: {
|
|
157
|
+
starred?: boolean;
|
|
158
|
+
category?: string[];
|
|
159
|
+
unread?: boolean;
|
|
160
|
+
attachments?: boolean;
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
assert.equal(params.query?.starred, true);
|
|
165
|
+
assert.deepEqual(params.query?.category, ["social"]);
|
|
166
|
+
assert.equal(params.query?.unread, true);
|
|
167
|
+
assert.equal(params.query?.attachments, true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an unfiltered listing asks for exactly what it asked for before", () => {
|
|
171
|
+
assert.deepEqual(starredThreadsQueryKey({}), starredThreadsQueryKey());
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("useStarredUnreadCount", () => {
|
|
176
|
+
function renderCount(seed?: { count?: number }): number | undefined {
|
|
177
|
+
const client = new QueryClient({
|
|
178
|
+
defaultOptions: { queries: { retry: false } },
|
|
179
|
+
});
|
|
180
|
+
if (seed) {
|
|
181
|
+
client.setQueryData(
|
|
182
|
+
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
183
|
+
query: {
|
|
184
|
+
starred: true,
|
|
185
|
+
order: "desc",
|
|
186
|
+
unread: true,
|
|
187
|
+
count: true,
|
|
188
|
+
results: false,
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
seed,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let captured: number | undefined;
|
|
196
|
+
function Capture() {
|
|
197
|
+
captured = useStarredUnreadCount();
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
renderToString(
|
|
201
|
+
createElement(
|
|
202
|
+
QueryClientProvider,
|
|
203
|
+
{ client },
|
|
204
|
+
createElement(Capture),
|
|
205
|
+
) as never,
|
|
206
|
+
);
|
|
207
|
+
return captured;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("reports the server's count", () => {
|
|
211
|
+
assert.equal(renderCount({ count: 137 }), 137);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// A count the server has not answered is no number at all. A page length
|
|
215
|
+
// dressed as a total is what this replaces.
|
|
216
|
+
test("reports nothing when the server has not answered", () => {
|
|
217
|
+
assert.equal(renderCount(), undefined);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -8,20 +8,43 @@
|
|
|
8
8
|
* selection from any other listing reintroduces issue #70 — the INBOX-scoped
|
|
9
9
|
* unified listing cannot see a starred thread filed elsewhere, so its row is
|
|
10
10
|
* clickable but resolves to nothing and no reading pane opens.
|
|
11
|
+
*
|
|
12
|
+
* The filters travel as query parameters rather than as a pass over the pages
|
|
13
|
+
* loaded so far. A starred collection is paged, so a category whose mail sits
|
|
14
|
+
* below the newest page rendered an empty list however much of it the
|
|
15
|
+
* collection held (#308); the server answers the predicate over the whole
|
|
16
|
+
* collection instead, and a page comes back full of matches.
|
|
11
17
|
*/
|
|
12
18
|
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
19
|
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
14
20
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
-
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
21
|
+
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
16
22
|
import { useMemo } from "react";
|
|
23
|
+
import type { InboxFilterParams } from "@/lib/inbox-filters";
|
|
24
|
+
|
|
25
|
+
/** The criteria the Flagged view narrows its listing by, all server-applied. */
|
|
26
|
+
export interface StarredCriteria extends InboxFilterParams {
|
|
27
|
+
/** Free text, matched against subject and From over the whole collection. */
|
|
28
|
+
query?: string;
|
|
29
|
+
/** Page size, set only by the single-page text search. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// `starred` and `order` are the view itself, not a criterion, so they are set
|
|
34
|
+
// last: no chip can widen the listing past starred mail or reorder it.
|
|
35
|
+
const starredQuery = (criteria: StarredCriteria) => ({
|
|
36
|
+
...criteria,
|
|
37
|
+
starred: true as const,
|
|
38
|
+
order: "desc" as const,
|
|
39
|
+
});
|
|
17
40
|
|
|
18
|
-
export const starredThreadsQueryKey = () =>
|
|
41
|
+
export const starredThreadsQueryKey = (criteria: StarredCriteria = {}) =>
|
|
19
42
|
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
20
|
-
query:
|
|
43
|
+
query: starredQuery(criteria),
|
|
21
44
|
});
|
|
22
45
|
|
|
23
46
|
interface StarredThreads {
|
|
24
|
-
/** Every starred thread across the pages loaded so far, newest first. */
|
|
47
|
+
/** Every matching starred thread across the pages loaded so far, newest first. */
|
|
25
48
|
threads: RemitImapThreadMessageResponse[];
|
|
26
49
|
isLoading: boolean;
|
|
27
50
|
isError: boolean;
|
|
@@ -32,7 +55,9 @@ interface StarredThreads {
|
|
|
32
55
|
isFetchingNextPage: boolean;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
export function useStarredThreads(
|
|
58
|
+
export function useStarredThreads(
|
|
59
|
+
criteria: StarredCriteria = {},
|
|
60
|
+
): StarredThreads {
|
|
36
61
|
const {
|
|
37
62
|
data,
|
|
38
63
|
isLoading,
|
|
@@ -43,12 +68,11 @@ export function useStarredThreads(): StarredThreads {
|
|
|
43
68
|
hasNextPage,
|
|
44
69
|
isFetchingNextPage,
|
|
45
70
|
} = useInfiniteQuery({
|
|
46
|
-
queryKey: starredThreadsQueryKey(),
|
|
71
|
+
queryKey: starredThreadsQueryKey(criteria),
|
|
47
72
|
queryFn: async ({ pageParam }) => {
|
|
48
73
|
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
49
74
|
query: {
|
|
50
|
-
|
|
51
|
-
order: "desc",
|
|
75
|
+
...starredQuery(criteria),
|
|
52
76
|
continuationToken: pageParam,
|
|
53
77
|
},
|
|
54
78
|
throwOnError: true,
|
|
@@ -76,3 +100,70 @@ export function useStarredThreads(): StarredThreads {
|
|
|
76
100
|
isFetchingNextPage,
|
|
77
101
|
};
|
|
78
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The server's own text match over the whole starred collection, as one page.
|
|
106
|
+
*
|
|
107
|
+
* The free-text filter has two halves that no single request covers: the server
|
|
108
|
+
* matches subject and From over every starred message, and the client can also
|
|
109
|
+
* match a snippet, but only on rows it has already loaded. The server's set is
|
|
110
|
+
* the filter — it is the half that sees the mail below the newest page — and
|
|
111
|
+
* the snippet pass complements it (#308). Same shape as the daily brief's
|
|
112
|
+
* unscoped search, which merges the same two halves.
|
|
113
|
+
*/
|
|
114
|
+
export function useStarredTextSearch(
|
|
115
|
+
criteria: StarredCriteria,
|
|
116
|
+
limit: number,
|
|
117
|
+
): RemitImapThreadMessageResponse[] {
|
|
118
|
+
const text = criteria.query ?? "";
|
|
119
|
+
const query = { ...starredQuery(criteria), limit };
|
|
120
|
+
const { data } = useQuery({
|
|
121
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
122
|
+
queryFn: async () => {
|
|
123
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
124
|
+
query,
|
|
125
|
+
throwOnError: true,
|
|
126
|
+
});
|
|
127
|
+
return page;
|
|
128
|
+
},
|
|
129
|
+
enabled: text.length > 0,
|
|
130
|
+
staleTime: 30_000,
|
|
131
|
+
});
|
|
132
|
+
return useMemo(() => data?.items ?? [], [data]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* How many of the matching starred messages are unread, counted by the server.
|
|
137
|
+
*
|
|
138
|
+
* Its own request, so no cursor enters the key and pressing "load more" cannot
|
|
139
|
+
* move the number — the count is over the whole collection under the active
|
|
140
|
+
* criteria, which is what the header claims it is. A page-length count grows
|
|
141
|
+
* with every press and reads as a total, which is the defect this replaces
|
|
142
|
+
* (#308). `results: false` reads the count alone: no rows are fetched or
|
|
143
|
+
* enriched to answer it.
|
|
144
|
+
*
|
|
145
|
+
* Undefined until the server answers, and undefined when it cannot. The header
|
|
146
|
+
* shows no number rather than a made-up one.
|
|
147
|
+
*/
|
|
148
|
+
export function useStarredUnreadCount(
|
|
149
|
+
criteria: StarredCriteria = {},
|
|
150
|
+
): number | undefined {
|
|
151
|
+
const query = {
|
|
152
|
+
...starredQuery(criteria),
|
|
153
|
+
unread: true,
|
|
154
|
+
count: true,
|
|
155
|
+
results: false,
|
|
156
|
+
};
|
|
157
|
+
const { data } = useQuery({
|
|
158
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
|
|
159
|
+
queryFn: async () => {
|
|
160
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
161
|
+
query,
|
|
162
|
+
throwOnError: true,
|
|
163
|
+
});
|
|
164
|
+
return page;
|
|
165
|
+
},
|
|
166
|
+
staleTime: 60_000,
|
|
167
|
+
});
|
|
168
|
+
return data?.count;
|
|
169
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Flagged view asks the server for.
|
|
3
|
+
*
|
|
4
|
+
* #308: the chips became query parameters, and the tokens that ask for the same
|
|
5
|
+
* thing have to travel the same way. A token evaluated over the pages loaded so
|
|
6
|
+
* far reintroduces the defect one control down — the field would show an empty
|
|
7
|
+
* list for a category whose starred mail sits below the newest page, while the
|
|
8
|
+
* chip beside it showed the mail.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, test } from "node:test";
|
|
12
|
+
import { flaggedCriteria } from "./flagged-criteria.js";
|
|
13
|
+
import type { InboxFilterCriteria } from "./inbox-filters.js";
|
|
14
|
+
import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
|
|
15
|
+
|
|
16
|
+
const NO_CHIPS: InboxFilterCriteria = {
|
|
17
|
+
category: "all",
|
|
18
|
+
attributes: new Set<string>(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const chips = (
|
|
22
|
+
category: string,
|
|
23
|
+
attributes: string[] = [],
|
|
24
|
+
): InboxFilterCriteria => ({ category, attributes: new Set(attributes) });
|
|
25
|
+
|
|
26
|
+
const tokensOf = (query: string): SearchToken[] =>
|
|
27
|
+
parseSearchTokens(query, {
|
|
28
|
+
accountsByName: new Map([["alice", "acc_1"]]),
|
|
29
|
+
mailboxesByName: new Map([["sent", "mb2"]]),
|
|
30
|
+
}).tokens;
|
|
31
|
+
|
|
32
|
+
const criteriaOf = (query: string, filters = NO_CHIPS) =>
|
|
33
|
+
flaggedCriteria(filters, tokensOf(query)).criteria;
|
|
34
|
+
|
|
35
|
+
const residualTypes = (query: string, filters = NO_CHIPS) =>
|
|
36
|
+
flaggedCriteria(filters, tokensOf(query)).residual.map((t) => t.type);
|
|
37
|
+
|
|
38
|
+
describe("flaggedCriteria", () => {
|
|
39
|
+
test("a typed token asks for exactly what its chip asks for", () => {
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
criteriaOf("is:unread"),
|
|
42
|
+
criteriaOf("", chips("all", ["unread"])),
|
|
43
|
+
);
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
criteriaOf("has:attachment"),
|
|
46
|
+
criteriaOf("", chips("all", ["attachment"])),
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
criteriaOf("category:personal"),
|
|
50
|
+
criteriaOf("", chips("personal")),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("carries the row criteria as parameters", () => {
|
|
55
|
+
assert.deepEqual(criteriaOf("is:unread"), { unread: true });
|
|
56
|
+
assert.deepEqual(criteriaOf("is:read"), { unread: false });
|
|
57
|
+
assert.deepEqual(criteriaOf("has:attachment"), { attachments: true });
|
|
58
|
+
assert.deepEqual(criteriaOf("category:social"), { category: ["social"] });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("leaves nothing carried in the residue", () => {
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
residualTypes("is:unread has:attachment category:personal"),
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The view is starred mail, so the request always carries `starred`. A token
|
|
69
|
+
// asking for what the request already asks for is answered, not re-applied.
|
|
70
|
+
test("answers is:starred from the view itself", () => {
|
|
71
|
+
assert.deepEqual(residualTypes("is:starred"), []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// No `from`/`subject` parameter on this endpoint: its one text parameter
|
|
75
|
+
// matches both at once, so these stay a pass over the returned rows rather
|
|
76
|
+
// than being dropped from a request that never carried them.
|
|
77
|
+
test("keeps the tokens no parameter carries", () => {
|
|
78
|
+
assert.deepEqual(residualTypes("from:alice"), ["from"]);
|
|
79
|
+
assert.deepEqual(residualTypes("before:2026-01-01"), ["before"]);
|
|
80
|
+
assert.deepEqual(residualTypes("in:sent"), ["in"]);
|
|
81
|
+
assert.deepEqual(residualTypes("account:alice"), ["account"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the chip wins over a token that contradicts it", () => {
|
|
85
|
+
const { criteria, residual } = flaggedCriteria(
|
|
86
|
+
chips("all", ["unread"]),
|
|
87
|
+
tokensOf("is:read"),
|
|
88
|
+
);
|
|
89
|
+
assert.equal(criteria.unread, true);
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
residual.map((t) => t.type),
|
|
92
|
+
["isRead"],
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a cleared category sets no category parameter", () => {
|
|
97
|
+
assert.deepEqual(criteriaOf("", NO_CHIPS), {});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Flagged view asks `listAllThreads` for, and what is left over.
|
|
3
|
+
*
|
|
4
|
+
* Every criterion the view offers — the category chip, the Unread and Has
|
|
5
|
+
* attachment chips, and the `category:` / `is:unread` / `is:read` /
|
|
6
|
+
* `has:attachment` / `is:starred` tokens typed in the field — is a column on
|
|
7
|
+
* the ThreadMessage row, so all of them travel as query parameters. A criterion
|
|
8
|
+
* applied over the pages loaded so far is the defect this replaces: a starred
|
|
9
|
+
* collection is paged, so anything below the newest page was invisible to it
|
|
10
|
+
* (#308).
|
|
11
|
+
*
|
|
12
|
+
* `starred` is the view rather than a criterion: the request always carries it,
|
|
13
|
+
* which is what makes `is:starred` a carried token instead of residue.
|
|
14
|
+
*
|
|
15
|
+
* A chip beats a token where both set the same parameter — a control on screen
|
|
16
|
+
* beats text in a field — and the overruled token drops to the residue, where
|
|
17
|
+
* it matches nothing, which is the honest answer to a contradiction.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
InboxFilterCriteria,
|
|
21
|
+
InboxFilterParams,
|
|
22
|
+
} from "./inbox-filters.js";
|
|
23
|
+
import { inboxFilterParams } from "./inbox-filters.js";
|
|
24
|
+
import type { SearchToken } from "./search-tokens.js";
|
|
25
|
+
import {
|
|
26
|
+
type ThreadSearchTokenParamName,
|
|
27
|
+
threadSearchTokens,
|
|
28
|
+
} from "./thread-search-tokens.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The token parameters `listAllThreads` carries. It has no `from` or `subject`
|
|
32
|
+
* of its own — its one text parameter matches both at once — so those two
|
|
33
|
+
* tokens stay residue rather than being dropped from a request that never had
|
|
34
|
+
* them.
|
|
35
|
+
*/
|
|
36
|
+
export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
|
|
37
|
+
"category",
|
|
38
|
+
"unread",
|
|
39
|
+
"starred",
|
|
40
|
+
"attachments",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export interface FlaggedCriteria {
|
|
44
|
+
/** What the request narrows by, chips and carried tokens together. */
|
|
45
|
+
criteria: InboxFilterParams;
|
|
46
|
+
/** The tokens no parameter carries; apply these over the returned rows. */
|
|
47
|
+
residual: SearchToken[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const flaggedCriteria = (
|
|
51
|
+
chips: InboxFilterCriteria,
|
|
52
|
+
tokens: readonly SearchToken[],
|
|
53
|
+
): FlaggedCriteria => {
|
|
54
|
+
const chipParams = inboxFilterParams(chips);
|
|
55
|
+
const { params, residual } = threadSearchTokens(
|
|
56
|
+
tokens,
|
|
57
|
+
{ ...chipParams, starred: true },
|
|
58
|
+
FLAGGED_TOKEN_PARAMS,
|
|
59
|
+
);
|
|
60
|
+
return { criteria: { ...params, ...chipParams }, residual };
|
|
61
|
+
};
|
package/src/lib/inbox-filters.ts
CHANGED
|
@@ -3,20 +3,26 @@ import type {
|
|
|
3
3
|
ThreadOperationsSearchThreadsData,
|
|
4
4
|
} from "@remit/api-http-client/types.gen.ts";
|
|
5
5
|
import { MessageCategory } from "@remit/domain-enums";
|
|
6
|
-
import type
|
|
6
|
+
import { type FilterReach, inboxFilterConfig } from "@remit/ui";
|
|
7
7
|
|
|
8
8
|
/** The request `threadOperationsSearchThreads` takes, whole. */
|
|
9
9
|
export type ThreadSearchQuery = NonNullable<
|
|
10
10
|
ThreadOperationsSearchThreadsData["query"]
|
|
11
11
|
>;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The parameters the filter chips set on a thread listing.
|
|
15
|
+
*
|
|
16
|
+
* Named for the inbox, which had them first, but shared: `listAllThreads` takes
|
|
17
|
+
* the same four (#308), so the Flagged view sends the chips through this rather
|
|
18
|
+
* than growing a second translation of the same chip ids.
|
|
19
|
+
*/
|
|
14
20
|
export type InboxFilterParams = Pick<
|
|
15
21
|
ThreadSearchQuery,
|
|
16
22
|
"category" | "unread" | "starred" | "attachments"
|
|
17
23
|
>;
|
|
18
24
|
|
|
19
|
-
/** The chip state
|
|
25
|
+
/** The chip state a filtered list holds: one category, any number of attributes. */
|
|
20
26
|
export interface InboxFilterCriteria {
|
|
21
27
|
/** A category id, or `"all"` when the category is cleared. */
|
|
22
28
|
category: string;
|
|
@@ -121,3 +127,18 @@ export const sameInboxFilter = (
|
|
|
121
127
|
if (!previous) return false;
|
|
122
128
|
return filterIdentity(previous) === filterIdentity(params);
|
|
123
129
|
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The label the empty state names an active category filter by, or undefined
|
|
133
|
+
* when the id names no category — `"all"` is how the category is cleared, not a
|
|
134
|
+
* category. Read off the preset so the chip and the sentence about it can never
|
|
135
|
+
* disagree; every list preset draws its categories from the same set.
|
|
136
|
+
*/
|
|
137
|
+
const CATEGORY_LABELS = new Map(
|
|
138
|
+
inboxFilterConfig()
|
|
139
|
+
.categories.filter((category) => category.id !== "all")
|
|
140
|
+
.map((category) => [category.id, category.label]),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export const categoryLabel = (id: string): string | undefined =>
|
|
144
|
+
CATEGORY_LABELS.get(id);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exact match count is opt-in because it is a second read over the whole
|
|
3
|
+
* match set (#307). These pin who pays for it and what happens when the server
|
|
4
|
+
* declines to answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, test } from "node:test";
|
|
9
|
+
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
10
|
+
import {
|
|
11
|
+
type ResultCountRequestInput,
|
|
12
|
+
shouldRequestResultCount,
|
|
13
|
+
toResultCount,
|
|
14
|
+
} from "./result-count.js";
|
|
15
|
+
|
|
16
|
+
const request = (input: Partial<ResultCountRequestInput>): boolean =>
|
|
17
|
+
shouldRequestResultCount({
|
|
18
|
+
hasSearchQuery: true,
|
|
19
|
+
freeText: "",
|
|
20
|
+
residualTokenCount: 0,
|
|
21
|
+
...input,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("shouldRequestResultCount", () => {
|
|
25
|
+
test("browsing a mailbox asks for no count", () => {
|
|
26
|
+
assert.equal(request({ hasSearchQuery: false }), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("a free-text query is not counted while it is being typed", () => {
|
|
30
|
+
for (const freeText of ["k", "ku", " ku "]) {
|
|
31
|
+
assert.equal(
|
|
32
|
+
request({ freeText }),
|
|
33
|
+
false,
|
|
34
|
+
`"${freeText}" was counted per keystroke`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a free-text query long enough for the index is counted", () => {
|
|
40
|
+
assert.equal(request({ freeText: "kub" }), true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a query made only of filter tokens is counted straight away", () => {
|
|
44
|
+
assert.equal(request({}), true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `before:`/`after:`/`account:` reach the request as nothing, so the server
|
|
49
|
+
* would count a wider set than the list shows — and `invoice before:2020`
|
|
50
|
+
* sends criteria identical to `invoice`, which is also the previous search's
|
|
51
|
+
* cache key.
|
|
52
|
+
*/
|
|
53
|
+
test("a term the request could not carry stops the count", () => {
|
|
54
|
+
assert.equal(
|
|
55
|
+
request({ freeText: "invoice", residualTokenCount: 1 }),
|
|
56
|
+
false,
|
|
57
|
+
"the count covered rows the list filters back out",
|
|
58
|
+
);
|
|
59
|
+
assert.equal(
|
|
60
|
+
request({ freeText: "invoice", residualTokenCount: 0 }),
|
|
61
|
+
true,
|
|
62
|
+
"the same search without the residual term must still be counted",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("toResultCount", () => {
|
|
68
|
+
test("the server's number is the whole match set", () => {
|
|
69
|
+
assert.deepEqual(toResultCount(1284), { kind: "exact", value: 1284 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("zero matches is a number, not an absent count", () => {
|
|
73
|
+
assert.deepEqual(toResultCount(0), { kind: "exact", value: 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("an absent count stays absent rather than becoming zero", () => {
|
|
77
|
+
assert.deepEqual(toResultCount(undefined), { kind: "unknown" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `searchThreads` withholds the count when the criteria carry an off-row term
|
|
83
|
+
* (#305), and the escalated selection reads `count` as a required number to size
|
|
84
|
+
* its run. Neither term can reach that predicate today; this fails the day one
|
|
85
|
+
* is added, so the absent-count state gets designed rather than surfacing as a
|
|
86
|
+
* run that refuses to start.
|
|
87
|
+
*/
|
|
88
|
+
describe("the escalation predicate carries no off-row criterion", () => {
|
|
89
|
+
test("senderTrust and dkimMismatch are not part of it", () => {
|
|
90
|
+
type OffRow = Extract<
|
|
91
|
+
keyof EscalationSearchQuery,
|
|
92
|
+
"senderTrust" | "dkimMismatch"
|
|
93
|
+
>;
|
|
94
|
+
const offRow: [OffRow] extends [never] ? true : false = true;
|
|
95
|
+
assert.equal(offRow, true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ResultCount } from "@remit/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shortest free text a count is asked for. `npm-scripts/sqlite-search-index.sql`
|
|
5
|
+
* documents that a query under three characters falls out of the trigram index
|
|
6
|
+
* onto a folded `LIKE` scan, so counting one is a mailbox scan per typed
|
|
7
|
+
* character. The list itself still pages — only the count waits.
|
|
8
|
+
*/
|
|
9
|
+
export const MIN_COUNTED_QUERY_LENGTH = 3;
|
|
10
|
+
|
|
11
|
+
export interface ResultCountRequestInput {
|
|
12
|
+
/** True while the list is answering a search rather than plain browsing. */
|
|
13
|
+
hasSearchQuery: boolean;
|
|
14
|
+
/** The literal text of the search, with its filter tokens already taken out. */
|
|
15
|
+
freeText: string;
|
|
16
|
+
/**
|
|
17
|
+
* Tokens the request could not carry — `before:`, `after:`, `account:`, and
|
|
18
|
+
* any second value for a parameter that takes one — applied over the rows
|
|
19
|
+
* after they arrive.
|
|
20
|
+
*/
|
|
21
|
+
residualTokenCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the exact match count is worth one request against these criteria.
|
|
26
|
+
*
|
|
27
|
+
* Chip- and token-driven criteria are counted straight away: they change once
|
|
28
|
+
* per click, and the predicate is one the index answers. Free text is counted
|
|
29
|
+
* only once it is long enough to be indexed, which is what keeps a per-character
|
|
30
|
+
* query from paying for a count it will throw away on the next keystroke.
|
|
31
|
+
*
|
|
32
|
+
* A residual token stops the count outright. The server never saw that term, so
|
|
33
|
+
* it would count a wider set than the list shows — and two searches differing
|
|
34
|
+
* only by one reach the request as the same criteria, so the answer would also
|
|
35
|
+
* be the one the previous search left in the cache.
|
|
36
|
+
*/
|
|
37
|
+
export const shouldRequestResultCount = ({
|
|
38
|
+
hasSearchQuery,
|
|
39
|
+
freeText,
|
|
40
|
+
residualTokenCount,
|
|
41
|
+
}: ResultCountRequestInput): boolean => {
|
|
42
|
+
if (!hasSearchQuery) return false;
|
|
43
|
+
if (residualTokenCount > 0) return false;
|
|
44
|
+
const typed = freeText.trim();
|
|
45
|
+
return typed.length === 0 || typed.length >= MIN_COUNTED_QUERY_LENGTH;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The server's answer, or no number.
|
|
50
|
+
*
|
|
51
|
+
* The only input is `ThreadSearchResponse.count`, which is the whole match set
|
|
52
|
+
* or absent (#305) — never a page length. Absent stays absent: a count the
|
|
53
|
+
* server declined to compute is not zero, and the surface renders nothing
|
|
54
|
+
* rather than a figure it made up.
|
|
55
|
+
*/
|
|
56
|
+
export const toResultCount = (count: number | undefined): ResultCount =>
|
|
57
|
+
count === undefined ? { kind: "unknown" } : { kind: "exact", value: count };
|
package/src/lib/starred-rows.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Collapse rows that are the same conversation.
|
|
5
3
|
*
|
|
@@ -12,12 +10,19 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
12
10
|
* conversation single even when its copies straddle a page boundary — a single
|
|
13
11
|
* page cannot know what earlier pages already showed. The first row wins, which
|
|
14
12
|
* is the newest under the server's descending order.
|
|
13
|
+
*
|
|
14
|
+
* Generic over anything naming its thread, so the same collapse runs over the
|
|
15
|
+
* API rows and over the merged display rows a text search produces — two
|
|
16
|
+
* definitions of "same conversation" is one too many. A row that names no
|
|
17
|
+
* thread is never merged: there is nothing to say it is the same conversation
|
|
18
|
+
* as any other.
|
|
15
19
|
*/
|
|
16
|
-
export const dedupeByThread = (
|
|
17
|
-
items:
|
|
18
|
-
):
|
|
20
|
+
export const dedupeByThread = <T extends { threadId?: string }>(
|
|
21
|
+
items: T[],
|
|
22
|
+
): T[] => {
|
|
19
23
|
const seen = new Set<string>();
|
|
20
24
|
return items.filter((item) => {
|
|
25
|
+
if (item.threadId === undefined) return true;
|
|
21
26
|
if (seen.has(item.threadId)) return false;
|
|
22
27
|
seen.add(item.threadId);
|
|
23
28
|
return true;
|
|
@@ -33,6 +33,19 @@ export type ThreadSearchTokenParams = Pick<
|
|
|
33
33
|
"from" | "subject" | "category" | "unread" | "starred" | "attachments"
|
|
34
34
|
>;
|
|
35
35
|
|
|
36
|
+
/** A parameter name a listing endpoint accepts. */
|
|
37
|
+
export type ThreadSearchTokenParamName = keyof ThreadSearchTokenParams;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every parameter `searchThreads` takes, the default set.
|
|
41
|
+
*
|
|
42
|
+
* `listAllThreads` takes all of these except `from` and `subject`, so a caller
|
|
43
|
+
* on that endpoint names its own set and the two tokens come back as residue
|
|
44
|
+
* rather than being silently dropped from a request that never carried them.
|
|
45
|
+
*/
|
|
46
|
+
export const THREAD_SEARCH_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] =
|
|
47
|
+
["from", "subject", "category", "unread", "starred", "attachments"];
|
|
48
|
+
|
|
36
49
|
export interface ThreadSearchTokens {
|
|
37
50
|
/** What the tokens add to the request. */
|
|
38
51
|
params: ThreadSearchTokenParams;
|
|
@@ -106,17 +119,32 @@ const isCarried = (
|
|
|
106
119
|
}
|
|
107
120
|
};
|
|
108
121
|
|
|
122
|
+
const narrowTo = (
|
|
123
|
+
params: ThreadSearchTokenParams,
|
|
124
|
+
carries: readonly ThreadSearchTokenParamName[],
|
|
125
|
+
): ThreadSearchTokenParams =>
|
|
126
|
+
Object.fromEntries(
|
|
127
|
+
Object.entries(params).filter(([name]) =>
|
|
128
|
+
carries.includes(name as ThreadSearchTokenParamName),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
109
132
|
/**
|
|
110
|
-
* Split the tokens into the parameters the thread
|
|
133
|
+
* Split the tokens into the parameters the thread listing takes and the residue
|
|
111
134
|
* the caller has to apply itself. `chipParams` are the parameters already set
|
|
112
|
-
* by the
|
|
135
|
+
* by the filter chips, which win where they overlap; `carries` names the
|
|
136
|
+
* parameters the endpoint being called actually accepts.
|
|
113
137
|
*/
|
|
114
138
|
export function threadSearchTokens(
|
|
115
139
|
tokens: readonly SearchToken[],
|
|
116
140
|
chipParams: InboxFilterParams = {},
|
|
141
|
+
carries: readonly ThreadSearchTokenParamName[] = THREAD_SEARCH_TOKEN_PARAMS,
|
|
117
142
|
): ThreadSearchTokens {
|
|
118
|
-
const params = paramsFromTokens(tokens);
|
|
119
|
-
const request: ThreadSearchTokenParams = {
|
|
143
|
+
const params = narrowTo(paramsFromTokens(tokens), carries);
|
|
144
|
+
const request: ThreadSearchTokenParams = {
|
|
145
|
+
...params,
|
|
146
|
+
...narrowTo(chipParams, carries),
|
|
147
|
+
};
|
|
120
148
|
return {
|
|
121
149
|
params,
|
|
122
150
|
residual: tokens.filter((token) => !isCarried(request, token)),
|