@remit/web-client 0.0.86 → 0.0.88
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 +157 -38
- 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 +5 -6
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +10 -6
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useOrganizeWiden.ts +1 -2
- 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 +89 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +117 -38
- package/src/lib/organize/search-to-rule.test.ts +8 -28
- package/src/lib/organize/search-to-rule.ts +33 -89
- package/src/lib/organize/sender-fallback.test.ts +1 -97
- package/src/lib/organize/sender-fallback.ts +8 -71
- 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
|
@@ -35,6 +35,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
|
|
|
35
35
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
36
36
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
37
37
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
38
|
+
import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
|
|
38
39
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
39
40
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
40
41
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
@@ -53,7 +54,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
53
54
|
interface FlaggedPaneContextValue {
|
|
54
55
|
selectedMessageId: string | undefined;
|
|
55
56
|
selectedThread: RemitImapThreadMessageResponse | undefined;
|
|
56
|
-
onSelectMessage: (id: string) => void;
|
|
57
|
+
onSelectMessage: (id: string, options?: OpenMessageOptions) => void;
|
|
57
58
|
onCloseThread: () => void;
|
|
58
59
|
/**
|
|
59
60
|
* Toolbar verbs for the open thread, keyed by the thread's own mailbox and
|
|
@@ -99,10 +100,11 @@ function FlaggedPaneProvider({
|
|
|
99
100
|
}, [threads, selectedMessageId]);
|
|
100
101
|
|
|
101
102
|
const handleSelectMessage = useCallback(
|
|
102
|
-
(id: string) => {
|
|
103
|
+
(id: string, options?: OpenMessageOptions) => {
|
|
103
104
|
navigate({
|
|
104
105
|
to: "/mail/flagged",
|
|
105
106
|
search: (prev) => ({ ...prev, selectedMessageId: id }),
|
|
107
|
+
replace: options?.replace,
|
|
106
108
|
});
|
|
107
109
|
},
|
|
108
110
|
[navigate],
|
|
@@ -11,8 +11,19 @@
|
|
|
11
11
|
* On phone the magnifier opens a full-screen `MobileSearchView` takeover instead
|
|
12
12
|
* of expanding over the title; tablet and desktop keep the inline header search
|
|
13
13
|
* and, once a query is present, swap the list-pane body to the same kit
|
|
14
|
-
* `SearchResults` sections
|
|
15
|
-
*
|
|
14
|
+
* `SearchResults` sections. Consumers feed the filter chrome and query-narrowed
|
|
15
|
+
* results; both tiers render identical rows.
|
|
16
|
+
*
|
|
17
|
+
* The field offers what the search API can be told: token names for a bare word,
|
|
18
|
+
* that token's values once its name is committed. The list sits under the field
|
|
19
|
+
* on both tiers, in flow, so it takes its own space instead of covering the
|
|
20
|
+
* query it completes.
|
|
21
|
+
*
|
|
22
|
+
* An active query owns the pane. The filter chrome is not rendered while one is
|
|
23
|
+
* present — the two narrow the same list by the same intent and would sit in the
|
|
24
|
+
* same place — and its place is taken by `MakeFilterAction`, which the pane mounts
|
|
25
|
+
* itself so it is there for every search on every view, whichever body is showing
|
|
26
|
+
* the results.
|
|
16
27
|
*
|
|
17
28
|
* Results split into two sections, one per engine: literal/instant and semantic.
|
|
18
29
|
* The consumer dedupes them — a thread in both appears only under the literal
|
|
@@ -28,28 +39,40 @@
|
|
|
28
39
|
* `resultsScopeForRoute` and `lib/spam-offer.ts`.
|
|
29
40
|
*/
|
|
30
41
|
import {
|
|
31
|
-
FilterSheet,
|
|
32
42
|
type FilterSheetProps,
|
|
43
|
+
isConvertible,
|
|
33
44
|
MailHeader,
|
|
45
|
+
MakeFilterAction,
|
|
34
46
|
MobileSearchView,
|
|
47
|
+
type SearchCaretRequest,
|
|
48
|
+
type SearchFieldSuggest,
|
|
35
49
|
type SearchResult,
|
|
36
50
|
type SearchResultSection,
|
|
37
51
|
SearchResults,
|
|
38
52
|
type SearchScope,
|
|
53
|
+
type Suggestion,
|
|
54
|
+
SuggestList,
|
|
39
55
|
useAppShellLayout,
|
|
56
|
+
useSuggestList,
|
|
40
57
|
} from "@remit/ui";
|
|
41
58
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
42
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
type ReactNode,
|
|
61
|
+
useCallback,
|
|
62
|
+
useEffect,
|
|
63
|
+
useMemo,
|
|
64
|
+
useRef,
|
|
65
|
+
useState,
|
|
66
|
+
} from "react";
|
|
43
67
|
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
44
68
|
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
69
|
+
import { useSearchSuggestions } from "@/hooks/useSearchSuggestions";
|
|
45
70
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
46
71
|
import { useMailContext } from "@/lib/mail-context";
|
|
47
|
-
import {
|
|
48
|
-
convertSearchToRule,
|
|
49
|
-
isConvertible,
|
|
50
|
-
} from "@/lib/organize/search-to-rule";
|
|
72
|
+
import { convertSearchToRule } from "@/lib/organize/search-to-rule";
|
|
51
73
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
52
74
|
import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
|
|
75
|
+
import { applySearchSuggestion } from "@/lib/search-suggestions";
|
|
53
76
|
import { showInlineSearchResults } from "@/lib/search-surface";
|
|
54
77
|
import {
|
|
55
78
|
parseSearchTokens,
|
|
@@ -59,11 +82,22 @@ import {
|
|
|
59
82
|
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
60
83
|
import { SearchFilterDialog } from "./organize/SearchFilterDialog";
|
|
61
84
|
|
|
62
|
-
interface MailListHeaderProps {
|
|
85
|
+
export interface MailListHeaderProps {
|
|
63
86
|
title: string;
|
|
64
87
|
unreadCount: number;
|
|
65
88
|
/** The list body (filter sheet / sections / virtualized rows). */
|
|
66
89
|
children: ReactNode;
|
|
90
|
+
/**
|
|
91
|
+
* Replaces the header for as long as a selection is active — the same slot
|
|
92
|
+
* contract the kit `MessageListPane` gives the mailbox list, so every list
|
|
93
|
+
* puts its bulk-action bar in the same place.
|
|
94
|
+
*/
|
|
95
|
+
selectionBar?: ReactNode;
|
|
96
|
+
/**
|
|
97
|
+
* Overlay anchored to the bottom of the pane (the mobile selection sheet).
|
|
98
|
+
* The pane is the positioned ancestor the sheet measures against.
|
|
99
|
+
*/
|
|
100
|
+
selectionSheet?: ReactNode;
|
|
67
101
|
/** Pinned below the scrollable list (e.g. the keyboard hint bar). */
|
|
68
102
|
footer?: ReactNode;
|
|
69
103
|
/** Filter chrome for the phone search takeover. Omit to drop the filter row. */
|
|
@@ -99,6 +133,8 @@ export function MailListHeader({
|
|
|
99
133
|
title,
|
|
100
134
|
unreadCount,
|
|
101
135
|
children,
|
|
136
|
+
selectionBar,
|
|
137
|
+
selectionSheet,
|
|
102
138
|
footer,
|
|
103
139
|
searchFilter,
|
|
104
140
|
searchResults,
|
|
@@ -137,6 +173,68 @@ export function MailListHeader({
|
|
|
137
173
|
setSearchOpen(false);
|
|
138
174
|
}, [searchViewKey]);
|
|
139
175
|
|
|
176
|
+
// What the box offers for the term being typed. The field says where the
|
|
177
|
+
// caret is; the caret decides whether the offer is a token name or one
|
|
178
|
+
// token's values, and picking one replaces that term and leaves the caret
|
|
179
|
+
// ready for the next. Typing always wins — the list only ever inserts, and a
|
|
180
|
+
// term it has nothing for leaves the field the plain text box it is.
|
|
181
|
+
//
|
|
182
|
+
// An empty field belongs to the recent searches and the filter row, as it
|
|
183
|
+
// does everywhere else in this pane, so the offer starts with the first
|
|
184
|
+
// character typed. The caret on the whitespace after a finished token is a
|
|
185
|
+
// term of its own, which is where the whole vocabulary is offered again.
|
|
186
|
+
const [caretPosition, setCaretPosition] = useState(0);
|
|
187
|
+
const [fieldFocused, setFieldFocused] = useState(false);
|
|
188
|
+
const [caretRequest, setCaretRequest] = useState<
|
|
189
|
+
SearchCaretRequest | undefined
|
|
190
|
+
>(undefined);
|
|
191
|
+
const suggestions = useSearchSuggestions({
|
|
192
|
+
query: searchInput,
|
|
193
|
+
cursor: caretPosition,
|
|
194
|
+
enabled: fieldFocused && searchInput.trim().length > 0,
|
|
195
|
+
});
|
|
196
|
+
const applySuggestion = useCallback(
|
|
197
|
+
(suggestion: Suggestion) => {
|
|
198
|
+
const applied = applySearchSuggestion(
|
|
199
|
+
searchInput,
|
|
200
|
+
caretPosition,
|
|
201
|
+
suggestion,
|
|
202
|
+
);
|
|
203
|
+
onSearchChange(applied.query);
|
|
204
|
+
setCaretRequest({ cursor: applied.cursor });
|
|
205
|
+
},
|
|
206
|
+
[searchInput, caretPosition, onSearchChange],
|
|
207
|
+
);
|
|
208
|
+
const suggest = useSuggestList({
|
|
209
|
+
count: suggestions.length,
|
|
210
|
+
onAccept: (index) => {
|
|
211
|
+
const suggestion = suggestions[index];
|
|
212
|
+
if (suggestion) applySuggestion(suggestion);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
const searchSuggest: SearchFieldSuggest = {
|
|
216
|
+
comboboxProps: suggest.comboboxProps,
|
|
217
|
+
onKeyDown: suggest.handleKeyDown,
|
|
218
|
+
onCaretChange: setCaretPosition,
|
|
219
|
+
onFocusChange: setFieldFocused,
|
|
220
|
+
...(caretRequest ? { caret: caretRequest } : {}),
|
|
221
|
+
};
|
|
222
|
+
// Under the field and in flow, on both tiers: a phone's soft keyboard owns
|
|
223
|
+
// the lower half of the screen, and a list floating over the field would
|
|
224
|
+
// cover the query it is completing.
|
|
225
|
+
const suggestList = suggest.open ? (
|
|
226
|
+
<SuggestList
|
|
227
|
+
id={suggest.listId}
|
|
228
|
+
suggestions={suggestions}
|
|
229
|
+
activeIndex={suggest.activeIndex}
|
|
230
|
+
optionId={suggest.optionId}
|
|
231
|
+
onPick={applySuggestion}
|
|
232
|
+
onHighlight={suggest.setActiveIndex}
|
|
233
|
+
label="Search suggestions"
|
|
234
|
+
className="mx-row-inset mt-1 shrink-0"
|
|
235
|
+
/>
|
|
236
|
+
) : null;
|
|
237
|
+
|
|
140
238
|
const hasQuery = searchInput.trim().length > 0;
|
|
141
239
|
// Filter tokens (`from:`, `has:attachment`, `account:`, …) parsed live from
|
|
142
240
|
// the typed query render as removable chips above the sections; removing one
|
|
@@ -212,9 +310,19 @@ export function MailListHeader({
|
|
|
212
310
|
// an `account:` facet names, else the primary account. The literal filter
|
|
213
311
|
// cannot reproduce the search's semantic reach, so the conversion states it
|
|
214
312
|
// whenever the search surfaced a "Related" section — a direct signal, read
|
|
215
|
-
// here from the semantic results, never a capability probe.
|
|
216
|
-
//
|
|
217
|
-
//
|
|
313
|
+
// here from the semantic results, never a capability probe. Disabled with a
|
|
314
|
+
// reason when the search has no clause to filter on (only non-clause facets,
|
|
315
|
+
// or a bare folder scope).
|
|
316
|
+
//
|
|
317
|
+
// It belongs to the search, not to any one way of showing it. The affordance
|
|
318
|
+
// therefore sits in the pane, above whichever body is up — the read-only
|
|
319
|
+
// results panel, or a list whose own rows narrow to the committed query — and
|
|
320
|
+
// is mounted on the same condition as the query itself. Rendering it inside the
|
|
321
|
+
// results panel made it a mailbox's flash: the panel shows while the query is
|
|
322
|
+
// being typed and hands back to `MessageList` the moment the query commits to
|
|
323
|
+
// the URL, taking the affordance down with it a few hundred milliseconds after
|
|
324
|
+
// it appeared, and leaving the brief (which keeps the panel for any query) as
|
|
325
|
+
// the only place it survived.
|
|
218
326
|
const accountToken = parsed.tokens.find((token) => token.type === "account");
|
|
219
327
|
const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
|
|
220
328
|
const searchHadSemanticReach = related.length > 0;
|
|
@@ -229,6 +337,10 @@ export function MailListHeader({
|
|
|
229
337
|
: "Add a sender or words to filter on",
|
|
230
338
|
}
|
|
231
339
|
: undefined;
|
|
340
|
+
// A selection replaces the header with its bulk-action bar and owns the pane;
|
|
341
|
+
// the search affordance stands down until the selection clears.
|
|
342
|
+
const makeFilterAction =
|
|
343
|
+
makeFilter && !selectionBar ? <MakeFilterAction {...makeFilter} /> : null;
|
|
232
344
|
const filterDialog =
|
|
233
345
|
filterOpen && targetAccountId ? (
|
|
234
346
|
<SearchFilterDialog
|
|
@@ -265,6 +377,8 @@ export function MailListHeader({
|
|
|
265
377
|
onSelectResult={handleSelectResult}
|
|
266
378
|
tokens={tokenChips}
|
|
267
379
|
scope={resultsScope}
|
|
380
|
+
suggest={searchSuggest}
|
|
381
|
+
suggestList={suggestList}
|
|
268
382
|
/>
|
|
269
383
|
{filterDialog}
|
|
270
384
|
</>
|
|
@@ -296,41 +410,46 @@ export function MailListHeader({
|
|
|
296
410
|
onSelectResult={handleSelectInlineResult}
|
|
297
411
|
tokens={tokenChips}
|
|
298
412
|
scope={resultsScope}
|
|
299
|
-
makeFilter={makeFilter}
|
|
300
413
|
/>
|
|
301
414
|
);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<FilterSheet {...searchFilter}>{results}</FilterSheet>
|
|
306
|
-
) : (
|
|
415
|
+
// The filter chrome is not rendered while a query is active — see `makeFilter`
|
|
416
|
+
// above — so the results panel gets the plain scroll container either way.
|
|
417
|
+
const body = showInlineResults ? (
|
|
307
418
|
<div className="h-full overflow-y-auto">{results}</div>
|
|
419
|
+
) : (
|
|
420
|
+
children
|
|
308
421
|
);
|
|
309
422
|
|
|
310
423
|
return (
|
|
311
|
-
<section className="flex h-full w-full flex-col bg-surface">
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
424
|
+
<section className="relative flex h-full w-full flex-col bg-surface">
|
|
425
|
+
{selectionBar ?? (
|
|
426
|
+
<MailHeader
|
|
427
|
+
title={title}
|
|
428
|
+
unreadCount={unreadCount}
|
|
429
|
+
// Desktop mounts the app top bar, which owns search for the whole
|
|
430
|
+
// shell — the list header shows no field there, so the page never
|
|
431
|
+
// has two search inputs competing for "/" and for focus. Below
|
|
432
|
+
// desktop the header keeps a compact magnifier: on phone it opens
|
|
433
|
+
// the full-screen takeover above, on tablet it expands over the
|
|
434
|
+
// title. `isSinglePaneTier` is the same predicate the shell gates
|
|
435
|
+
// the top bar on, so the two cannot drift into zero or two fields.
|
|
436
|
+
isDesktop={false}
|
|
437
|
+
showSearch={isSinglePaneTier(tier)}
|
|
438
|
+
onMenuClick={() => layout?.openNav()}
|
|
439
|
+
searchValue={searchInput}
|
|
440
|
+
onSearchChange={onSearchChange}
|
|
441
|
+
onSearchClear={onSearchClear}
|
|
442
|
+
searchOpen={searchOpen}
|
|
443
|
+
onSearchOpenChange={setSearchOpen}
|
|
444
|
+
searchSuggest={searchSuggest}
|
|
445
|
+
/>
|
|
446
|
+
)}
|
|
447
|
+
{suggestList}
|
|
448
|
+
{makeFilterAction}
|
|
331
449
|
<div className="min-h-0 flex-1">{body}</div>
|
|
332
450
|
{footer}
|
|
333
451
|
{filterDialog}
|
|
452
|
+
{selectionSheet}
|
|
334
453
|
</section>
|
|
335
454
|
);
|
|
336
455
|
}
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* category / attribute / source selection state. The same filter config feeds
|
|
8
8
|
* the phone search takeover (via `MailListHeader`), so filters carry across.
|
|
9
9
|
*
|
|
10
|
+
* An active search takes the sheet down. Filtering and searching narrow the same
|
|
11
|
+
* list by the same intent, and the sheet and the search's "Make this a filter"
|
|
12
|
+
* affordance want the same row above the list, so only one of them is up at a
|
|
13
|
+
* time. The selection state is held here and survives, so clearing the query
|
|
14
|
+
* restores the sheet exactly as it was.
|
|
15
|
+
*
|
|
10
16
|
* The daily brief no longer uses this: it composes `MailListHeader` with the kit
|
|
11
17
|
* `BriefSections`, which owns its own filter row (so there is exactly one filter
|
|
12
18
|
* surface and the section headers flatten correctly when filtered).
|
|
@@ -18,6 +24,7 @@ import {
|
|
|
18
24
|
type SearchResult,
|
|
19
25
|
} from "@remit/ui";
|
|
20
26
|
import { type ReactNode, useState } from "react";
|
|
27
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
21
28
|
import { MailListHeader } from "./MailListHeader";
|
|
22
29
|
|
|
23
30
|
interface MailViewChromeProps {
|
|
@@ -74,6 +81,12 @@ export function MailViewChrome({
|
|
|
74
81
|
searchResultsInBody,
|
|
75
82
|
}: MailViewChromeProps) {
|
|
76
83
|
const [expanded, setExpanded] = useState(false);
|
|
84
|
+
// A query owns the pane: the filter chrome and the search's own affordance
|
|
85
|
+
// narrow the same list from the same place, so the filter sheet stands down
|
|
86
|
+
// for as long as something is being searched. Its state survives — clearing
|
|
87
|
+
// the query brings the sheet back with the same category and toggles.
|
|
88
|
+
const { searchInput } = useMailContext();
|
|
89
|
+
const searching = searchInput.trim().length > 0;
|
|
77
90
|
|
|
78
91
|
const filterConfig: Omit<FilterSheetProps, "children"> = {
|
|
79
92
|
categories: preset.categories,
|
|
@@ -104,7 +117,11 @@ export function MailViewChrome({
|
|
|
104
117
|
relatedResultsLabel={relatedResultsLabel}
|
|
105
118
|
searchResultsInBody={searchResultsInBody}
|
|
106
119
|
>
|
|
107
|
-
|
|
120
|
+
{searching ? (
|
|
121
|
+
<div className="h-full overflow-y-auto">{children}</div>
|
|
122
|
+
) : (
|
|
123
|
+
<FilterSheet {...filterConfig}>{children}</FilterSheet>
|
|
124
|
+
)}
|
|
108
125
|
</MailListHeader>
|
|
109
126
|
);
|
|
110
127
|
}
|
|
@@ -104,6 +104,7 @@ import {
|
|
|
104
104
|
filterReach,
|
|
105
105
|
hasInboxFilter,
|
|
106
106
|
type InboxFilterCriteria,
|
|
107
|
+
type InboxFilterParams,
|
|
107
108
|
inboxFilterParams,
|
|
108
109
|
sameInboxFilter,
|
|
109
110
|
} from "@/lib/inbox-filters";
|
|
@@ -123,6 +124,10 @@ import {
|
|
|
123
124
|
} from "@/lib/search-result";
|
|
124
125
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
125
126
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
127
|
+
import {
|
|
128
|
+
applyResidualTokens,
|
|
129
|
+
threadSearchTokens,
|
|
130
|
+
} from "@/lib/thread-search-tokens";
|
|
126
131
|
import { MailViewChrome } from "./MailViewChrome";
|
|
127
132
|
|
|
128
133
|
/* ------------------------------------------------------------------ */
|
|
@@ -260,17 +265,19 @@ function MailboxPaneProvider({
|
|
|
260
265
|
|
|
261
266
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
262
267
|
const hasSearchQuery = normalizedSearchQuery.length > 0;
|
|
263
|
-
// Filter tokens
|
|
264
|
-
//
|
|
265
|
-
// `
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
268
|
+
// Filter tokens narrow this literal search to the params
|
|
269
|
+
// `threadOperationsSearchThreads` supports (`from`, `subject`, `category`,
|
|
270
|
+
// `unread`, `starred`, `attachments`). What the endpoint has no parameter for
|
|
271
|
+
// — `before:`/`after:`/`account:`, and any second value for a parameter that
|
|
272
|
+
// takes one — comes back as residue and is applied over the returned rows
|
|
273
|
+
// below, because a token neither sent nor applied silently widens the result
|
|
274
|
+
// to mail the user asked to exclude. `in:` never reaches here at all: this
|
|
275
|
+
// view is scoped to one mailbox by its route, so `useSearchTokenContext` does
|
|
276
|
+
// not resolve the term and it stays free text.
|
|
269
277
|
const { freeText, tokens: searchTokens } = parseSearchTokens(
|
|
270
278
|
normalizedSearchQuery,
|
|
271
279
|
tokenContext,
|
|
272
280
|
);
|
|
273
|
-
const fromToken = searchTokens.find((t) => t.type === "from");
|
|
274
281
|
|
|
275
282
|
const [filterCategory, setFilterCategory] = useState("all");
|
|
276
283
|
const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
|
|
@@ -291,21 +298,30 @@ function MailboxPaneProvider({
|
|
|
291
298
|
// active chip routes the listing through `searchThreads` — one predicate, one
|
|
292
299
|
// query key, so the key and the branch below cannot diverge.
|
|
293
300
|
const hasServerFilter = hasSearchQuery || hasInboxFilter(filterCriteria);
|
|
301
|
+
// The chips are spread last: where a chip and a token set the same parameter
|
|
302
|
+
// the visible control decides, and the token drops to the residue.
|
|
303
|
+
const { params: tokenParams, residual: residualTokens } = threadSearchTokens(
|
|
304
|
+
searchTokens,
|
|
305
|
+
filterParams,
|
|
306
|
+
);
|
|
294
307
|
const searchThreadsQuery = {
|
|
295
308
|
order: "desc" as const,
|
|
296
309
|
// Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
|
|
297
310
|
// so switching paths without it multiplies the page size by ten.
|
|
298
311
|
limit: THREADS_PAGE_SIZE,
|
|
299
312
|
...(freeText ? { query: freeText } : {}),
|
|
300
|
-
...
|
|
301
|
-
...(searchTokens.some((t) => t.type === "hasAttachment")
|
|
302
|
-
? { attachments: true }
|
|
303
|
-
: {}),
|
|
304
|
-
...(searchTokens.some((t) => t.type === "isUnread")
|
|
305
|
-
? { unread: true }
|
|
306
|
-
: {}),
|
|
313
|
+
...tokenParams,
|
|
307
314
|
...filterParams,
|
|
308
315
|
};
|
|
316
|
+
// What the request actually narrows by, whoever set it. `placeholderData`
|
|
317
|
+
// keeps the previous rows only under the same predicate, and a token
|
|
318
|
+
// narrowing the list is as much that predicate as a chip is.
|
|
319
|
+
const activeFilterParams: InboxFilterParams = {
|
|
320
|
+
category: searchThreadsQuery.category,
|
|
321
|
+
unread: searchThreadsQuery.unread,
|
|
322
|
+
starred: searchThreadsQuery.starred,
|
|
323
|
+
attachments: searchThreadsQuery.attachments,
|
|
324
|
+
};
|
|
309
325
|
|
|
310
326
|
const queryKey = hasServerFilter
|
|
311
327
|
? threadOperationsSearchThreadsQueryKey({
|
|
@@ -354,7 +370,7 @@ function MailboxPaneProvider({
|
|
|
354
370
|
// skeleton rather than showing the old predicate's mail under the new
|
|
355
371
|
// chip for one round trip.
|
|
356
372
|
placeholderData: (previousData, previousQuery) =>
|
|
357
|
-
sameInboxFilter(previousQuery?.queryKey,
|
|
373
|
+
sameInboxFilter(previousQuery?.queryKey, activeFilterParams)
|
|
358
374
|
? previousData
|
|
359
375
|
: undefined,
|
|
360
376
|
});
|
|
@@ -395,10 +411,18 @@ function MailboxPaneProvider({
|
|
|
395
411
|
// The server answered the active predicate, so these rows are the list: the
|
|
396
412
|
// dedupe spans pages and the deleted drop repeats the server's own
|
|
397
413
|
// `excludeDeleted`, and neither result changes when another page loads.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
414
|
+
// Residual tokens are the exception — the request could not carry them, so
|
|
415
|
+
// they are applied here over what came back. That thins a page rather than
|
|
416
|
+
// answering over the whole mailbox, which is why the empty state stops
|
|
417
|
+
// claiming the folder was read and the escalation predicate is withheld.
|
|
418
|
+
const threads = applyResidualTokens(
|
|
419
|
+
dropDeletedThreads(
|
|
420
|
+
dedupeThreadMessages(
|
|
421
|
+
threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
|
|
422
|
+
),
|
|
401
423
|
),
|
|
424
|
+
residualTokens,
|
|
425
|
+
mailboxAccountId,
|
|
402
426
|
);
|
|
403
427
|
|
|
404
428
|
const onSelectFilterCategory = useCallback((id: string) => {
|
|
@@ -424,7 +448,10 @@ function MailboxPaneProvider({
|
|
|
424
448
|
const listFilter: MessageListFilter | undefined = filterLabel
|
|
425
449
|
? {
|
|
426
450
|
label: filterLabel,
|
|
427
|
-
reach:
|
|
451
|
+
reach:
|
|
452
|
+
residualTokens.length > 0
|
|
453
|
+
? "loaded-pages"
|
|
454
|
+
: filterReach(searchThreadsQuery),
|
|
428
455
|
onClear: onClearFilters,
|
|
429
456
|
}
|
|
430
457
|
: undefined;
|
|
@@ -823,7 +850,15 @@ function MailboxPaneProvider({
|
|
|
823
850
|
listFilter,
|
|
824
851
|
intelligenceOpen,
|
|
825
852
|
onToggleIntelligence,
|
|
826
|
-
|
|
853
|
+
// Escalation ("select all N matching") re-issues this predicate server-side
|
|
854
|
+
// and acts on every match, so it is only offered when the predicate IS the
|
|
855
|
+
// search: a residual token narrows the rows on screen but not the request,
|
|
856
|
+
// and a bulk delete over the broader set would reach mail the search
|
|
857
|
+
// excluded.
|
|
858
|
+
searchPredicate:
|
|
859
|
+
hasSearchQuery && residualTokens.length === 0
|
|
860
|
+
? searchThreadsQuery
|
|
861
|
+
: undefined,
|
|
827
862
|
onDeleteMessages: handleDeleteMessages,
|
|
828
863
|
onMoveMessages: handleMoveMessages,
|
|
829
864
|
isDeleting,
|