@remit/web-client 0.0.281 → 0.0.283

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.281",
3
+ "version": "0.0.283",
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": {
@@ -10,6 +10,7 @@ import {
10
10
  import { type FormEvent, type ReactNode, useState } from "react";
11
11
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
12
12
  import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
13
+ import { openBugReport } from "@/lib/bug-report";
13
14
  import { authFooterNote } from "./account-menu-mode";
14
15
  import {
15
16
  type AuthRequest,
@@ -22,6 +23,8 @@ import {
22
23
  isBetterAuthEnabled,
23
24
  isSignUpDisabledError,
24
25
  } from "./better-auth-config";
26
+ import { SessionRateLimited } from "./SessionRateLimited";
27
+ import { classifySessionQuery, type SessionQuery } from "./session-state";
25
28
 
26
29
  type Mode = "signIn" | "signUp";
27
30
 
@@ -189,10 +192,42 @@ export const SignInForm = () => {
189
192
  );
190
193
  };
191
194
 
192
- export const BetterAuthShell = ({ children }: BetterAuthShellProps) => {
193
- const { data: session, isPending } = authClient.useSession();
195
+ interface SessionGateProps {
196
+ session: SessionQuery;
197
+ onRetry: () => void;
198
+ children: ReactNode;
199
+ }
194
200
 
195
- if (isPending) return <AppShellSkeleton />;
196
- if (session) return <>{children}</>;
201
+ /**
202
+ * What the app shows for each state the session lookup can be in, separated
203
+ * from the hook that feeds it so every state can be driven directly.
204
+ */
205
+ export const SessionGate = ({
206
+ session,
207
+ onRetry,
208
+ children,
209
+ }: SessionGateProps) => {
210
+ const state = classifySessionQuery(session);
211
+
212
+ if (state.kind === "active") return <>{children}</>;
213
+ if (state.kind === "pending") return <AppShellSkeleton />;
214
+ if (state.kind === "rateLimited") {
215
+ return <SessionRateLimited onRetry={onRetry} onReport={openBugReport} />;
216
+ }
197
217
  return <SignInForm />;
198
218
  };
219
+
220
+ export const BetterAuthShell = ({ children }: BetterAuthShellProps) => {
221
+ const { data, isPending, error, refetch } = authClient.useSession();
222
+
223
+ return (
224
+ <SessionGate
225
+ session={{ data, isPending, error }}
226
+ onRetry={() => {
227
+ void refetch();
228
+ }}
229
+ >
230
+ {children}
231
+ </SessionGate>
232
+ );
233
+ };
@@ -0,0 +1,26 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { SessionRateLimited } from "./SessionRateLimited";
3
+
4
+ /**
5
+ * What a 429 on the session lookup looks like (#441). Before this state
6
+ * existed the gate fell through to the sign-in screen, so a throttled instance
7
+ * told everyone behind the address that they had been logged out.
8
+ */
9
+ const meta: Meta<typeof SessionRateLimited> = {
10
+ title: "Screens/SessionRateLimited",
11
+ component: SessionRateLimited,
12
+ parameters: { layout: "fullscreen" },
13
+ args: {
14
+ onRetry: () => undefined,
15
+ onReport: () => undefined,
16
+ },
17
+ };
18
+ export default meta;
19
+
20
+ type Story = StoryObj<typeof SessionRateLimited>;
21
+
22
+ export const Default: Story = {};
23
+
24
+ export const Dark: Story = {
25
+ parameters: { theme: "dark" },
26
+ };
@@ -0,0 +1,39 @@
1
+ import { AuthCard, AuthHero, Banner, Button } from "@remit/ui";
2
+
3
+ export interface SessionRateLimitedProps {
4
+ onRetry: () => void;
5
+ onReport: () => void;
6
+ }
7
+
8
+ /**
9
+ * The session could not be checked because the server is throttling this
10
+ * address. Shown instead of the sign-in screen, which would claim a sign-out
11
+ * that never happened (#441). The limits are keyed by IP, so a household, an
12
+ * office, a VPN exit or a handful of open tabs all spend one budget — the copy
13
+ * says so, because otherwise a single user cannot explain what they did wrong.
14
+ */
15
+ export const SessionRateLimited = ({
16
+ onRetry,
17
+ onReport,
18
+ }: SessionRateLimitedProps) => (
19
+ <AuthCard>
20
+ <AuthHero />
21
+ <div className="flex flex-col gap-4 rounded-lg border border-line bg-surface p-7 shadow-lg">
22
+ <Banner tone="warning">
23
+ <p className="font-medium">Too many requests from this address</p>
24
+ <p className="mt-1">
25
+ The server is limiting requests from your network address, so it could
26
+ not check whether you are signed in. You have not been signed out.
27
+ </p>
28
+ </Banner>
29
+ <p className="text-sm text-fg-muted">
30
+ Wait a minute and try again. Everyone behind the same address — other
31
+ people on this network, and every tab you have open — shares one limit.
32
+ </p>
33
+ <Button onClick={onRetry}>Try again</Button>
34
+ <Button variant="ghost" size="sm" onClick={onReport}>
35
+ Report an issue
36
+ </Button>
37
+ </div>
38
+ </AuthCard>
39
+ );
@@ -52,6 +52,17 @@ describe("classifyAuthError", () => {
52
52
  );
53
53
  });
54
54
 
55
+ it("treats a 429 as rate limited, not as a rejected credential (#441)", () => {
56
+ assert.equal(
57
+ classifyAuthError({
58
+ status: 429,
59
+ statusText: "Too Many Requests",
60
+ message: "Too many requests. Please try again later.",
61
+ }),
62
+ "rateLimited",
63
+ );
64
+ });
65
+
55
66
  it("treats a statusless failure as network", () => {
56
67
  assert.equal(classifyAuthError(new Error("Failed to fetch")), "network");
57
68
  });
@@ -82,6 +93,15 @@ describe("authInlineMessage", () => {
82
93
  it("gives a connection hint for a network error", () => {
83
94
  assert.match(authInlineMessage(new Error("boom"), "network"), /offline/i);
84
95
  });
96
+
97
+ it("says the limit is shared by address, over the server's bare wording", () => {
98
+ const message = authInlineMessage(
99
+ { status: 429, message: "Too many requests. Please try again later." },
100
+ "rateLimited",
101
+ );
102
+ assert.match(message, /this address/i);
103
+ assert.match(message, /wait a minute/i);
104
+ });
85
105
  });
86
106
 
87
107
  describe("buildFatalAuthMessage", () => {
@@ -9,12 +9,16 @@ import { reportFatalError } from "@/lib/fatal-error";
9
9
  * - "validation" — an expected, user-correctable rejection the server
10
10
  * described (email already registered, weak password, wrong
11
11
  * credentials). Inline, surfacing the server's own message.
12
+ * - "rateLimited" — a 429. Nothing is wrong with the credentials or the
13
+ * deployment; the address has spent its budget and the same
14
+ * attempt works after a wait. Inline, and named as its own
15
+ * thing so it is never read as a rejection (#441).
12
16
  * - "fatal" — a 404, or any other unexpected 4xx/5xx from our own API. The
13
17
  * deployment or the client/server contract is broken and there
14
18
  * is nothing the user can retry their way out of. Escalates to
15
19
  * the full-screen fatal page with a bug-report link.
16
20
  */
17
- export type AuthErrorClass = "network" | "validation" | "fatal";
21
+ export type AuthErrorClass = "network" | "validation" | "rateLimited" | "fatal";
18
22
 
19
23
  /** The request being attempted, for a fatal report that names method and path. */
20
24
  export interface AuthRequest {
@@ -25,6 +29,8 @@ export interface AuthRequest {
25
29
  /** better-auth's client sentinel for a fetch that never got an HTTP response. */
26
30
  const FETCH_ERROR_STATUS_TEXT = "Fetch Error";
27
31
 
32
+ const TOO_MANY_REQUESTS = 429;
33
+
28
34
  const readString = (error: unknown, key: string): string | undefined => {
29
35
  if (error && typeof error === "object" && key in error) {
30
36
  const value = (error as Record<string, unknown>)[key];
@@ -53,6 +59,7 @@ export const classifyAuthError = (error: unknown): AuthErrorClass => {
53
59
  if (isNetworkFailure(error)) return "network";
54
60
  const status = getErrorStatus(error);
55
61
  if (status === undefined) return "fatal";
62
+ if (status === TOO_MANY_REQUESTS) return "rateLimited";
56
63
  if (status === 404 || status >= 500) return "fatal";
57
64
  if (authServerMessage(error)) return "validation";
58
65
  return "fatal";
@@ -61,12 +68,18 @@ export const classifyAuthError = (error: unknown): AuthErrorClass => {
61
68
  const NETWORK_MESSAGE =
62
69
  "Can't reach the server. You may be offline — check your connection and try again.";
63
70
 
64
- /** The inline banner text for a network or validation failure. */
71
+ // The server's own 429 body says only "Too many requests"; it cannot say what
72
+ // the budget is shared with, and that is the part a single user needs to hear.
73
+ const RATE_LIMITED_MESSAGE =
74
+ "Too many requests from this address. Everyone behind the same address — other people on this network, and every tab you have open — shares one limit. Wait a minute and try again.";
75
+
76
+ /** The inline banner text for a network, rate-limited or validation failure. */
65
77
  export const authInlineMessage = (
66
78
  error: unknown,
67
79
  kind: AuthErrorClass,
68
80
  ): string => {
69
81
  if (kind === "network") return NETWORK_MESSAGE;
82
+ if (kind === "rateLimited") return RATE_LIMITED_MESSAGE;
70
83
  return authServerMessage(error) ?? "Please check your details and try again.";
71
84
  };
72
85
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * #441: a 429 on the session lookup put the sign-in screen up, so a throttled
3
+ * instance told everyone behind the address they had been logged out. This
4
+ * renders the gate the app actually mounts, one state per case.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "node:test";
9
+ import { createElement } from "react";
10
+ import { renderToString } from "react-dom/server";
11
+ import { SessionGate } from "./BetterAuthShell";
12
+ import type { SessionQuery } from "./session-state";
13
+
14
+ const APP = "the-signed-in-app";
15
+
16
+ const render = (session: SessionQuery): string =>
17
+ renderToString(
18
+ createElement(SessionGate, {
19
+ session,
20
+ onRetry: () => undefined,
21
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
22
+ children: APP,
23
+ }) as never,
24
+ );
25
+
26
+ const tooManyRequests = {
27
+ status: 429,
28
+ statusText: "Too Many Requests",
29
+ error: { message: "Too many requests. Please try again later." },
30
+ };
31
+
32
+ describe("the session gate", () => {
33
+ it("shows the app when a session is held", () => {
34
+ const html = render({
35
+ data: { user: { id: "u1" } },
36
+ isPending: false,
37
+ error: null,
38
+ });
39
+ assert.match(html, new RegExp(APP));
40
+ });
41
+
42
+ it("shows the sign-in screen when the lookup answered without a session", () => {
43
+ const html = render({ data: null, isPending: false, error: null });
44
+ assert.match(html, /Sign in/);
45
+ assert.doesNotMatch(html, /Too many requests/i);
46
+ });
47
+
48
+ it("names the rate limit instead of claiming a sign-out", () => {
49
+ const html = render({
50
+ data: null,
51
+ isPending: false,
52
+ error: tooManyRequests,
53
+ });
54
+ assert.match(html, /Too many requests from this address/i);
55
+ assert.match(html, /have not been signed out/i);
56
+ assert.doesNotMatch(html, /Create account/);
57
+ });
58
+
59
+ it("offers a way to retry and a way to report", () => {
60
+ const html = render({
61
+ data: null,
62
+ isPending: false,
63
+ error: tooManyRequests,
64
+ });
65
+ assert.match(html, /Try again/);
66
+ assert.match(html, /Report an issue/);
67
+ });
68
+
69
+ it("announces the refusal rather than leaving it to colour", () => {
70
+ const html = render({
71
+ data: null,
72
+ isPending: false,
73
+ error: tooManyRequests,
74
+ });
75
+ assert.match(html, /role="alert"/);
76
+ });
77
+ });
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { classifySessionQuery } from "./session-state";
4
+
5
+ const rateLimited = {
6
+ status: 429,
7
+ statusText: "Too Many Requests",
8
+ error: { message: "Too many requests. Please try again later." },
9
+ };
10
+
11
+ describe("classifySessionQuery", () => {
12
+ it("is pending only while nothing has come back", () => {
13
+ assert.deepEqual(
14
+ classifySessionQuery({ data: null, isPending: true, error: null }),
15
+ { kind: "pending" },
16
+ );
17
+ });
18
+
19
+ it("is active whenever a session is held", () => {
20
+ assert.deepEqual(
21
+ classifySessionQuery({
22
+ data: { user: { id: "u1" } },
23
+ isPending: false,
24
+ error: null,
25
+ }),
26
+ { kind: "active" },
27
+ );
28
+ });
29
+
30
+ it("keeps a held session through a throttled refetch", () => {
31
+ assert.deepEqual(
32
+ classifySessionQuery({
33
+ data: { user: { id: "u1" } },
34
+ isPending: false,
35
+ error: rateLimited,
36
+ }),
37
+ { kind: "active" },
38
+ );
39
+ });
40
+
41
+ it("reads a 429 as rate limited, never as signed out (#441)", () => {
42
+ assert.deepEqual(
43
+ classifySessionQuery({
44
+ data: null,
45
+ isPending: false,
46
+ error: rateLimited,
47
+ }),
48
+ { kind: "rateLimited" },
49
+ );
50
+ });
51
+
52
+ it("is signed out only when the lookup answered without a session", () => {
53
+ assert.deepEqual(
54
+ classifySessionQuery({ data: null, isPending: false, error: null }),
55
+ { kind: "signedOut" },
56
+ );
57
+ assert.deepEqual(
58
+ classifySessionQuery({
59
+ data: null,
60
+ isPending: false,
61
+ error: { status: 401, statusText: "Unauthorized" },
62
+ }),
63
+ { kind: "signedOut" },
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,41 @@
1
+ import { getErrorStatus } from "@/lib/error-classifier";
2
+
3
+ /**
4
+ * What the session query is actually telling the gate.
5
+ *
6
+ * "signedOut" is the only state that may put the sign-in screen up. A session
7
+ * lookup that failed for any other reason has not established that the user is
8
+ * signed out, and rendering the sign-in screen for it tells them they were
9
+ * logged out when they were not (#441).
10
+ */
11
+ export type SessionState =
12
+ | { kind: "pending" }
13
+ | { kind: "active" }
14
+ | { kind: "rateLimited" }
15
+ | { kind: "signedOut" };
16
+
17
+ /** The shape better-auth's `useSession` hands back, read structurally. */
18
+ export interface SessionQuery {
19
+ data: unknown;
20
+ isPending: boolean;
21
+ error: unknown;
22
+ }
23
+
24
+ const TOO_MANY_REQUESTS = 429;
25
+
26
+ /**
27
+ * A held session outranks everything: better-auth keeps `data` across a failed
28
+ * refetch unless the failure was a 401, so a throttled refresh must not evict a
29
+ * signed-in user.
30
+ */
31
+ export const classifySessionQuery = ({
32
+ data,
33
+ isPending,
34
+ error,
35
+ }: SessionQuery): SessionState => {
36
+ if (data) return { kind: "active" };
37
+ if (isPending) return { kind: "pending" };
38
+ if (getErrorStatus(error) === TOO_MANY_REQUESTS)
39
+ return { kind: "rateLimited" };
40
+ return { kind: "signedOut" };
41
+ };
@@ -109,6 +109,7 @@ import { useIsDesktop } from "@/hooks/useMediaQuery";
109
109
  import { useRefreshControl } from "@/hooks/useRefreshControl";
110
110
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
111
111
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
112
+ import { useSpamMatchCounts } from "@/hooks/useSpamMatchCounts";
112
113
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
113
114
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
114
115
  import {
@@ -128,11 +129,13 @@ import { junkDestination } from "@/lib/junk-destination";
128
129
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
129
130
  import { useMailContext } from "@/lib/mail-context";
130
131
  import { useMailFreshness } from "@/lib/mail-freshness";
132
+ import { shouldRequestResultCount } from "@/lib/result-count";
133
+ import { junkMailboxIds } from "@/lib/result-folder";
131
134
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
132
135
  import { showInlineSearchResults } from "@/lib/search-surface";
133
136
  import { parseSearchTokens } from "@/lib/search-tokens";
134
137
  import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
135
- import { spamOfferForResults } from "@/lib/spam-offer";
138
+ import { spamOfferFromCounts } from "@/lib/spam-offer";
136
139
  import { dedupeByThread } from "@/lib/starred-rows";
137
140
  import { wizardSelectionFrom } from "@/lib/wizard-selection";
138
141
  import {
@@ -738,6 +741,30 @@ export function DailyBrief({
738
741
  // same, or a committed search surfaces junk mail inline and drops the way back
739
742
  // to it. A bare token query (e.g. `is:unread`) never reaches search mode, so
740
743
  // there is nothing to hold out.
744
+ // What the offer states is the server's count of every junk folder the search
745
+ // reached, one request each, summed — not the junk share of the page below
746
+ // it, which is a page length wearing a folder total's clothes (#313). The
747
+ // page's own junk rows still decide what is held out of the list, and where
748
+ // "Go to Spam" lands whenever no count ranks the folders.
749
+ const junkIds = useMemo(
750
+ () => junkMailboxIds(resultFolderIndex),
751
+ [resultFolderIndex],
752
+ );
753
+ const spamCounts = useSpamMatchCounts({
754
+ criteria: searchCriteria,
755
+ junkMailboxIds: junkIds,
756
+ // The same two gates the mailbox list counts under: criteria the request
757
+ // carried in full, and free text long enough for the index to answer
758
+ // without a folder scan per character.
759
+ enabled:
760
+ counted &&
761
+ shouldRequestResultCount({
762
+ hasSearchQuery: underQuery,
763
+ freeText: sq,
764
+ residualTokenCount: residualTokens.length,
765
+ }),
766
+ });
767
+
741
768
  const { spamIds, briefSpamOffer } = useMemo(() => {
742
769
  const none = { spamIds: undefined, briefSpamOffer: undefined };
743
770
  if (!sq) return none;
@@ -745,12 +772,25 @@ export function DailyBrief({
745
772
  rowToSearchResult(row, resultFolderIndex),
746
773
  );
747
774
  const { spam } = partitionSpamResults(asResults);
748
- if (spam.length === 0) return none;
775
+ // Per folder, not a total: with no count to rank them by, this is the only
776
+ // evidence of which account's Spam holds the mail, and a button landing on
777
+ // the wrong one shows "No matches" over rows this page is holding out.
778
+ const pageRowsByMailbox = new Map<string, number>();
779
+ for (const result of spam) {
780
+ if (!result.mailboxId) continue;
781
+ pageRowsByMailbox.set(
782
+ result.mailboxId,
783
+ (pageRowsByMailbox.get(result.mailboxId) ?? 0) + 1,
784
+ );
785
+ }
786
+ const offer = spamOfferFromCounts(spamCounts, { pageRowsByMailbox });
787
+ if (spam.length === 0 && !offer) return none;
749
788
  return {
750
- spamIds: new Set(spam.map((result) => result.id)),
751
- briefSpamOffer: spamOfferForResults(asResults),
789
+ spamIds:
790
+ spam.length > 0 ? new Set(spam.map((result) => result.id)) : undefined,
791
+ briefSpamOffer: offer,
752
792
  };
753
- }, [filteredRows, sq, resultFolderIndex]);
793
+ }, [filteredRows, sq, resultFolderIndex, spamCounts]);
754
794
 
755
795
  const sections = useMemo<ThreadSection[]>(() => {
756
796
  const keep = (rows: ThreadRowData[]): ThreadRowData[] =>
@@ -1045,6 +1085,10 @@ export function DailyBrief({
1045
1085
  relatedResults,
1046
1086
  relatedLoading,
1047
1087
  onSelectSearchResult: openResult,
1088
+ // The read-only results panel holds spam out of the same
1089
+ // committed search this body does, so it states the same
1090
+ // server-counted number rather than counting its own page.
1091
+ spamOffer: briefSpamOffer,
1048
1092
  // The body already narrows to the committed query (the server
1049
1093
  // `query` on every section request, plus `matchesSearchTokens` for
1050
1094
  // the residue), so a committed search is a selectable list here
@@ -89,7 +89,7 @@ import {
89
89
  removeSearchToken,
90
90
  searchTokenLabel,
91
91
  } from "@/lib/search-tokens";
92
- import { spamOfferForResults } from "@/lib/spam-offer";
92
+ import type { SpamOffer } from "@/lib/spam-offer";
93
93
  import {
94
94
  useBrowsedList,
95
95
  useOpenWizard,
@@ -152,6 +152,13 @@ export interface MailListHeaderProps {
152
152
  * leaves this unset and keeps the panel for every query.
153
153
  */
154
154
  searchResultsInBody?: boolean;
155
+ /**
156
+ * The Spam offer this view is making, if any: where "Go to Spam" lands and
157
+ * how much of the search sits there, counted by the server over every junk
158
+ * folder it reached. Only the global scope holds spam out, so only the brief
159
+ * passes one; every other view leaves it unset and no offer is made.
160
+ */
161
+ spamOffer?: SpamOffer;
155
162
  /**
156
163
  * The refresh control for this view — the mailbox route and the brief pass
157
164
  * one, scoped to the account(s) they show; a view with nothing account-scoped
@@ -230,6 +237,7 @@ export function MailListHeader({
230
237
  searchResultsLabel = "Top matches",
231
238
  relatedResultsLabel = "Related",
232
239
  searchResultsInBody = false,
240
+ spamOffer,
233
241
  refreshControl,
234
242
  }: MailListHeaderProps) {
235
243
  const {
@@ -393,26 +401,23 @@ export function MailListHeader({
393
401
  [browsed, scope, scopedRole],
394
402
  );
395
403
 
396
- // The offer counts results for the *committed* query, so that is the query it
397
- // carries into Spam. The count the banner states is the results list's own,
398
- // over every row it held out; the app supplies only where "Go to Spam" goes.
399
- const spamOffer = useMemo(
400
- () =>
401
- routeScope.kind === "global"
402
- ? spamOfferForResults([...topMatches, ...related])
403
- : undefined,
404
- [routeScope, topMatches, related],
405
- );
404
+ // The offer is made for the *committed* query, so that is the query it carries
405
+ // into Spam. Both halves come from the view that owns the search: the number
406
+ // is the server's count of every junk folder it reached, and the destination
407
+ // the folder holding most of them. Counting the rows this panel happens to
408
+ // hold would count a page and call it a folder (#313).
409
+ const offer = routeScope.kind === "global" ? spamOffer : undefined;
406
410
  const resultsScope = useMemo<SearchScope>(
407
411
  () =>
408
- spamOffer
412
+ offer
409
413
  ? {
410
414
  kind: "global",
415
+ spamCount: offer.count,
411
416
  onScopeToSpam: () =>
412
- scopeSearchToMailbox(spamOffer.mailboxId, searchQuery),
417
+ scopeSearchToMailbox(offer.mailboxId, searchQuery),
413
418
  }
414
419
  : routeScope,
415
- [spamOffer, routeScope, scopeSearchToMailbox, searchQuery],
420
+ [offer, routeScope, scopeSearchToMailbox, searchQuery],
416
421
  );
417
422
 
418
423
  // Make-this-a-filter (#477 clause 1.8): the search is the wizard's second