@remit/web-client 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/FlaggedList.tsx +12 -32
- package/src/components/mail/FlaggedPane.tsx +12 -11
- package/src/components/mail/MailListHeader.tsx +30 -8
- package/src/components/mail/MailViewChrome.tsx +7 -0
- package/src/components/mail/MailboxPane.tsx +12 -4
- package/src/hooks/useStarredThreads.test.ts +143 -0
- package/src/hooks/useStarredThreads.ts +78 -0
- package/src/lib/mail-context.ts +8 -1
- package/src/lib/mail-route.ts +17 -0
- package/src/lib/search-view.test.ts +111 -0
- package/src/lib/search-view.ts +54 -0
- package/src/routes/mail.tsx +52 -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.18",
|
|
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": {
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlaggedList — a FLAT, cross-account inbox of starred mail.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Reads the starred listing through `useStarredThreads` — GET /threads with
|
|
5
|
+
* `starred=true`, served by the `byStarred` index — which returns every starred
|
|
6
|
+
* thread in the config across all non-muted mailboxes, paged. `FlaggedPane`
|
|
7
|
+
* resolves the open thread from that same hook, so every row rendered here can
|
|
8
|
+
* be opened. Starredness is decided server-side from `hasStars`; the client
|
|
9
|
+
* neither re-filters nor caps the set, so a starred thread outside the newest
|
|
10
|
+
* inbox page still appears. Rendered as one continuous list (no category
|
|
9
11
|
* sections). The shared `MailViewChrome` owns the `MailHeader` + filter
|
|
10
12
|
* expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
|
|
11
13
|
* / empty / error chrome and keyboard hints, with a consumer-supplied
|
|
12
14
|
* `listBody` so the real rows render at every width.
|
|
13
15
|
*/
|
|
14
|
-
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
-
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
16
16
|
import {
|
|
17
17
|
ComfortableRow,
|
|
18
18
|
flaggedFilterConfig,
|
|
19
19
|
MessageListPane,
|
|
20
20
|
type ThreadRowData,
|
|
21
21
|
} from "@remit/ui";
|
|
22
|
-
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
23
22
|
import { useCallback, useMemo, useState } from "react";
|
|
24
23
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
25
24
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
25
|
+
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
26
26
|
import {
|
|
27
27
|
matchesBriefSearch,
|
|
28
28
|
matchesSearchTokens,
|
|
@@ -72,7 +72,7 @@ export function FlaggedList({
|
|
|
72
72
|
}, []);
|
|
73
73
|
|
|
74
74
|
const {
|
|
75
|
-
|
|
75
|
+
threads,
|
|
76
76
|
isLoading,
|
|
77
77
|
isError,
|
|
78
78
|
error,
|
|
@@ -80,25 +80,7 @@ export function FlaggedList({
|
|
|
80
80
|
fetchNextPage,
|
|
81
81
|
hasNextPage,
|
|
82
82
|
isFetchingNextPage,
|
|
83
|
-
} =
|
|
84
|
-
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({
|
|
85
|
-
query: { starred: true, order: "desc" },
|
|
86
|
-
}),
|
|
87
|
-
queryFn: async ({ pageParam }) => {
|
|
88
|
-
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
89
|
-
query: {
|
|
90
|
-
starred: true,
|
|
91
|
-
order: "desc",
|
|
92
|
-
continuationToken: pageParam,
|
|
93
|
-
},
|
|
94
|
-
throwOnError: true,
|
|
95
|
-
});
|
|
96
|
-
return data;
|
|
97
|
-
},
|
|
98
|
-
initialPageParam: undefined as string | undefined,
|
|
99
|
-
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
100
|
-
staleTime: 60_000,
|
|
101
|
-
});
|
|
83
|
+
} = useStarredThreads();
|
|
102
84
|
|
|
103
85
|
const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
|
|
104
86
|
searchQuery.trim().toLowerCase(),
|
|
@@ -109,9 +91,7 @@ export function FlaggedList({
|
|
|
109
91
|
const predicates = Array.from(activeFilters)
|
|
110
92
|
.map((id) => FILTER_PREDICATES[id])
|
|
111
93
|
.filter((p): p is (t: ThreadRowData) => boolean => p != null);
|
|
112
|
-
return dedupeByThread(
|
|
113
|
-
(threadsData?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
114
|
-
)
|
|
94
|
+
return dedupeByThread(threads)
|
|
115
95
|
.map(toThreadRowData)
|
|
116
96
|
.filter(
|
|
117
97
|
(t) =>
|
|
@@ -120,7 +100,7 @@ export function FlaggedList({
|
|
|
120
100
|
(!sq || matchesBriefSearch(t, sq)) &&
|
|
121
101
|
matchesSearchTokens(t, queryTokens),
|
|
122
102
|
);
|
|
123
|
-
}, [
|
|
103
|
+
}, [threads, selectedCategory, activeFilters, sq, queryTokens]);
|
|
124
104
|
|
|
125
105
|
const preset = useMemo(() => flaggedFilterConfig(), []);
|
|
126
106
|
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* FlaggedPane — compound component for the Flagged virtual mailbox
|
|
3
3
|
* (/mail/flagged route).
|
|
4
4
|
*
|
|
5
|
-
* Mirrors BriefPane: it
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Mirrors BriefPane in shape: it resolves the open thread and owns the list /
|
|
6
|
+
* reading / phone slots. The list itself is a FLAT inbox of starred mail (see
|
|
7
|
+
* `FlaggedList`), not the sectioned brief.
|
|
8
|
+
*
|
|
9
|
+
* The selection resolves from the starred listing, the same query that produced
|
|
10
|
+
* the rows. The unified listing is INBOX-scoped, so resolving against it left
|
|
11
|
+
* every starred thread filed elsewhere — Sent, an archive folder, anything past
|
|
12
|
+
* the inbox window — visible in the list but impossible to open (issue #70).
|
|
8
13
|
*
|
|
9
14
|
* <FlaggedPane selectedMessageId={...}>
|
|
10
15
|
* <AppShellSlotted
|
|
@@ -15,10 +20,8 @@
|
|
|
15
20
|
*
|
|
16
21
|
* On phone, use `<FlaggedPane.Phone />` instead.
|
|
17
22
|
*/
|
|
18
|
-
import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
19
23
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
20
24
|
import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
|
|
21
|
-
import { useQuery } from "@tanstack/react-query";
|
|
22
25
|
import { useNavigate } from "@tanstack/react-router";
|
|
23
26
|
import {
|
|
24
27
|
createContext,
|
|
@@ -32,6 +35,7 @@ import { ConversationView } from "@/components/mail/ConversationView";
|
|
|
32
35
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
33
36
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
34
37
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
38
|
+
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
35
39
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
40
|
|
|
37
41
|
/* ------------------------------------------------------------------ */
|
|
@@ -68,15 +72,12 @@ function FlaggedPaneProvider({
|
|
|
68
72
|
}: FlaggedPaneProps) {
|
|
69
73
|
const navigate = useNavigate();
|
|
70
74
|
|
|
71
|
-
const {
|
|
72
|
-
...unifiedThreadOperationsListAllThreadsOptions(),
|
|
73
|
-
staleTime: 60_000,
|
|
74
|
-
});
|
|
75
|
+
const { threads } = useStarredThreads();
|
|
75
76
|
|
|
76
77
|
const selectedThread = useMemo(() => {
|
|
77
78
|
if (!selectedMessageId) return undefined;
|
|
78
|
-
return
|
|
79
|
-
}, [
|
|
79
|
+
return threads.find((t) => t.messageId === selectedMessageId);
|
|
80
|
+
}, [threads, selectedMessageId]);
|
|
80
81
|
|
|
81
82
|
const handleSelectMessage = useCallback(
|
|
82
83
|
(id: string) => {
|
|
@@ -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,7 +32,7 @@ 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";
|
|
35
37
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
38
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
@@ -56,6 +58,13 @@ interface MailListHeaderProps {
|
|
|
56
58
|
relatedResults?: SearchResult[];
|
|
57
59
|
relatedLoading?: boolean;
|
|
58
60
|
onSelectSearchResult?: (result: SearchResult) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Section headings. A view whose two engines cover different ground names
|
|
63
|
+
* them by that ground ("In Archive" / "Everywhere") so the reach of a result
|
|
64
|
+
* is on screen rather than inferred.
|
|
65
|
+
*/
|
|
66
|
+
searchResultsLabel?: string;
|
|
67
|
+
relatedResultsLabel?: string;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export function MailListHeader({
|
|
@@ -69,11 +78,14 @@ export function MailListHeader({
|
|
|
69
78
|
relatedResults,
|
|
70
79
|
relatedLoading,
|
|
71
80
|
onSelectSearchResult,
|
|
81
|
+
searchResultsLabel = "Top matches",
|
|
82
|
+
relatedResultsLabel = "Related",
|
|
72
83
|
}: MailListHeaderProps) {
|
|
73
84
|
const {
|
|
74
85
|
searchInput,
|
|
75
86
|
onSearchChange,
|
|
76
87
|
onSearchClear,
|
|
88
|
+
searchViewKey,
|
|
77
89
|
mailboxNameIndex,
|
|
78
90
|
accountNameIndex,
|
|
79
91
|
} = useMailContext();
|
|
@@ -82,6 +94,16 @@ export function MailListHeader({
|
|
|
82
94
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
83
95
|
const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
|
|
84
96
|
|
|
97
|
+
// Leaving the view ends the search: the shell drops the query, and the chrome
|
|
98
|
+
// it opened — the phone takeover, the expanded tablet field — closes with it
|
|
99
|
+
// rather than sitting there empty over the new mailbox (#47).
|
|
100
|
+
const searchViewRef = useRef(searchViewKey);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (searchViewRef.current === searchViewKey) return;
|
|
103
|
+
searchViewRef.current = searchViewKey;
|
|
104
|
+
setSearchOpen(false);
|
|
105
|
+
}, [searchViewKey]);
|
|
106
|
+
|
|
85
107
|
const hasQuery = searchInput.trim().length > 0;
|
|
86
108
|
// Filter tokens (`from:`, `has:attachment`, `in:`, `account:`, …) parsed live
|
|
87
109
|
// from the typed query render as removable chips above the sections;
|
|
@@ -102,8 +124,8 @@ export function MailListHeader({
|
|
|
102
124
|
// state. The empty-query case (recent searches) is the kit's job.
|
|
103
125
|
const sections: SearchResultSection[] = hasQuery
|
|
104
126
|
? [
|
|
105
|
-
{ id: "top", label:
|
|
106
|
-
{ id: "related", label:
|
|
127
|
+
{ id: "top", label: searchResultsLabel, results: topMatches },
|
|
128
|
+
{ id: "related", label: relatedResultsLabel, results: related },
|
|
107
129
|
]
|
|
108
130
|
: [];
|
|
109
131
|
// Skeleton only while nothing is in yet — once either section has rows, show
|
|
@@ -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>
|
|
@@ -949,10 +949,13 @@ function MailboxList() {
|
|
|
949
949
|
() => threads.map(threadToSearchResult),
|
|
950
950
|
[threads],
|
|
951
951
|
);
|
|
952
|
-
//
|
|
953
|
-
// literal
|
|
952
|
+
// The field above every pane says "Search all mail", and it means it (#47).
|
|
953
|
+
// The literal engine only searches one mailbox at a time (there is no
|
|
954
|
+
// cross-mailbox thread search), so this view names that section after the
|
|
955
|
+
// mailbox and lets the semantic engine run unscoped — the "Everywhere"
|
|
956
|
+
// section reaches the rest of the mail. Results are deduped by thread, so a
|
|
957
|
+
// thread never shows in both.
|
|
954
958
|
const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch({
|
|
955
|
-
mailboxId,
|
|
956
959
|
filterCategory,
|
|
957
960
|
});
|
|
958
961
|
const relatedResults = useMemo(
|
|
@@ -967,7 +970,10 @@ function MailboxList() {
|
|
|
967
970
|
(result: SearchResult) =>
|
|
968
971
|
navigate({
|
|
969
972
|
to: "/mail/$mailboxId",
|
|
970
|
-
|
|
973
|
+
// A hit from the unscoped "Everywhere" section lives in another
|
|
974
|
+
// mailbox; open it there, so the list, the toolbar's verbs and the
|
|
975
|
+
// message all belong to the same mailbox.
|
|
976
|
+
params: { mailboxId: result.mailboxId ?? mailboxId },
|
|
971
977
|
search: (prev: Record<string, unknown>) => ({
|
|
972
978
|
...prev,
|
|
973
979
|
// Commit the active query alongside the selection. The debounced
|
|
@@ -1061,6 +1067,8 @@ function MailboxList() {
|
|
|
1061
1067
|
relatedResults={relatedResults}
|
|
1062
1068
|
relatedLoading={relatedLoading}
|
|
1063
1069
|
onSelectSearchResult={handleSelectSearchResult}
|
|
1070
|
+
searchResultsLabel={`In ${listTitle}`}
|
|
1071
|
+
relatedResultsLabel="Everywhere"
|
|
1064
1072
|
>
|
|
1065
1073
|
{body}
|
|
1066
1074
|
</MailViewChrome>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useStarredThreads — regression cover for issue #70.
|
|
3
|
+
*
|
|
4
|
+
* The Starred pane rendered its rows from `GET /threads?starred=true` but
|
|
5
|
+
* resolved the open thread from the unfiltered (INBOX-scoped) listing. Rows for
|
|
6
|
+
* starred mail filed outside INBOX therefore had nothing to resolve to and
|
|
7
|
+
* opened no reading pane.
|
|
8
|
+
*
|
|
9
|
+
* Strategy follows useRescueCandidates.test.ts: pre-seed a QueryClient under the
|
|
10
|
+
* key the hook generates and render it synchronously, so the queryFn never
|
|
11
|
+
* fires. The last two cases are the ones that fail if the wrong listing comes
|
|
12
|
+
* back — the unfiltered key is seeded alongside and must not be read.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { describe, test } from "node:test";
|
|
17
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
18
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
20
|
+
import { createElement } from "react";
|
|
21
|
+
import { renderToString } from "react-dom/server";
|
|
22
|
+
import {
|
|
23
|
+
starredThreadsQueryKey,
|
|
24
|
+
useStarredThreads,
|
|
25
|
+
} from "./useStarredThreads.js";
|
|
26
|
+
|
|
27
|
+
interface Page {
|
|
28
|
+
items: RemitImapThreadMessageResponse[];
|
|
29
|
+
continuationToken?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeThread(
|
|
33
|
+
messageId: string,
|
|
34
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
35
|
+
): RemitImapThreadMessageResponse {
|
|
36
|
+
return {
|
|
37
|
+
messageId,
|
|
38
|
+
threadMessageId: `tm-${messageId}`,
|
|
39
|
+
threadId: `t-${messageId}`,
|
|
40
|
+
accountConfigId: "acc",
|
|
41
|
+
mailboxId: "mb-inbox",
|
|
42
|
+
sentDate: 1_000_000,
|
|
43
|
+
isRead: false,
|
|
44
|
+
hasAttachment: false,
|
|
45
|
+
hasStars: true,
|
|
46
|
+
isDeleted: false,
|
|
47
|
+
createdAt: 1_000_000,
|
|
48
|
+
updatedAt: 1_000_000,
|
|
49
|
+
fromName: "Anna de Vries",
|
|
50
|
+
fromEmail: "anna@example.nl",
|
|
51
|
+
subject: `subject ${messageId}`,
|
|
52
|
+
snippet: "…",
|
|
53
|
+
senderTrust: "vip",
|
|
54
|
+
...overrides,
|
|
55
|
+
} as RemitImapThreadMessageResponse;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Renders the hook against a cache holding `starredPages` under the starred key
|
|
60
|
+
* and `unfilteredItems` under the unfiltered one, so a hook reading the wrong
|
|
61
|
+
* listing returns the wrong threads instead of returning nothing.
|
|
62
|
+
*/
|
|
63
|
+
function renderHook(
|
|
64
|
+
starredPages: Page[],
|
|
65
|
+
unfilteredItems: RemitImapThreadMessageResponse[] = [],
|
|
66
|
+
): RemitImapThreadMessageResponse[] {
|
|
67
|
+
const client = new QueryClient({
|
|
68
|
+
defaultOptions: { queries: { retry: false } },
|
|
69
|
+
});
|
|
70
|
+
client.setQueryData(starredThreadsQueryKey(), {
|
|
71
|
+
pages: starredPages,
|
|
72
|
+
pageParams: starredPages.map((_, index) =>
|
|
73
|
+
index === 0 ? undefined : `page-${index}`,
|
|
74
|
+
),
|
|
75
|
+
});
|
|
76
|
+
client.setQueryData(unifiedThreadOperationsListAllThreadsQueryKey(), {
|
|
77
|
+
items: unfilteredItems,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
let captured: RemitImapThreadMessageResponse[] = [];
|
|
81
|
+
|
|
82
|
+
function Capture() {
|
|
83
|
+
captured = useStarredThreads().threads;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
renderToString(
|
|
88
|
+
createElement(
|
|
89
|
+
QueryClientProvider,
|
|
90
|
+
{ client },
|
|
91
|
+
createElement(Capture),
|
|
92
|
+
) as never,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return captured;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("starredThreadsQueryKey", () => {
|
|
99
|
+
test("asks for the starred listing, newest first", () => {
|
|
100
|
+
const [params] = starredThreadsQueryKey() as [
|
|
101
|
+
{ query?: { starred?: boolean; order?: string } },
|
|
102
|
+
];
|
|
103
|
+
assert.equal(params.query?.starred, true);
|
|
104
|
+
assert.equal(params.query?.order, "desc");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("useStarredThreads", () => {
|
|
109
|
+
test("returns no threads when the listing is empty", () => {
|
|
110
|
+
assert.deepEqual(renderHook([{ items: [] }]), []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("flattens every loaded page, in page order", () => {
|
|
114
|
+
const threads = renderHook([
|
|
115
|
+
{ items: [makeThread("m1"), makeThread("m2")], continuationToken: "p2" },
|
|
116
|
+
{ items: [makeThread("m3")] },
|
|
117
|
+
]);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
threads.map((thread) => thread.messageId),
|
|
120
|
+
["m1", "m2", "m3"],
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("returns a starred thread that lives outside INBOX", () => {
|
|
125
|
+
// The case issue #70 could not open: starred, filed in Sent, and absent
|
|
126
|
+
// from the unfiltered listing the pane used to resolve against.
|
|
127
|
+
const sent = makeThread("m-sent", { mailboxId: "mb-sent" });
|
|
128
|
+
const threads = renderHook([{ items: [sent] }], [makeThread("m-inbox")]);
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
threads.map((thread) => thread.messageId),
|
|
131
|
+
["m-sent"],
|
|
132
|
+
);
|
|
133
|
+
assert.equal(
|
|
134
|
+
threads.find((thread) => thread.messageId === "m-sent")?.mailboxId,
|
|
135
|
+
"mb-sent",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("does not read the unfiltered INBOX listing", () => {
|
|
140
|
+
const threads = renderHook([{ items: [] }], [makeThread("m-inbox")]);
|
|
141
|
+
assert.deepEqual(threads, []);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The starred listing — `GET /threads?starred=true`, served by the `byStarred`
|
|
3
|
+
* index — as one hook, so the Starred pane's list and its selection resolve
|
|
4
|
+
* from the same query.
|
|
5
|
+
*
|
|
6
|
+
* The two are the same cache entry, not two requests: they share a query key,
|
|
7
|
+
* so `threads` here is exactly the set of rows the list rendered. Resolving a
|
|
8
|
+
* selection from any other listing reintroduces issue #70 — the INBOX-scoped
|
|
9
|
+
* unified listing cannot see a starred thread filed elsewhere, so its row is
|
|
10
|
+
* clickable but resolves to nothing and no reading pane opens.
|
|
11
|
+
*/
|
|
12
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
13
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
14
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
|
|
18
|
+
export const starredThreadsQueryKey = () =>
|
|
19
|
+
unifiedThreadOperationsListAllThreadsQueryKey({
|
|
20
|
+
query: { starred: true, order: "desc" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
interface StarredThreads {
|
|
24
|
+
/** Every starred thread across the pages loaded so far, newest first. */
|
|
25
|
+
threads: RemitImapThreadMessageResponse[];
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
isError: boolean;
|
|
28
|
+
error: unknown;
|
|
29
|
+
refetch: () => void;
|
|
30
|
+
fetchNextPage: () => void;
|
|
31
|
+
hasNextPage: boolean;
|
|
32
|
+
isFetchingNextPage: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useStarredThreads(): StarredThreads {
|
|
36
|
+
const {
|
|
37
|
+
data,
|
|
38
|
+
isLoading,
|
|
39
|
+
isError,
|
|
40
|
+
error,
|
|
41
|
+
refetch,
|
|
42
|
+
fetchNextPage,
|
|
43
|
+
hasNextPage,
|
|
44
|
+
isFetchingNextPage,
|
|
45
|
+
} = useInfiniteQuery({
|
|
46
|
+
queryKey: starredThreadsQueryKey(),
|
|
47
|
+
queryFn: async ({ pageParam }) => {
|
|
48
|
+
const { data: page } = await unifiedThreadOperationsListAllThreads({
|
|
49
|
+
query: {
|
|
50
|
+
starred: true,
|
|
51
|
+
order: "desc",
|
|
52
|
+
continuationToken: pageParam,
|
|
53
|
+
},
|
|
54
|
+
throwOnError: true,
|
|
55
|
+
});
|
|
56
|
+
return page;
|
|
57
|
+
},
|
|
58
|
+
initialPageParam: undefined as string | undefined,
|
|
59
|
+
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
60
|
+
staleTime: 60_000,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const threads = useMemo(
|
|
64
|
+
() => (data?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
65
|
+
[data],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
threads,
|
|
70
|
+
isLoading,
|
|
71
|
+
isError,
|
|
72
|
+
error,
|
|
73
|
+
refetch,
|
|
74
|
+
fetchNextPage,
|
|
75
|
+
hasNextPage,
|
|
76
|
+
isFetchingNextPage,
|
|
77
|
+
};
|
|
78
|
+
}
|
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: () => {},
|
package/src/lib/mail-route.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
/** A matched route, minimal shape needed for pane detection. */
|
|
16
16
|
export interface MailRouteMatch {
|
|
17
17
|
routeId: string;
|
|
18
|
+
params?: Record<string, string | undefined>;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/** The leaf route ids the /mail shell branches on. */
|
|
@@ -42,3 +43,19 @@ export function isOutboxRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
|
42
43
|
export function isMailboxRoute(matches: readonly MailRouteMatch[]): boolean {
|
|
43
44
|
return matches.some((m) => m.routeId === MAIL_MAILBOX_ROUTE_ID);
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Identity of the list view the shell is showing — one mailbox, the brief, the
|
|
49
|
+
* flagged list, or the outbox. Two locations share a key when they differ only
|
|
50
|
+
* in search params (opening a result, mirroring `q`), so opening a hit is not a
|
|
51
|
+
* view change while switching mailbox is. `lib/search-view.ts` re-seeds the
|
|
52
|
+
* search field whenever this changes.
|
|
53
|
+
*/
|
|
54
|
+
export function mailViewKey(matches: readonly MailRouteMatch[]): string {
|
|
55
|
+
const mailboxId = matches.find((m) => m.params?.mailboxId)?.params?.mailboxId;
|
|
56
|
+
if (mailboxId) return `${MAIL_MAILBOX_ROUTE_ID}:${mailboxId}`;
|
|
57
|
+
if (isFlaggedRoute(matches)) return MAIL_FLAGGED_ROUTE_ID;
|
|
58
|
+
if (isOutboxRoute(matches)) return MAIL_OUTBOX_ROUTE_ID;
|
|
59
|
+
if (isBriefRoute(matches)) return MAIL_BRIEF_ROUTE_ID;
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression (#47): the search query must not survive a move to another
|
|
3
|
+
* mailbox. The /mail shell holds the field state and never unmounts between
|
|
4
|
+
* child routes, so without these rules the text — and the query it drives —
|
|
5
|
+
* followed the user from inbox to inbox.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { type MailRouteMatch, mailViewKey } from "./mail-route.js";
|
|
10
|
+
import {
|
|
11
|
+
committedSearchQuery,
|
|
12
|
+
searchInputForView,
|
|
13
|
+
shouldMirrorQuery,
|
|
14
|
+
} from "./search-view.js";
|
|
15
|
+
|
|
16
|
+
const matches = (routeId: string, mailboxId?: string): MailRouteMatch[] => [
|
|
17
|
+
{ routeId: "__root__" },
|
|
18
|
+
{ routeId: "/mail" },
|
|
19
|
+
{ routeId, ...(mailboxId ? { params: { mailboxId } } : {}) },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe("mailViewKey", () => {
|
|
23
|
+
it("distinguishes two mailboxes", () => {
|
|
24
|
+
assert.notEqual(
|
|
25
|
+
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
26
|
+
mailViewKey(matches("/mail/$mailboxId", "archive-1")),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("gives the same mailbox one key regardless of search params", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
33
|
+
mailViewKey(matches("/mail/$mailboxId", "inbox-1")),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("separates the brief, flagged and outbox views", () => {
|
|
38
|
+
const keys = [
|
|
39
|
+
mailViewKey(matches("/mail/")),
|
|
40
|
+
mailViewKey(matches("/mail/flagged")),
|
|
41
|
+
mailViewKey(matches("/mail/outbox")),
|
|
42
|
+
];
|
|
43
|
+
assert.equal(new Set(keys).size, 3);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("searchInputForView", () => {
|
|
48
|
+
it("clears the field when the destination carries no query", () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
searchInputForView(
|
|
51
|
+
"/mail/$mailboxId:inbox-1",
|
|
52
|
+
"/mail/$mailboxId:arch",
|
|
53
|
+
"",
|
|
54
|
+
),
|
|
55
|
+
"",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves the field alone while the view stays the same", () => {
|
|
60
|
+
// Opening a result and the q-mirror both re-render on the same view; the
|
|
61
|
+
// user's in-flight typing must survive both.
|
|
62
|
+
assert.equal(
|
|
63
|
+
searchInputForView(
|
|
64
|
+
"/mail/$mailboxId:inbox-1",
|
|
65
|
+
"/mail/$mailboxId:inbox-1",
|
|
66
|
+
"",
|
|
67
|
+
),
|
|
68
|
+
undefined,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("seeds from the destination's own query (deep link, saved search)", () => {
|
|
73
|
+
assert.equal(
|
|
74
|
+
searchInputForView("/mail/$mailboxId:inbox-1", "/mail/", "invoice"),
|
|
75
|
+
"invoice",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("committedSearchQuery", () => {
|
|
81
|
+
it("commits an empty field immediately, without waiting on the debounce", () => {
|
|
82
|
+
assert.equal(committedSearchQuery("", "stale query"), "");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("debounces everything else", () => {
|
|
86
|
+
assert.equal(committedSearchQuery("inv", ""), "");
|
|
87
|
+
assert.equal(committedSearchQuery("invoice", "inv"), "inv");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("shouldMirrorQuery", () => {
|
|
92
|
+
it("writes a settled query the URL does not have yet", () => {
|
|
93
|
+
assert.equal(shouldMirrorQuery("invoice", "invoice", ""), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("stays quiet once the URL already says it", () => {
|
|
97
|
+
assert.equal(shouldMirrorQuery("invoice", "invoice", "invoice"), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not strip the query a deep link just arrived with", () => {
|
|
101
|
+
// The field is seeded from the URL and the debounce has not caught up, so
|
|
102
|
+
// the committed query is still the previous view's. Writing it would drop
|
|
103
|
+
// `q` for the length of the debounce — and with it the search the link
|
|
104
|
+
// asked for.
|
|
105
|
+
assert.equal(shouldMirrorQuery("invoice", "", "invoice"), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("waits for the debounce while the user is still typing", () => {
|
|
109
|
+
assert.equal(shouldMirrorQuery("invoi", "inv", ""), false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search is a view-level mode, not a persistent global filter (#47).
|
|
3
|
+
*
|
|
4
|
+
* The query belongs to the location it was typed in: the URL's `q` is what a
|
|
5
|
+
* view is searching for, and the nav links already drop `q` when they switch
|
|
6
|
+
* mailbox. The search field, however, is local state in the /mail shell — which
|
|
7
|
+
* outlives every child route — so it kept the old text and went on querying the
|
|
8
|
+
* new mailbox with it (the confusing Mac Mail behaviour). These rules make the
|
|
9
|
+
* URL win on every view change: the field re-seeds from the location it lands
|
|
10
|
+
* on, so switching mailbox clears a stale query while a deep link or a saved
|
|
11
|
+
* search that carries `q` still arrives with the query intact.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The field text after a view transition, or `undefined` when nothing changes
|
|
16
|
+
* (same view — typing, opening a result, mirroring `q` back to the URL).
|
|
17
|
+
*/
|
|
18
|
+
export function searchInputForView(
|
|
19
|
+
previousViewKey: string,
|
|
20
|
+
viewKey: string,
|
|
21
|
+
urlQuery: string,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
if (previousViewKey === viewKey) return undefined;
|
|
24
|
+
return urlQuery;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The committed query the search APIs run. Typing debounces, but an empty field
|
|
29
|
+
* commits immediately — otherwise a view change fires one more request for the
|
|
30
|
+
* query the user just left behind.
|
|
31
|
+
*/
|
|
32
|
+
export function committedSearchQuery(
|
|
33
|
+
searchInput: string,
|
|
34
|
+
debouncedSearchInput: string,
|
|
35
|
+
): string {
|
|
36
|
+
return searchInput === "" ? "" : debouncedSearchInput;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the committed query may be written back to the URL. Only a settled
|
|
41
|
+
* one may: mid-debounce the committed value is the *previous* query, and
|
|
42
|
+
* writing it would overwrite the URL that a deep link or a saved search just
|
|
43
|
+
* arrived with — stripping `q` for as long as the debounce lasts, and taking
|
|
44
|
+
* the search with it. The mirror waits for the field and the committed query to
|
|
45
|
+
* agree, then writes only if the URL says something else.
|
|
46
|
+
*/
|
|
47
|
+
export function shouldMirrorQuery(
|
|
48
|
+
searchInput: string,
|
|
49
|
+
committedQuery: string,
|
|
50
|
+
urlQuery: string,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (committedQuery !== searchInput) return false;
|
|
53
|
+
return committedQuery !== urlQuery;
|
|
54
|
+
}
|
package/src/routes/mail.tsx
CHANGED
|
@@ -30,8 +30,18 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
|
30
30
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
31
31
|
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
32
32
|
import { MailContext } from "@/lib/mail-context";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
isBriefRoute,
|
|
35
|
+
isFlaggedRoute,
|
|
36
|
+
isOutboxRoute,
|
|
37
|
+
mailViewKey,
|
|
38
|
+
} from "@/lib/mail-route";
|
|
34
39
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
40
|
+
import {
|
|
41
|
+
committedSearchQuery,
|
|
42
|
+
searchInputForView,
|
|
43
|
+
shouldMirrorQuery,
|
|
44
|
+
} from "@/lib/search-view";
|
|
35
45
|
import "@/lib/client";
|
|
36
46
|
|
|
37
47
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -86,19 +96,25 @@ function MailLayout() {
|
|
|
86
96
|
writeIntelligencePref(open);
|
|
87
97
|
}, []);
|
|
88
98
|
|
|
89
|
-
// URL `q`
|
|
90
|
-
// target
|
|
91
|
-
//
|
|
92
|
-
//
|
|
99
|
+
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
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 effect below
|
|
102
|
+
// and `lib/search-view.ts` (#47).
|
|
93
103
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
94
104
|
const debouncedSearchInput = useDebouncedValue(searchInput, 200);
|
|
105
|
+
const committedQuery = committedSearchQuery(
|
|
106
|
+
searchInput,
|
|
107
|
+
debouncedSearchInput,
|
|
108
|
+
);
|
|
95
109
|
|
|
96
110
|
const searchQueryRef = useRef(searchQuery);
|
|
97
111
|
searchQueryRef.current = searchQuery;
|
|
98
112
|
|
|
99
|
-
// Mirror the
|
|
100
|
-
// refresh restores the query.
|
|
101
|
-
// into state, so there is no sync loop
|
|
113
|
+
// Mirror the settled search into the URL so links are shareable and a
|
|
114
|
+
// refresh restores the query. Within a view this is one-directional — the
|
|
115
|
+
// URL is not read back into state, so there is no sync loop — and it writes
|
|
116
|
+
// only once the debounce agrees with the field, so a query arriving by URL
|
|
117
|
+
// is never overwritten mid-debounce (`shouldMirrorQuery`).
|
|
102
118
|
// When a query *goes* active, also strip selectedMessageId so the reading
|
|
103
119
|
// pane closes (#539): an open message from the pre-search list is not
|
|
104
120
|
// meaningful in the search result set. Only strip on that transition though —
|
|
@@ -108,16 +124,17 @@ function MailLayout() {
|
|
|
108
124
|
// the row shows before the debounce settles, so this mirror can land just
|
|
109
125
|
// after the open and close it again.
|
|
110
126
|
useEffect(() => {
|
|
111
|
-
if (
|
|
127
|
+
if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
|
|
128
|
+
return;
|
|
112
129
|
navigate({
|
|
113
130
|
to: ".",
|
|
114
131
|
search: (prev) => {
|
|
115
132
|
const queryAlreadyActive =
|
|
116
|
-
(prev as { q?: string }).q ===
|
|
133
|
+
(prev as { q?: string }).q === committedQuery;
|
|
117
134
|
return {
|
|
118
135
|
...prev,
|
|
119
|
-
q:
|
|
120
|
-
...(
|
|
136
|
+
q: committedQuery || undefined,
|
|
137
|
+
...(committedQuery && !queryAlreadyActive
|
|
121
138
|
? {
|
|
122
139
|
selectedMessageId: undefined,
|
|
123
140
|
selectedThreadId: undefined,
|
|
@@ -128,7 +145,25 @@ function MailLayout() {
|
|
|
128
145
|
},
|
|
129
146
|
replace: true,
|
|
130
147
|
});
|
|
131
|
-
}, [
|
|
148
|
+
}, [searchInput, committedQuery, navigate]);
|
|
149
|
+
|
|
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]);
|
|
132
167
|
|
|
133
168
|
const {
|
|
134
169
|
data: config,
|
|
@@ -250,10 +285,11 @@ function MailLayout() {
|
|
|
250
285
|
accounts,
|
|
251
286
|
mailboxNameIndex,
|
|
252
287
|
accountNameIndex,
|
|
253
|
-
//
|
|
254
|
-
// mirrored to the URL
|
|
255
|
-
searchQuery:
|
|
288
|
+
// The committed local value is the source of truth for search; it is
|
|
289
|
+
// mirrored to the URL for shareable links.
|
|
290
|
+
searchQuery: committedQuery,
|
|
256
291
|
searchInput,
|
|
292
|
+
searchViewKey: viewKey,
|
|
257
293
|
onSearchChange: handleSearchChange,
|
|
258
294
|
onSearchClear: handleSearchClear,
|
|
259
295
|
onSearchClearQuery: handleSearchClearQuery,
|