@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 +1 -1
- package/src/auth/BetterAuthShell.tsx +39 -4
- package/src/auth/SessionRateLimited.stories.tsx +26 -0
- package/src/auth/SessionRateLimited.tsx +39 -0
- package/src/auth/auth-error.test.ts +20 -0
- package/src/auth/auth-error.ts +15 -2
- package/src/auth/session-gate.render.test.ts +77 -0
- package/src/auth/session-state.test.ts +66 -0
- package/src/auth/session-state.ts +41 -0
- package/src/components/mail/DailyBrief.tsx +49 -5
- package/src/components/mail/MailListHeader.tsx +19 -14
- package/src/components/mail/spam-offer-server-count.render.test.ts +349 -0
- package/src/hooks/useSpamMatchCounts.ts +102 -0
- package/src/lib/result-folder.ts +16 -0
- package/src/lib/spam-offer.test.ts +166 -58
- package/src/lib/spam-offer.ts +150 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
193
|
-
|
|
195
|
+
interface SessionGateProps {
|
|
196
|
+
session: SessionQuery;
|
|
197
|
+
onRetry: () => void;
|
|
198
|
+
children: ReactNode;
|
|
199
|
+
}
|
|
194
200
|
|
|
195
|
-
|
|
196
|
-
|
|
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", () => {
|
package/src/auth/auth-error.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
751
|
-
|
|
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 {
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
412
|
+
offer
|
|
409
413
|
? {
|
|
410
414
|
kind: "global",
|
|
415
|
+
spamCount: offer.count,
|
|
411
416
|
onScopeToSpam: () =>
|
|
412
|
-
scopeSearchToMailbox(
|
|
417
|
+
scopeSearchToMailbox(offer.mailboxId, searchQuery),
|
|
413
418
|
}
|
|
414
419
|
: routeScope,
|
|
415
|
-
[
|
|
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
|