@remit/web-client 0.0.18 → 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 +16 -20
- package/src/components/mail/MailSidebarAdapter.tsx +11 -2
- package/src/components/mail/MailboxPane.tsx +13 -14
- 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/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 +94 -0
- package/src/routes/mail.tsx +26 -21
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[]>(() => {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "@remit/ui";
|
|
35
35
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
36
36
|
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
37
|
+
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
37
38
|
import { useMailContext } from "@/lib/mail-context";
|
|
38
39
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
39
40
|
import {
|
|
@@ -81,14 +82,9 @@ export function MailListHeader({
|
|
|
81
82
|
searchResultsLabel = "Top matches",
|
|
82
83
|
relatedResultsLabel = "Related",
|
|
83
84
|
}: MailListHeaderProps) {
|
|
84
|
-
const {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
onSearchClear,
|
|
88
|
-
searchViewKey,
|
|
89
|
-
mailboxNameIndex,
|
|
90
|
-
accountNameIndex,
|
|
91
|
-
} = useMailContext();
|
|
85
|
+
const { searchInput, onSearchChange, onSearchClear, searchViewKey } =
|
|
86
|
+
useMailContext();
|
|
87
|
+
const tokenContext = useSearchTokenContext();
|
|
92
88
|
const layout = useAppShellLayout();
|
|
93
89
|
const tier = useLayoutTier();
|
|
94
90
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
@@ -105,18 +101,18 @@ export function MailListHeader({
|
|
|
105
101
|
}, [searchViewKey]);
|
|
106
102
|
|
|
107
103
|
const hasQuery = searchInput.trim().length > 0;
|
|
108
|
-
// Filter tokens (`from:`, `has:attachment`, `
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// chip only
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
);
|
|
120
116
|
const topMatches = searchResults ?? [];
|
|
121
117
|
const related = relatedResults ?? [];
|
|
122
118
|
// Always offer both sections while a query is present; the kit drops the empty
|
|
@@ -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
|
|
|
@@ -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,12 +947,13 @@ function MailboxList() {
|
|
|
949
947
|
() => threads.map(threadToSearchResult),
|
|
950
948
|
[threads],
|
|
951
949
|
);
|
|
952
|
-
// The
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
// mailbox
|
|
956
|
-
// section
|
|
957
|
-
//
|
|
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.
|
|
958
957
|
const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
|
|
959
958
|
filterCategory,
|
|
960
959
|
});
|
|
@@ -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({
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { matchesOutboxSearch, outboxQueryIsUnsupported } from "./outbox-search";
|
|
4
|
+
import { parseSearchTokens } from "./search-tokens";
|
|
5
|
+
|
|
6
|
+
const row = {
|
|
7
|
+
subject: "Q3 Invoice",
|
|
8
|
+
fromAddress: "me@example.com",
|
|
9
|
+
fromName: "Me",
|
|
10
|
+
toAddresses: ["billing@acme.test"],
|
|
11
|
+
ccAddresses: ["accounts@acme.test"],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("matchesOutboxSearch", () => {
|
|
15
|
+
it("keeps every row when there is no query", () => {
|
|
16
|
+
assert.equal(matchesOutboxSearch(row, " "), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches the subject", () => {
|
|
20
|
+
assert.equal(matchesOutboxSearch(row, "invoice"), true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("matches a recipient", () => {
|
|
24
|
+
assert.equal(matchesOutboxSearch(row, "acme"), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("matches the sender", () => {
|
|
28
|
+
assert.equal(matchesOutboxSearch(row, "me@example"), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("requires every word, in any order and any field", () => {
|
|
32
|
+
assert.equal(matchesOutboxSearch(row, "invoice billing"), true);
|
|
33
|
+
assert.equal(matchesOutboxSearch(row, "invoice missing"), false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("drops a row that matches nothing", () => {
|
|
37
|
+
assert.equal(matchesOutboxSearch(row, "receipt"), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("tolerates rows with absent fields", () => {
|
|
41
|
+
assert.equal(matchesOutboxSearch({}, "anything"), false);
|
|
42
|
+
assert.equal(matchesOutboxSearch({}, ""), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches the free text of a query that also carries a token", () => {
|
|
46
|
+
// The token is dropped, but what the user typed alongside it still
|
|
47
|
+
// searches — the row is not lost to an operator the outbox can't serve.
|
|
48
|
+
const { freeText } = parseSearchTokens("Q3 from:billing");
|
|
49
|
+
assert.equal(matchesOutboxSearch(row, freeText), true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("outboxQueryIsUnsupported", () => {
|
|
54
|
+
it("is true for a query that is only tokens", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
outboxQueryIsUnsupported(parseSearchTokens("from:billing")),
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("is false when free text survives the parse", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
outboxQueryIsUnsupported(parseSearchTokens("Q3 from:billing")),
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is false for plain free text", () => {
|
|
69
|
+
assert.equal(outboxQueryIsUnsupported(parseSearchTokens("invoice")), false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is false for an empty query", () => {
|
|
73
|
+
assert.equal(outboxQueryIsUnsupported(parseSearchTokens("")), false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query narrowing for the outbox list.
|
|
3
|
+
*
|
|
4
|
+
* The outbox is a small, fully-loaded client-side list of mail that has not
|
|
5
|
+
* left yet, so its search is a filter over the rows already in hand rather
|
|
6
|
+
* than a request. It matches what an outbox row shows — subject, recipients
|
|
7
|
+
* and sender — because that is what the user can see to search for.
|
|
8
|
+
*
|
|
9
|
+
* Filter tokens (`from:`, `is:unread`, dates) are not honored: an outbox row
|
|
10
|
+
* has no read state, no mailbox and no received date, so the operators have
|
|
11
|
+
* nothing to read. Only the free text narrows, so `Q3 from:billing` still
|
|
12
|
+
* matches on "Q3".
|
|
13
|
+
*
|
|
14
|
+
* A query made only of tokens asks for a filter this view cannot apply, and
|
|
15
|
+
* there is no free text left to fall back on. Rather than return every row as
|
|
16
|
+
* if the filter had been honored, that case returns nothing and the list says
|
|
17
|
+
* why — see `outboxQueryIsUnsupported`.
|
|
18
|
+
*/
|
|
19
|
+
import type { ParsedSearchQuery } from "./search-tokens";
|
|
20
|
+
|
|
21
|
+
/** The outbox row fields a query is matched against. */
|
|
22
|
+
export interface OutboxSearchRow {
|
|
23
|
+
subject?: string | null;
|
|
24
|
+
fromAddress?: string | null;
|
|
25
|
+
fromName?: string | null;
|
|
26
|
+
toAddresses?: readonly string[] | null;
|
|
27
|
+
ccAddresses?: readonly string[] | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function matchesOutboxSearch(
|
|
31
|
+
row: OutboxSearchRow,
|
|
32
|
+
freeText: string,
|
|
33
|
+
): boolean {
|
|
34
|
+
const needle = freeText.trim().toLowerCase();
|
|
35
|
+
if (needle.length === 0) return true;
|
|
36
|
+
const haystack = [
|
|
37
|
+
row.subject,
|
|
38
|
+
row.fromAddress,
|
|
39
|
+
row.fromName,
|
|
40
|
+
...(row.toAddresses ?? []),
|
|
41
|
+
...(row.ccAddresses ?? []),
|
|
42
|
+
]
|
|
43
|
+
.filter((value): value is string => typeof value === "string")
|
|
44
|
+
.join(" ")
|
|
45
|
+
.toLowerCase();
|
|
46
|
+
return needle
|
|
47
|
+
.split(/\s+/)
|
|
48
|
+
.every((word) => word.length === 0 || haystack.includes(word));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when the query is nothing but filter tokens. The outbox can serve none
|
|
53
|
+
* of them, and matching on the empty free text left over would return every
|
|
54
|
+
* row — indistinguishable from a filter that matched everything.
|
|
55
|
+
*/
|
|
56
|
+
export function outboxQueryIsUnsupported(parsed: ParsedSearchQuery): boolean {
|
|
57
|
+
return parsed.tokens.length > 0 && parsed.freeText.trim().length === 0;
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
5
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
6
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
7
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
8
|
+
} from "./mail-route";
|
|
9
|
+
import {
|
|
10
|
+
isScopedRoute,
|
|
11
|
+
SEARCH_SCOPE_CHIP_ID,
|
|
12
|
+
scopeLabelForMailboxName,
|
|
13
|
+
searchScopeForRoute,
|
|
14
|
+
} from "./search-scope";
|
|
15
|
+
|
|
16
|
+
const matches = (routeId: string) => [{ routeId: "/mail" }, { routeId }];
|
|
17
|
+
|
|
18
|
+
describe("searchScopeForRoute", () => {
|
|
19
|
+
it("gives the daily brief no scope — it is the global view", () => {
|
|
20
|
+
assert.deepEqual(searchScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID)), {
|
|
21
|
+
kind: "global",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("scopes a mailbox route to its sidebar label", () => {
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), "Spam"),
|
|
28
|
+
{
|
|
29
|
+
kind: "scoped",
|
|
30
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("is pending, not global, while the mailbox name resolves", () => {
|
|
36
|
+
// The list under the bar is already one mailbox. A chip reading a raw
|
|
37
|
+
// uuid is worse than no chip, but the field must not claim to search
|
|
38
|
+
// everything either.
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
searchScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), null),
|
|
41
|
+
{ kind: "pending" },
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("scopes the starred view", () => {
|
|
46
|
+
const scope = searchScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID));
|
|
47
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "is:starred");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("scopes the outbox", () => {
|
|
51
|
+
const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID));
|
|
52
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prefers the virtual views over a lingering mailbox name", () => {
|
|
56
|
+
const scope = searchScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), "Spam");
|
|
57
|
+
assert.equal(scope.kind === "scoped" && scope.chip.label, "in:outbox");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isScopedRoute", () => {
|
|
62
|
+
// This predicate decides whether a typed `in:` is recognized at all, so
|
|
63
|
+
// every route has to answer it the same way it answers for its chip: a
|
|
64
|
+
// route showing a scope chip must not also honour a competing typed term.
|
|
65
|
+
for (const routeId of [
|
|
66
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
67
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
68
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
69
|
+
]) {
|
|
70
|
+
it(`${routeId} carries a scope`, () => {
|
|
71
|
+
assert.equal(isScopedRoute(matches(routeId)), true);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
it("the daily brief carries none", () => {
|
|
76
|
+
assert.equal(isScopedRoute(matches(MAIL_BRIEF_ROUTE_ID)), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("agrees with the chip on every route", () => {
|
|
80
|
+
for (const routeId of [
|
|
81
|
+
MAIL_BRIEF_ROUTE_ID,
|
|
82
|
+
MAIL_MAILBOX_ROUTE_ID,
|
|
83
|
+
MAIL_FLAGGED_ROUTE_ID,
|
|
84
|
+
MAIL_OUTBOX_ROUTE_ID,
|
|
85
|
+
]) {
|
|
86
|
+
const scope = searchScopeForRoute(matches(routeId), "Spam");
|
|
87
|
+
assert.equal(
|
|
88
|
+
isScopedRoute(matches(routeId)),
|
|
89
|
+
scope.kind === "scoped",
|
|
90
|
+
`${routeId} disagrees about whether it has a scope`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("scopeLabelForMailboxName", () => {
|
|
97
|
+
it("lower-cases so the chip reads like the operator it mimics", () => {
|
|
98
|
+
assert.equal(scopeLabelForMailboxName("Archive"), "in:archive");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("quotes a multi-word folder so the chip stays one term", () => {
|
|
102
|
+
assert.equal(scopeLabelForMailboxName("Work Stuff"), 'in:"work stuff"');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The search scope is the route.
|
|
3
|
+
*
|
|
4
|
+
* Search is the app's search: one field in the top bar, and by default it
|
|
5
|
+
* covers everything. Navigating the sidebar narrows it, and the narrowing is
|
|
6
|
+
* shown as one chip in the field — `in:spam` while viewing Spam. The chip is
|
|
7
|
+
* derived from the active route, not parsed out of the typed text: the route
|
|
8
|
+
* decides which pane is mounted and which mail that pane reads, and the chip is
|
|
9
|
+
* that decision made visible. Navigating elsewhere replaces the chip because the
|
|
10
|
+
* route changed.
|
|
11
|
+
*
|
|
12
|
+
* Removing the chip means "search everything", which is the daily brief — the
|
|
13
|
+
* cross-account view whose scope is nothing. So removal is a navigation to
|
|
14
|
+
* `/mail/` carrying the query, not an edit of the query text.
|
|
15
|
+
*
|
|
16
|
+
* There is exactly one scope, and it is this one. A typed `in:` term is a
|
|
17
|
+
* second, competing answer to "which mailbox", so it is recognized only where
|
|
18
|
+
* there is no route scope to compete with — see `isScopedRoute` and
|
|
19
|
+
* `useSearchTokenContext`. Every engine parses the query through that one
|
|
20
|
+
* context, so a scoped view cannot show a chip for a term it is ignoring.
|
|
21
|
+
*
|
|
22
|
+
* Pure functions only. `useSearchScope` binds these to the router.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
isBriefRoute,
|
|
26
|
+
isFlaggedRoute,
|
|
27
|
+
isMailboxRoute,
|
|
28
|
+
isOutboxRoute,
|
|
29
|
+
type MailRouteMatch,
|
|
30
|
+
} from "./mail-route";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Chip id of the scope chip. The top bar owns exactly one, so a fixed id is
|
|
34
|
+
* enough of a removal handle.
|
|
35
|
+
*/
|
|
36
|
+
export const SEARCH_SCOPE_CHIP_ID = "search-scope";
|
|
37
|
+
|
|
38
|
+
export interface SearchScope {
|
|
39
|
+
id: string;
|
|
40
|
+
/** What the chip reads, e.g. `in:spam`. */
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What the field can say about its own scope.
|
|
46
|
+
*
|
|
47
|
+
* `pending` is the mailbox route before its name has loaded: the list under the
|
|
48
|
+
* bar is already one mailbox, so the field must not claim to search everything,
|
|
49
|
+
* but there is no name yet to put in a chip. It is a real third state, not a
|
|
50
|
+
* flavour of `global`.
|
|
51
|
+
*/
|
|
52
|
+
export type SearchScopeState =
|
|
53
|
+
| { kind: "global" }
|
|
54
|
+
| { kind: "pending" }
|
|
55
|
+
| { kind: "scoped"; chip: SearchScope };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Render a mailbox name as an `in:` term. Lower-cased so it reads like the
|
|
59
|
+
* operator it mimics, and quoted when the name has whitespace so the chip
|
|
60
|
+
* stays one legible term.
|
|
61
|
+
*/
|
|
62
|
+
export function scopeLabelForMailboxName(name: string): string {
|
|
63
|
+
const collapsed = name.trim().replace(/\s+/g, " ").toLowerCase();
|
|
64
|
+
return /\s/.test(collapsed) ? `in:"${collapsed}"` : `in:${collapsed}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* True on the routes whose pane reads one mailbox or one virtual collection.
|
|
69
|
+
* These carry a scope; the daily brief (and any route not recognized here) does
|
|
70
|
+
* not.
|
|
71
|
+
*/
|
|
72
|
+
export function isScopedRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
73
|
+
return (
|
|
74
|
+
isFlaggedRoute(matches) || isOutboxRoute(matches) || isMailboxRoute(matches)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The scope of the active route.
|
|
80
|
+
*
|
|
81
|
+
* `mailboxName` is the sidebar's own label for the mailbox route (resolved by
|
|
82
|
+
* `useCurrentMailboxName`). Until it resolves the scope is `pending`: no chip,
|
|
83
|
+
* because a chip reading a raw uuid is worse than none, but not `global`
|
|
84
|
+
* either, because the list underneath is already narrowed.
|
|
85
|
+
*/
|
|
86
|
+
export function searchScopeForRoute(
|
|
87
|
+
matches: readonly MailRouteMatch[],
|
|
88
|
+
mailboxName?: string | null,
|
|
89
|
+
): SearchScopeState {
|
|
90
|
+
if (isBriefRoute(matches)) return { kind: "global" };
|
|
91
|
+
if (isFlaggedRoute(matches)) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "scoped",
|
|
94
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (isOutboxRoute(matches)) {
|
|
98
|
+
return {
|
|
99
|
+
kind: "scoped",
|
|
100
|
+
chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (isMailboxRoute(matches)) {
|
|
104
|
+
if (!mailboxName) return { kind: "pending" };
|
|
105
|
+
return {
|
|
106
|
+
kind: "scoped",
|
|
107
|
+
chip: {
|
|
108
|
+
id: SEARCH_SCOPE_CHIP_ID,
|
|
109
|
+
label: scopeLabelForMailboxName(mailboxName),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { kind: "global" };
|
|
114
|
+
}
|
|
@@ -109,3 +109,97 @@ describe("shouldMirrorQuery", () => {
|
|
|
109
109
|
assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The three rules compose into the shell's behaviour (`routes/mail.tsx`): the
|
|
115
|
+
* field is re-seeded during render on a view change, and the mirror decides
|
|
116
|
+
* separately whether to write back. These check the composition, because the
|
|
117
|
+
* two ways search ends — a view change (#47) and the sidebar's own clear —
|
|
118
|
+
* both have to end it without ever writing over what the user is typing.
|
|
119
|
+
*/
|
|
120
|
+
interface Shell {
|
|
121
|
+
viewKey: string;
|
|
122
|
+
field: string;
|
|
123
|
+
debounced: string;
|
|
124
|
+
url: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One render of the shell for a location, returning the state it settles on. */
|
|
128
|
+
const render = (shell: Shell, viewKey: string, url: string): Shell => {
|
|
129
|
+
const field =
|
|
130
|
+
viewKey === shell.viewKey
|
|
131
|
+
? shell.field
|
|
132
|
+
: (searchInputForView(shell.viewKey, viewKey, url) ?? shell.field);
|
|
133
|
+
return { ...shell, viewKey, field, url };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** What the mirror effect would do after that render. */
|
|
137
|
+
const mirror = (shell: Shell): Shell => {
|
|
138
|
+
const committed = committedSearchQuery(shell.field, shell.debounced);
|
|
139
|
+
if (!shouldMirrorQuery(shell.field, committed, shell.url)) return shell;
|
|
140
|
+
return { ...shell, url: committed };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const typing = (shell: Shell, text: string): Shell => ({
|
|
144
|
+
...shell,
|
|
145
|
+
field: text,
|
|
146
|
+
});
|
|
147
|
+
const settle = (shell: Shell): Shell => ({ ...shell, debounced: shell.field });
|
|
148
|
+
|
|
149
|
+
describe("search across a view change", () => {
|
|
150
|
+
const searching: Shell = {
|
|
151
|
+
viewKey: mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
152
|
+
field: "invoice",
|
|
153
|
+
debounced: "invoice",
|
|
154
|
+
url: "invoice",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("ends the search when the user leaves the view", () => {
|
|
158
|
+
// The nav link drops `q`, so the destination carries none.
|
|
159
|
+
const next = mirror(
|
|
160
|
+
render(searching, mailViewKey(matches("/mail/$mailboxId", "sent-1")), ""),
|
|
161
|
+
);
|
|
162
|
+
assert.equal(next.field, "");
|
|
163
|
+
assert.equal(next.url, "");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("does not put the query it just left onto the view it landed on", () => {
|
|
167
|
+
// The debounce still holds "invoice" for up to 200ms after the move. The
|
|
168
|
+
// mirror must not write it back — that is #47 returning by another route.
|
|
169
|
+
const landed = render(
|
|
170
|
+
searching,
|
|
171
|
+
mailViewKey(matches("/mail/$mailboxId", "sent-1")),
|
|
172
|
+
"",
|
|
173
|
+
);
|
|
174
|
+
assert.equal(landed.debounced, "invoice");
|
|
175
|
+
assert.equal(mirror(landed).url, "");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps a query the destination carries (scope chip removed)", () => {
|
|
179
|
+
// Dropping the scope chip navigates to the brief carrying the query, so
|
|
180
|
+
// the same words are searched with a wider scope.
|
|
181
|
+
const next = mirror(
|
|
182
|
+
render(searching, mailViewKey(matches("/mail/")), "invoice"),
|
|
183
|
+
);
|
|
184
|
+
assert.equal(next.field, "invoice");
|
|
185
|
+
assert.equal(next.url, "invoice");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("never clobbers characters the user is still typing", () => {
|
|
189
|
+
// Opening a result and the q-mirror both re-render the same view. Neither
|
|
190
|
+
// is a view change, so neither may reach into the field.
|
|
191
|
+
const mailbox = mailViewKey(matches("/mail/$mailboxId", "inbox-1"));
|
|
192
|
+
let shell = typing(
|
|
193
|
+
{ ...searching, field: "", debounced: "", url: "" },
|
|
194
|
+
"i",
|
|
195
|
+
);
|
|
196
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
197
|
+
shell = typing(shell, "inv");
|
|
198
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
199
|
+
shell = typing(shell, "invoice");
|
|
200
|
+
shell = settle(mirror(render(shell, mailbox, shell.url)));
|
|
201
|
+
shell = mirror(render(shell, mailbox, shell.url));
|
|
202
|
+
assert.equal(shell.field, "invoice");
|
|
203
|
+
assert.equal(shell.url, "invoice");
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/routes/mail.tsx
CHANGED
|
@@ -98,8 +98,8 @@ function MailLayout() {
|
|
|
98
98
|
|
|
99
99
|
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
100
100
|
// target: the debounced local value drives the search API and is mirrored
|
|
101
|
-
// back. Across views the URL wins again — see the view-change
|
|
102
|
-
// and `lib/search-view.ts` (#47).
|
|
101
|
+
// back. Across views the URL wins again — see the view-change adjustment
|
|
102
|
+
// below and `lib/search-view.ts` (#47).
|
|
103
103
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
104
104
|
const debouncedSearchInput = useDebouncedValue(searchInput, 200);
|
|
105
105
|
const committedQuery = committedSearchQuery(
|
|
@@ -110,11 +110,34 @@ function MailLayout() {
|
|
|
110
110
|
const searchQueryRef = useRef(searchQuery);
|
|
111
111
|
searchQueryRef.current = searchQuery;
|
|
112
112
|
|
|
113
|
+
// Search is a mode of the view it was typed in, so leaving that view re-seeds
|
|
114
|
+
// the field from wherever we land (#47): empty when the sidebar dropped `q`
|
|
115
|
+
// (a folder switch starts that folder's search fresh), and the carried query
|
|
116
|
+
// when the top bar's scope chip was removed and sent the user to the brief to
|
|
117
|
+
// search everything. Views that differ only in search params — opening a
|
|
118
|
+
// result, mirroring `q` — are the same view, so in-flight typing survives
|
|
119
|
+
// them (`searchInputForView`).
|
|
120
|
+
//
|
|
121
|
+
// Adjusted during render, not in an effect. This is React's documented
|
|
122
|
+
// "adjusting state when a prop changes" pattern: both updates are to this
|
|
123
|
+
// component's own state and are guarded by a changed value, so React re-runs
|
|
124
|
+
// the render before committing and nothing is painted with the stale query.
|
|
125
|
+
// An effect would commit one frame carrying the previous view's text, which
|
|
126
|
+
// the mirror below then has to be defended against.
|
|
127
|
+
const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
|
|
128
|
+
const [searchViewKey, setSearchViewKey] = useState(viewKey);
|
|
129
|
+
if (searchViewKey !== viewKey) {
|
|
130
|
+
const seeded = searchInputForView(searchViewKey, viewKey, searchQuery);
|
|
131
|
+
setSearchViewKey(viewKey);
|
|
132
|
+
if (seeded !== undefined) setSearchInput(seeded);
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
// Mirror the settled search into the URL so links are shareable and a
|
|
114
136
|
// refresh restores the query. Within a view this is one-directional — the
|
|
115
137
|
// URL is not read back into state, so there is no sync loop — and it writes
|
|
116
138
|
// only once the debounce agrees with the field, so a query arriving by URL
|
|
117
|
-
// is never overwritten mid-debounce
|
|
139
|
+
// is never overwritten mid-debounce, and the query the user just left behind
|
|
140
|
+
// is never written onto the view they landed on (`shouldMirrorQuery`).
|
|
118
141
|
// When a query *goes* active, also strip selectedMessageId so the reading
|
|
119
142
|
// pane closes (#539): an open message from the pre-search list is not
|
|
120
143
|
// meaningful in the search result set. Only strip on that transition though —
|
|
@@ -147,24 +170,6 @@ function MailLayout() {
|
|
|
147
170
|
});
|
|
148
171
|
}, [searchInput, committedQuery, navigate]);
|
|
149
172
|
|
|
150
|
-
// Search is a mode of the view it was typed in, so leaving that view re-seeds
|
|
151
|
-
// the field from wherever we land. The nav links already drop `q` when they
|
|
152
|
-
// switch mailbox, so this clears the query instead of silently re-running it
|
|
153
|
-
// against the new mailbox (#47), while a deep link or a saved search that
|
|
154
|
-
// carries `q` still arrives searching.
|
|
155
|
-
const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
|
|
156
|
-
const viewKeyRef = useRef(viewKey);
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
const next = searchInputForView(
|
|
159
|
-
viewKeyRef.current,
|
|
160
|
-
viewKey,
|
|
161
|
-
searchQueryRef.current,
|
|
162
|
-
);
|
|
163
|
-
viewKeyRef.current = viewKey;
|
|
164
|
-
if (next === undefined) return;
|
|
165
|
-
setSearchInput(next);
|
|
166
|
-
}, [viewKey]);
|
|
167
|
-
|
|
168
173
|
const {
|
|
169
174
|
data: config,
|
|
170
175
|
isLoading,
|