@remit/web-client 0.0.17 → 0.0.19
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/layout/MailTopBar.tsx +27 -1
- package/src/components/mail/DailyBrief.tsx +4 -2
- package/src/components/mail/FlaggedList.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +45 -27
- package/src/components/mail/MailSidebarAdapter.tsx +11 -2
- package/src/components/mail/MailViewChrome.tsx +7 -0
- package/src/components/mail/MailboxPane.tsx +19 -12
- package/src/components/mail/OutboxPane.tsx +67 -8
- package/src/hooks/useSearchScope.ts +49 -0
- package/src/hooks/useSearchTokenContext.ts +28 -0
- package/src/hooks/useSemanticSearch.ts +13 -9
- package/src/lib/mail-context.ts +8 -1
- package/src/lib/mail-route.ts +17 -0
- package/src/lib/outbox-search.test.ts +75 -0
- package/src/lib/outbox-search.ts +58 -0
- package/src/lib/search-scope.test.ts +104 -0
- package/src/lib/search-scope.ts +114 -0
- package/src/lib/search-view.test.ts +205 -0
- package/src/lib/search-view.ts +54 -0
- package/src/routes/mail.tsx +57 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
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": {
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* The actions here are the ones that belong to the app rather than to whatever
|
|
10
10
|
* is currently listed or open — compose, bug report, account. Reply, delete,
|
|
11
11
|
* move and the rest stay on the reading pane's own toolbar, under this bar.
|
|
12
|
+
*
|
|
13
|
+
* The field carries one chip: the scope of the view the user navigated into
|
|
14
|
+
* (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
|
|
15
|
+
* searches everything. Typed `in:`/`from:` terms are not chipped here — they
|
|
16
|
+
* are already visible as the text the user typed, and chipping them would show
|
|
17
|
+
* the same term twice in one field; they render as chips over the result
|
|
18
|
+
* sections instead, where the text is not repeated.
|
|
12
19
|
*/
|
|
13
20
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
21
|
import { AppTopBar, Button, SearchBar } from "@remit/ui";
|
|
@@ -16,8 +23,16 @@ import { SquarePen } from "lucide-react";
|
|
|
16
23
|
import { AccountMenu } from "@/auth/AccountMenu";
|
|
17
24
|
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
18
25
|
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
26
|
+
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
19
27
|
import { tooltipForAction } from "@/lib/keymap";
|
|
20
28
|
import { useMailContext } from "@/lib/mail-context";
|
|
29
|
+
import type { SearchScopeState } from "@/lib/search-scope";
|
|
30
|
+
|
|
31
|
+
const SEARCH_PLACEHOLDER: Record<SearchScopeState["kind"], string> = {
|
|
32
|
+
global: "Search all mail",
|
|
33
|
+
pending: "Search mail",
|
|
34
|
+
scoped: "Search this folder",
|
|
35
|
+
};
|
|
21
36
|
|
|
22
37
|
interface MailTopBarProps {
|
|
23
38
|
accounts: RemitImapAccountResponse[];
|
|
@@ -27,6 +42,15 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
27
42
|
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
28
43
|
useMailContext();
|
|
29
44
|
const compose = useGlobalCompose(accounts);
|
|
45
|
+
const { scope, clearScope } = useSearchScope(accounts);
|
|
46
|
+
const chips =
|
|
47
|
+
scope.kind === "scoped"
|
|
48
|
+
? [{ id: scope.chip.id, label: scope.chip.label, tone: "scope" as const }]
|
|
49
|
+
: undefined;
|
|
50
|
+
// Only the brief may claim to search all mail. A mailbox route whose name has
|
|
51
|
+
// not loaded yet has no chip to show but is already narrowed, so it gets the
|
|
52
|
+
// neutral wording rather than a placeholder that asserts the wrong scope.
|
|
53
|
+
const placeholder = SEARCH_PLACEHOLDER[scope.kind];
|
|
30
54
|
|
|
31
55
|
return (
|
|
32
56
|
<AppTopBar
|
|
@@ -41,7 +65,9 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
41
65
|
onChange={onSearchChange}
|
|
42
66
|
onClear={onSearchClear}
|
|
43
67
|
onClearQuery={onSearchClearQuery}
|
|
44
|
-
|
|
68
|
+
chips={chips}
|
|
69
|
+
onRemoveChip={clearScope}
|
|
70
|
+
placeholder={placeholder}
|
|
45
71
|
size="lg"
|
|
46
72
|
/>
|
|
47
73
|
}
|
|
@@ -40,6 +40,7 @@ import { useNavigate } from "@tanstack/react-router";
|
|
|
40
40
|
import { AlertCircle, RefreshCw, Sparkles } from "lucide-react";
|
|
41
41
|
import { useCallback, useMemo, useState } from "react";
|
|
42
42
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
43
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
43
44
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
44
45
|
import { sortAccountsByCreatedAt } from "@/lib/account-order";
|
|
45
46
|
import {
|
|
@@ -168,7 +169,8 @@ export function DailyBrief({
|
|
|
168
169
|
onSelectMessage,
|
|
169
170
|
onSelectSearchResult,
|
|
170
171
|
}: DailyBriefProps) {
|
|
171
|
-
const { searchQuery
|
|
172
|
+
const { searchQuery } = useMailContext();
|
|
173
|
+
const tokenContext = useSearchTokenContext();
|
|
172
174
|
const isDesktop = useIsDesktop();
|
|
173
175
|
|
|
174
176
|
const nonMuted = useMemo(
|
|
@@ -259,7 +261,7 @@ export function DailyBrief({
|
|
|
259
261
|
|
|
260
262
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
261
263
|
searchQuery.trim().toLowerCase(),
|
|
262
|
-
|
|
264
|
+
tokenContext,
|
|
263
265
|
);
|
|
264
266
|
|
|
265
267
|
// Convert API rows to ThreadRowData, narrowing only by the selected account
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { useCallback, useMemo, useState } from "react";
|
|
23
23
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
24
24
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
25
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
25
26
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
26
27
|
import {
|
|
27
28
|
matchesBriefSearch,
|
|
@@ -49,7 +50,8 @@ export function FlaggedList({
|
|
|
49
50
|
selectedMessageId,
|
|
50
51
|
onSelectMessage,
|
|
51
52
|
}: FlaggedListProps) {
|
|
52
|
-
const { searchQuery
|
|
53
|
+
const { searchQuery } = useMailContext();
|
|
54
|
+
const tokenContext = useSearchTokenContext();
|
|
53
55
|
const isDesktop = useIsDesktop();
|
|
54
56
|
|
|
55
57
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
@@ -84,7 +86,7 @@ export function FlaggedList({
|
|
|
84
86
|
|
|
85
87
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
86
88
|
searchQuery.trim().toLowerCase(),
|
|
87
|
-
|
|
89
|
+
tokenContext,
|
|
88
90
|
);
|
|
89
91
|
|
|
90
92
|
const rows = useMemo<ThreadRowData[]>(() => {
|
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
* `SearchResults` sections under the shared `FilterSheet`. Consumers feed the
|
|
15
15
|
* filter chrome and query-narrowed results; both tiers render identical rows.
|
|
16
16
|
*
|
|
17
|
-
* Results split into two sections
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Results split into two sections, one per engine: literal/instant and semantic.
|
|
18
|
+
* The consumer dedupes them — a thread in both appears only under the literal
|
|
19
|
+
* section — and names them, so a view whose engines reach different mail says so
|
|
20
|
+
* ("In Archive" / "Everywhere"). Each loads independently; an empty section
|
|
21
|
+
* drops out kit-side, so a semantic-only result still shows when the literal
|
|
22
|
+
* search finds nothing. The hamburger opens the nav drawer via the enclosing
|
|
23
|
+
* `AppShellSlotted`.
|
|
22
24
|
*/
|
|
23
25
|
import {
|
|
24
26
|
FilterSheet,
|
|
@@ -30,8 +32,9 @@ import {
|
|
|
30
32
|
SearchResults,
|
|
31
33
|
useAppShellLayout,
|
|
32
34
|
} from "@remit/ui";
|
|
33
|
-
import { type ReactNode, useState } from "react";
|
|
35
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
34
36
|
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
37
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
35
38
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
39
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
37
40
|
import {
|
|
@@ -56,6 +59,13 @@ interface MailListHeaderProps {
|
|
|
56
59
|
relatedResults?: SearchResult[];
|
|
57
60
|
relatedLoading?: boolean;
|
|
58
61
|
onSelectSearchResult?: (result: SearchResult) => void;
|
|
62
|
+
/**
|
|
63
|
+
* Section headings. A view whose two engines cover different ground names
|
|
64
|
+
* them by that ground ("In Archive" / "Everywhere") so the reach of a result
|
|
65
|
+
* is on screen rather than inferred.
|
|
66
|
+
*/
|
|
67
|
+
searchResultsLabel?: string;
|
|
68
|
+
relatedResultsLabel?: string;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export function MailListHeader({
|
|
@@ -69,32 +79,40 @@ export function MailListHeader({
|
|
|
69
79
|
relatedResults,
|
|
70
80
|
relatedLoading,
|
|
71
81
|
onSelectSearchResult,
|
|
82
|
+
searchResultsLabel = "Top matches",
|
|
83
|
+
relatedResultsLabel = "Related",
|
|
72
84
|
}: MailListHeaderProps) {
|
|
73
|
-
const {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
onSearchClear,
|
|
77
|
-
mailboxNameIndex,
|
|
78
|
-
accountNameIndex,
|
|
79
|
-
} = useMailContext();
|
|
85
|
+
const { searchInput, onSearchChange, onSearchClear, searchViewKey } =
|
|
86
|
+
useMailContext();
|
|
87
|
+
const tokenContext = useSearchTokenContext();
|
|
80
88
|
const layout = useAppShellLayout();
|
|
81
89
|
const tier = useLayoutTier();
|
|
82
90
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
83
91
|
const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
|
|
84
92
|
|
|
93
|
+
// Leaving the view ends the search: the shell drops the query, and the chrome
|
|
94
|
+
// it opened — the phone takeover, the expanded tablet field — closes with it
|
|
95
|
+
// rather than sitting there empty over the new mailbox (#47).
|
|
96
|
+
const searchViewRef = useRef(searchViewKey);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (searchViewRef.current === searchViewKey) return;
|
|
99
|
+
searchViewRef.current = searchViewKey;
|
|
100
|
+
setSearchOpen(false);
|
|
101
|
+
}, [searchViewKey]);
|
|
102
|
+
|
|
85
103
|
const hasQuery = searchInput.trim().length > 0;
|
|
86
|
-
// Filter tokens (`from:`, `has:attachment`, `
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// chip only
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
104
|
+
// Filter tokens (`from:`, `has:attachment`, `account:`, …) parsed live from
|
|
105
|
+
// the typed query render as removable chips above the sections; removing one
|
|
106
|
+
// edits the query text directly, which re-parses on the next render. The
|
|
107
|
+
// parse runs through `useSearchTokenContext`, the same one the engines use,
|
|
108
|
+
// so a chip appears only for a term that is actually being applied — on a
|
|
109
|
+
// scoped view `in:` is not resolved and so is never chipped here.
|
|
110
|
+
const tokenChips = parseSearchTokens(searchInput, tokenContext).tokens.map(
|
|
111
|
+
(token) => ({
|
|
112
|
+
label: searchTokenLabel(token),
|
|
113
|
+
onRemove: () => onSearchChange(removeSearchToken(searchInput, token)),
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
98
116
|
const topMatches = searchResults ?? [];
|
|
99
117
|
const related = relatedResults ?? [];
|
|
100
118
|
// Always offer both sections while a query is present; the kit drops the empty
|
|
@@ -102,8 +120,8 @@ export function MailListHeader({
|
|
|
102
120
|
// state. The empty-query case (recent searches) is the kit's job.
|
|
103
121
|
const sections: SearchResultSection[] = hasQuery
|
|
104
122
|
? [
|
|
105
|
-
{ id: "top", label:
|
|
106
|
-
{ id: "related", label:
|
|
123
|
+
{ id: "top", label: searchResultsLabel, results: topMatches },
|
|
124
|
+
{ id: "related", label: relatedResultsLabel, results: related },
|
|
107
125
|
]
|
|
108
126
|
: [];
|
|
109
127
|
// Skeleton only while nothing is in yet — once either section has rows, show
|
|
@@ -291,8 +291,17 @@ export function MailSidebarAdapter({
|
|
|
291
291
|
};
|
|
292
292
|
|
|
293
293
|
const handleSelectNav = () => {
|
|
294
|
-
// Navigation is handled by the router <Link>; this
|
|
295
|
-
// post-selection side
|
|
294
|
+
// Navigation is handled by the router <Link>; this runs the
|
|
295
|
+
// post-selection side effects.
|
|
296
|
+
//
|
|
297
|
+
// Clearing the field is one of them. Every link above drops `q`, and the
|
|
298
|
+
// shell re-seeds the field from the destination's `q` when the view
|
|
299
|
+
// changes — but picking the folder you are already in is the same view,
|
|
300
|
+
// so the shell leaves the field alone and the previous query would sit in
|
|
301
|
+
// the bar over a list that no longer has it in the URL. Selecting a nav
|
|
302
|
+
// entry is an unambiguous "show me this", so it clears here regardless.
|
|
303
|
+
// It is a user action, not a mirror, so it cannot race in-flight typing.
|
|
304
|
+
onSearchChange("");
|
|
296
305
|
onMailboxSelect?.();
|
|
297
306
|
};
|
|
298
307
|
|
|
@@ -41,6 +41,9 @@ interface MailViewChromeProps {
|
|
|
41
41
|
relatedResults?: SearchResult[];
|
|
42
42
|
relatedLoading?: boolean;
|
|
43
43
|
onSelectSearchResult?: (result: SearchResult) => void;
|
|
44
|
+
/** Section headings; see `MailListHeader`. */
|
|
45
|
+
searchResultsLabel?: string;
|
|
46
|
+
relatedResultsLabel?: string;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export function MailViewChrome({
|
|
@@ -60,6 +63,8 @@ export function MailViewChrome({
|
|
|
60
63
|
relatedResults,
|
|
61
64
|
relatedLoading,
|
|
62
65
|
onSelectSearchResult,
|
|
66
|
+
searchResultsLabel,
|
|
67
|
+
relatedResultsLabel,
|
|
63
68
|
}: MailViewChromeProps) {
|
|
64
69
|
const [expanded, setExpanded] = useState(false);
|
|
65
70
|
|
|
@@ -88,6 +93,8 @@ export function MailViewChrome({
|
|
|
88
93
|
relatedResults={relatedResults}
|
|
89
94
|
relatedLoading={relatedLoading}
|
|
90
95
|
onSelectSearchResult={onSelectSearchResult}
|
|
96
|
+
searchResultsLabel={searchResultsLabel}
|
|
97
|
+
relatedResultsLabel={relatedResultsLabel}
|
|
91
98
|
>
|
|
92
99
|
<FilterSheet {...filterConfig}>{children}</FilterSheet>
|
|
93
100
|
</MailListHeader>
|
|
@@ -88,6 +88,7 @@ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
|
88
88
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
89
89
|
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
90
90
|
import { useRescueCandidates } from "@/hooks/useRescueCandidates";
|
|
91
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
91
92
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
92
93
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
93
94
|
import { useTriageKeyboard } from "@/hooks/useTriageKeyboard";
|
|
@@ -247,28 +248,25 @@ function MailboxPaneProvider({
|
|
|
247
248
|
const telemetry = useTelemetry();
|
|
248
249
|
const {
|
|
249
250
|
accounts,
|
|
250
|
-
mailboxNameIndex,
|
|
251
|
-
accountNameIndex,
|
|
252
251
|
searchQuery,
|
|
253
252
|
searchInput,
|
|
254
253
|
intelligenceOpen,
|
|
255
254
|
onToggleIntelligence,
|
|
256
255
|
onSetIntelligenceOpen,
|
|
257
256
|
} = useMailContext();
|
|
257
|
+
const tokenContext = useSearchTokenContext();
|
|
258
258
|
|
|
259
259
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
260
260
|
const hasSearchQuery = normalizedSearchQuery.length > 0;
|
|
261
261
|
// Filter tokens (`from:`, `has:attachment`, `is:unread`) narrow this literal
|
|
262
262
|
// search to the params `threadOperationsSearchThreads` supports; `before:`/
|
|
263
263
|
// `after:`/`account:` have no equivalent on this endpoint and are left for
|
|
264
|
-
// the semantic section only (see `useSemanticSearch`). `in:`
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// token is stripped from `freeText` instead of leaking into the literal
|
|
268
|
-
// query text as a stray word.
|
|
264
|
+
// the semantic section only (see `useSemanticSearch`). `in:` never reaches
|
|
265
|
+
// here at all: this view is scoped to one mailbox by its route, so
|
|
266
|
+
// `useSearchTokenContext` does not resolve the term and it stays free text.
|
|
269
267
|
const { freeText, tokens: searchTokens } = parseSearchTokens(
|
|
270
268
|
normalizedSearchQuery,
|
|
271
|
-
|
|
269
|
+
tokenContext,
|
|
272
270
|
);
|
|
273
271
|
const fromToken = searchTokens.find((t) => t.type === "from");
|
|
274
272
|
const searchThreadsQuery = {
|
|
@@ -949,10 +947,14 @@ function MailboxList() {
|
|
|
949
947
|
() => threads.map(threadToSearchResult),
|
|
950
948
|
[threads],
|
|
951
949
|
);
|
|
952
|
-
//
|
|
953
|
-
//
|
|
950
|
+
// The route scopes this view, and the top bar's chip says so. Both sections
|
|
951
|
+
// are named by the ground they cover so that scope is never overstated: the
|
|
952
|
+
// literal engine searches this mailbox and nothing else (there is no
|
|
953
|
+
// cross-mailbox thread search), while the semantic engine runs unscoped and
|
|
954
|
+
// its section says "Everywhere" (#47). Reaching past the folder is a labelled
|
|
955
|
+
// section, not a silent widening of the chip. Results are deduped by thread,
|
|
956
|
+
// so a thread never shows in both.
|
|
954
957
|
const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
|
|
955
|
-
mailboxId,
|
|
956
958
|
filterCategory,
|
|
957
959
|
});
|
|
958
960
|
const relatedResults = useMemo(
|
|
@@ -967,7 +969,10 @@ function MailboxList() {
|
|
|
967
969
|
(result: SearchResult) =>
|
|
968
970
|
navigate({
|
|
969
971
|
to: "/mail/$mailboxId",
|
|
970
|
-
|
|
972
|
+
// A hit from the unscoped "Everywhere" section lives in another
|
|
973
|
+
// mailbox; open it there, so the list, the toolbar's verbs and the
|
|
974
|
+
// message all belong to the same mailbox.
|
|
975
|
+
params: { mailboxId: result.mailboxId ?? mailboxId },
|
|
971
976
|
search: (prev: Record<string, unknown>) => ({
|
|
972
977
|
...prev,
|
|
973
978
|
// Commit the active query alongside the selection. The debounced
|
|
@@ -1061,6 +1066,8 @@ function MailboxList() {
|
|
|
1061
1066
|
relatedResults={relatedResults}
|
|
1062
1067
|
relatedLoading={relatedLoading}
|
|
1063
1068
|
onSelectSearchResult={handleSelectSearchResult}
|
|
1069
|
+
searchResultsLabel={`In ${listTitle}`}
|
|
1070
|
+
relatedResultsLabel="Everywhere"
|
|
1064
1071
|
>
|
|
1065
1072
|
{body}
|
|
1066
1073
|
</MailViewChrome>
|
|
@@ -46,8 +46,16 @@ import { MessageBody } from "@/components/mail/MessageBody";
|
|
|
46
46
|
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
47
47
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
48
48
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
49
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
49
50
|
import { formatDate as formatLocaleDate, toDate } from "@/lib/format";
|
|
51
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
52
|
+
import {
|
|
53
|
+
matchesOutboxSearch,
|
|
54
|
+
outboxQueryIsUnsupported,
|
|
55
|
+
} from "@/lib/outbox-search";
|
|
50
56
|
import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
|
|
57
|
+
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
58
|
+
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
51
59
|
import { cn } from "@/lib/utils";
|
|
52
60
|
|
|
53
61
|
/* ------------------------------------------------------------------ */
|
|
@@ -103,12 +111,31 @@ const formatRecipients = (message: RemitImapOutboxMessageResponse): string => {
|
|
|
103
111
|
return `${recipients[0]} +${recipients.length - 1}`;
|
|
104
112
|
};
|
|
105
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What an empty outbox list says. A query that filtered everything out reads
|
|
116
|
+
* differently from an empty outbox, and a query the outbox cannot serve at all
|
|
117
|
+
* has to say so rather than look like a search that found nothing.
|
|
118
|
+
*/
|
|
119
|
+
const outboxEmptyMessage = (
|
|
120
|
+
hasQuery: boolean,
|
|
121
|
+
unsupportedQuery: boolean,
|
|
122
|
+
): string => {
|
|
123
|
+
if (unsupportedQuery) return "The outbox cannot filter on those terms";
|
|
124
|
+
if (hasQuery) return "No matching outbox messages";
|
|
125
|
+
return "No outbox messages";
|
|
126
|
+
};
|
|
127
|
+
|
|
106
128
|
/* ------------------------------------------------------------------ */
|
|
107
129
|
/* Context */
|
|
108
130
|
/* ------------------------------------------------------------------ */
|
|
109
131
|
|
|
110
132
|
interface OutboxPaneContextValue {
|
|
133
|
+
/** Outbox rows, narrowed by the search query when one is active. */
|
|
111
134
|
messages: RemitImapOutboxMessageResponse[];
|
|
135
|
+
/** True while a query is narrowing `messages` — the empty state differs. */
|
|
136
|
+
hasQuery: boolean;
|
|
137
|
+
/** True when that query asks for a filter the outbox cannot apply. */
|
|
138
|
+
unsupportedQuery: boolean;
|
|
112
139
|
isLoading: boolean;
|
|
113
140
|
selectedMessageId: string | undefined;
|
|
114
141
|
selectedMessage: RemitImapOutboxMessageResponse | undefined;
|
|
@@ -143,6 +170,20 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
|
|
|
143
170
|
outboxOperationsListOutboxMessagesOptions(),
|
|
144
171
|
);
|
|
145
172
|
|
|
173
|
+
// The top bar's field scopes to this view (`in:outbox`), so a query typed
|
|
174
|
+
// here narrows these rows. The list is small and already fully loaded, so
|
|
175
|
+
// the narrowing is a filter over what is in hand — see lib/outbox-search.ts
|
|
176
|
+
// for which fields it matches and why the filter tokens are not honored.
|
|
177
|
+
// The query is parsed the same way every other engine parses it, and only
|
|
178
|
+
// the free text is used: `Q3 from:billing` still matches on "Q3" rather
|
|
179
|
+
// than searching the rows for the literal string "from:billing".
|
|
180
|
+
const { searchQuery } = useMailContext();
|
|
181
|
+
const tokenContext = useSearchTokenContext();
|
|
182
|
+
const normalizedQuery = normalizeSearchQuery(searchQuery);
|
|
183
|
+
const parsedQuery = parseSearchTokens(normalizedQuery, tokenContext);
|
|
184
|
+
const hasQuery = normalizedQuery.length > 0;
|
|
185
|
+
const unsupportedQuery = outboxQueryIsUnsupported(parsedQuery);
|
|
186
|
+
|
|
146
187
|
// Outbox surfaces in-flight + actionable rows only. `draft` lives in the
|
|
147
188
|
// Drafts view; `sent` rows are deleted by the IMAP append handler once
|
|
148
189
|
// the message lands in Sent (issue #178), but we filter defensively so a
|
|
@@ -150,10 +191,12 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
|
|
|
150
191
|
// has not completed yet (issue #193).
|
|
151
192
|
const messages = useMemo(
|
|
152
193
|
() =>
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
194
|
+
unsupportedQuery
|
|
195
|
+
? []
|
|
196
|
+
: (outboxResponse?.items ?? [])
|
|
197
|
+
.filter((item) => isOutboxListRow(item.status))
|
|
198
|
+
.filter((item) => matchesOutboxSearch(item, parsedQuery.freeText)),
|
|
199
|
+
[outboxResponse, parsedQuery.freeText, unsupportedQuery],
|
|
157
200
|
);
|
|
158
201
|
|
|
159
202
|
const selectedMessage = useMemo(
|
|
@@ -176,6 +219,8 @@ function OutboxPaneProvider({ children }: OutboxPaneProps) {
|
|
|
176
219
|
|
|
177
220
|
const ctx: OutboxPaneContextValue = {
|
|
178
221
|
messages,
|
|
222
|
+
hasQuery,
|
|
223
|
+
unsupportedQuery,
|
|
179
224
|
isLoading,
|
|
180
225
|
selectedMessageId,
|
|
181
226
|
selectedMessage,
|
|
@@ -381,8 +426,14 @@ function OutboxMessageDetail({ message, onBack }: OutboxMessageDetailProps) {
|
|
|
381
426
|
* Outbox list with datum bar header. Mount in the `list` slot of `AppShellSlotted`.
|
|
382
427
|
*/
|
|
383
428
|
function OutboxList() {
|
|
384
|
-
const {
|
|
385
|
-
|
|
429
|
+
const {
|
|
430
|
+
messages,
|
|
431
|
+
hasQuery,
|
|
432
|
+
unsupportedQuery,
|
|
433
|
+
isLoading,
|
|
434
|
+
selectedMessageId,
|
|
435
|
+
onSelectMessage,
|
|
436
|
+
} = useOutboxPane();
|
|
386
437
|
|
|
387
438
|
if (isLoading) {
|
|
388
439
|
return (
|
|
@@ -395,7 +446,10 @@ function OutboxList() {
|
|
|
395
446
|
if (messages.length === 0) {
|
|
396
447
|
return (
|
|
397
448
|
<div className="flex h-full bg-surface">
|
|
398
|
-
<ReadingPaneEmpty
|
|
449
|
+
<ReadingPaneEmpty
|
|
450
|
+
message={outboxEmptyMessage(hasQuery, unsupportedQuery)}
|
|
451
|
+
showHints={false}
|
|
452
|
+
/>
|
|
399
453
|
</div>
|
|
400
454
|
);
|
|
401
455
|
}
|
|
@@ -462,6 +516,8 @@ function OutboxReading() {
|
|
|
462
516
|
function OutboxPhone() {
|
|
463
517
|
const {
|
|
464
518
|
messages,
|
|
519
|
+
hasQuery,
|
|
520
|
+
unsupportedQuery,
|
|
465
521
|
isLoading,
|
|
466
522
|
selectedMessageId,
|
|
467
523
|
selectedMessage,
|
|
@@ -488,7 +544,10 @@ function OutboxPhone() {
|
|
|
488
544
|
if (messages.length === 0) {
|
|
489
545
|
return (
|
|
490
546
|
<div className="flex h-full bg-surface">
|
|
491
|
-
<ReadingPaneEmpty
|
|
547
|
+
<ReadingPaneEmpty
|
|
548
|
+
message={outboxEmptyMessage(hasQuery, unsupportedQuery)}
|
|
549
|
+
showHints={false}
|
|
550
|
+
/>
|
|
492
551
|
</div>
|
|
493
552
|
);
|
|
494
553
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
5
|
+
import {
|
|
6
|
+
SEARCH_SCOPE_CHIP_ID,
|
|
7
|
+
type SearchScopeState,
|
|
8
|
+
searchScopeForRoute,
|
|
9
|
+
} from "@/lib/search-scope";
|
|
10
|
+
import { useCurrentMailboxName } from "./useCurrentMailboxName";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The scope chip for the active route and the way off it.
|
|
14
|
+
*
|
|
15
|
+
* `clearScope` navigates to the daily brief carrying the current query, which
|
|
16
|
+
* is what "search everything" means here — the brief is the unscoped
|
|
17
|
+
* cross-account view. It is a navigation rather than a query edit because the
|
|
18
|
+
* chip mirrors the route (see `lib/search-scope.ts`); editing the text would
|
|
19
|
+
* leave the chip in place and the list still narrowed.
|
|
20
|
+
*/
|
|
21
|
+
export function useSearchScope(accounts: RemitImapAccountResponse[]): {
|
|
22
|
+
scope: SearchScopeState;
|
|
23
|
+
clearScope: (chipId: string) => void;
|
|
24
|
+
} {
|
|
25
|
+
const navigate = useNavigate();
|
|
26
|
+
const { searchInput } = useMailContext();
|
|
27
|
+
const mailboxName = useCurrentMailboxName({ accounts });
|
|
28
|
+
// Select the matches, not the scope: a select closing over `mailboxName`
|
|
29
|
+
// only re-runs on router state changes, so the chip would keep reading the
|
|
30
|
+
// previous folder until the next navigation.
|
|
31
|
+
const matches = useRouterState({ select: (s) => s.matches });
|
|
32
|
+
const scope = searchScopeForRoute(matches, mailboxName);
|
|
33
|
+
|
|
34
|
+
// Takes the chip id the field removed rather than assuming which chip that
|
|
35
|
+
// was. The bar owns one chip today; keying on the id means a second one
|
|
36
|
+
// added later cannot silently drop the user out of their scope.
|
|
37
|
+
const clearScope = useCallback(
|
|
38
|
+
(chipId: string) => {
|
|
39
|
+
if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
|
|
40
|
+
navigate({
|
|
41
|
+
to: "/mail",
|
|
42
|
+
search: { q: searchInput || undefined, selectedMessageId: undefined },
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
[navigate, searchInput],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return { scope, clearScope };
|
|
49
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
3
|
+
import { isScopedRoute } from "@/lib/search-scope";
|
|
4
|
+
import type { SearchTokenContext } from "@/lib/search-tokens";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one context every search engine parses the query through.
|
|
8
|
+
*
|
|
9
|
+
* `in:` is recognized only where the route carries no scope. On a scoped view
|
|
10
|
+
* the top bar's chip already answers "which mailbox", and a typed `in:` is a
|
|
11
|
+
* second, competing answer that no engine there acts on — the literal search is
|
|
12
|
+
* pinned to the route's mailbox and the semantic request takes the caller's
|
|
13
|
+
* scope. Leaving the term unresolved means it stays ordinary words: it is
|
|
14
|
+
* matched as text and never chipped, so the field can't advertise a filter that
|
|
15
|
+
* does nothing. To search another folder, drop the scope chip or go to it.
|
|
16
|
+
*
|
|
17
|
+
* Every call site takes this hook rather than building its own context, because
|
|
18
|
+
* the guarantee is that they agree. Two of them disagreeing is what made the
|
|
19
|
+
* same query behave one way on `/mail/flagged` and another on a mailbox.
|
|
20
|
+
*/
|
|
21
|
+
export function useSearchTokenContext(): SearchTokenContext {
|
|
22
|
+
const { mailboxNameIndex, accountNameIndex } = useMailContext();
|
|
23
|
+
const scoped = useRouterState({ select: (s) => isScopedRoute(s.matches) });
|
|
24
|
+
return {
|
|
25
|
+
...(scoped ? {} : { mailboxesByName: mailboxNameIndex }),
|
|
26
|
+
accountsByName: accountNameIndex,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
5
5
|
import { useMailContext } from "@/lib/mail-context";
|
|
6
6
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
7
7
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
8
|
+
import { useSearchTokenContext } from "./useSearchTokenContext";
|
|
8
9
|
|
|
9
10
|
/** Cap the "Related" section; the literal "Top matches" is the primary surface. */
|
|
10
11
|
const SEMANTIC_RESULT_LIMIT = 20;
|
|
@@ -44,9 +45,14 @@ interface UseSemanticSearchParams {
|
|
|
44
45
|
* account filter) — both still render as chips and narrow the literal engine,
|
|
45
46
|
* but never reach the semantic request (`account:` is a documented gap, see
|
|
46
47
|
* doc/design/flows/06-search.md — the semantic index is per account config).
|
|
47
|
-
* `in:` resolves to a mailboxId
|
|
48
|
-
*
|
|
49
|
-
*
|
|
48
|
+
* `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
|
|
49
|
+
* from the unscoped daily brief. It cannot contradict a scoped view because
|
|
50
|
+
* `useSearchTokenContext` does not resolve the term there at all — the term
|
|
51
|
+
* stays free text and no engine, and no chip, treats it as a filter.
|
|
52
|
+
*
|
|
53
|
+
* Without a `mailboxId` the request spans every mailbox of every account the
|
|
54
|
+
* caller owns: the backend partitions the vector index by accountConfigId (one
|
|
55
|
+
* per signed-in user, not per mail account), so unscoped is genuinely global.
|
|
50
56
|
*/
|
|
51
57
|
export function useSemanticSearch({
|
|
52
58
|
mailboxId,
|
|
@@ -55,12 +61,10 @@ export function useSemanticSearch({
|
|
|
55
61
|
hits: RemitImapSemanticSearchResult[];
|
|
56
62
|
isLoading: boolean;
|
|
57
63
|
} {
|
|
58
|
-
const { searchQuery
|
|
64
|
+
const { searchQuery } = useMailContext();
|
|
65
|
+
const tokenContext = useSearchTokenContext();
|
|
59
66
|
const normalizedQuery = normalizeSearchQuery(searchQuery);
|
|
60
|
-
const { freeText, tokens } = parseSearchTokens(normalizedQuery,
|
|
61
|
-
mailboxesByName: mailboxNameIndex,
|
|
62
|
-
accountsByName: accountNameIndex,
|
|
63
|
-
});
|
|
67
|
+
const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
|
|
64
68
|
const enabled = normalizedQuery.length > 0;
|
|
65
69
|
|
|
66
70
|
const category = toCategoryParam(filterCategory);
|
|
@@ -71,7 +75,7 @@ export function useSemanticSearch({
|
|
|
71
75
|
const afterToken = tokens.find((t) => t.type === "after");
|
|
72
76
|
const beforeToken = tokens.find((t) => t.type === "before");
|
|
73
77
|
const inToken = tokens.find((t) => t.type === "in");
|
|
74
|
-
const effectiveMailboxId =
|
|
78
|
+
const effectiveMailboxId = mailboxId ?? inToken?.mailboxId;
|
|
75
79
|
|
|
76
80
|
const { data, isLoading } = useQuery({
|
|
77
81
|
...semanticSearchOperationsSemanticSearchOptions({
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -23,8 +23,14 @@ export interface MailContextValue {
|
|
|
23
23
|
mailboxNameIndex: ReadonlyMap<string, string>;
|
|
24
24
|
accountNameIndex: ReadonlyMap<string, string>;
|
|
25
25
|
searchQuery: string;
|
|
26
|
-
/** Live (pre-debounce) search input — the
|
|
26
|
+
/** Live (pre-debounce) search input — the search field binds this. */
|
|
27
27
|
searchInput: string;
|
|
28
|
+
/**
|
|
29
|
+
* Identity of the view the current search belongs to (`lib/mail-route.ts`).
|
|
30
|
+
* It changes when the user leaves that view, which is when search ends: the
|
|
31
|
+
* query re-seeds from the new URL and any search chrome collapses (#47).
|
|
32
|
+
*/
|
|
33
|
+
searchViewKey: string;
|
|
28
34
|
onSearchChange: (query: string) => void;
|
|
29
35
|
/** Full clear (X button): drops the query and any selected thread (#538). */
|
|
30
36
|
onSearchClear: () => void;
|
|
@@ -53,6 +59,7 @@ export const useMailContext = (): MailContextValue => {
|
|
|
53
59
|
accountNameIndex: new Map(),
|
|
54
60
|
searchQuery: "",
|
|
55
61
|
searchInput: "",
|
|
62
|
+
searchViewKey: "",
|
|
56
63
|
onSearchChange: () => {},
|
|
57
64
|
onSearchClear: () => {},
|
|
58
65
|
onSearchClearQuery: () => {},
|