@remit/web-client 0.0.248 → 0.0.249

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.248",
3
+ "version": "0.0.249",
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": {
@@ -30,6 +30,7 @@ import {
30
30
  ReadingPaneEmpty,
31
31
  RefreshButton,
32
32
  type RescueCandidate,
33
+ type ResultCount,
33
34
  type SearchResult,
34
35
  } from "@remit/ui";
35
36
  import { useInfiniteQuery } from "@tanstack/react-query";
@@ -87,6 +88,7 @@ import { useToggleReadFor } from "@/hooks/useMarkAsRead";
87
88
  import { useMoveMessages } from "@/hooks/useMoveMessages";
88
89
  import { useRefreshControl } from "@/hooks/useRefreshControl";
89
90
  import { useRescueCandidates } from "@/hooks/useRescueCandidates";
91
+ import { useResultCount } from "@/hooks/useResultCount";
90
92
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
91
93
  import { useSemanticSearch } from "@/hooks/useSemanticSearch";
92
94
  import { useThreadActions } from "@/hooks/useThreadActions";
@@ -111,6 +113,7 @@ import { useMailContext } from "@/lib/mail-context";
111
113
  import { useMailFreshness } from "@/lib/mail-freshness";
112
114
  import { isRescueCandidate } from "@/lib/rescue-candidates";
113
115
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
116
+ import { shouldRequestResultCount } from "@/lib/result-count";
114
117
  import { normalizeSearchQuery } from "@/lib/search-query";
115
118
  import {
116
119
  relatedSearchResults,
@@ -204,6 +207,8 @@ interface MailboxPaneContextValue {
204
207
  * header, not enough to reproduce the query server-side.
205
208
  */
206
209
  searchPredicate: EscalationSearchQuery | undefined;
210
+ /** The server's count of the whole match set, for the result header. */
211
+ resultCount: ResultCount;
207
212
  // List actions
208
213
  onDeleteMessages: (ids: string[]) => void;
209
214
  onMoveMessages: (ids: string[], dest: string) => void;
@@ -316,15 +321,21 @@ function MailboxPaneProvider({
316
321
  searchTokens,
317
322
  filterParams,
318
323
  );
319
- const searchThreadsQuery = {
324
+ // What the list is asking about, with nothing about paging in it. The count
325
+ // is a property of the criteria alone, so it keys on this and a page fetch
326
+ // can never trigger one.
327
+ const searchCriteria = {
320
328
  order: "desc" as const,
321
- // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
322
- // so switching paths without it multiplies the page size by ten.
323
- limit: THREADS_PAGE_SIZE,
324
329
  ...(freeText ? { query: freeText } : {}),
325
330
  ...tokenParams,
326
331
  ...filterParams,
327
332
  };
333
+ const searchThreadsQuery = {
334
+ ...searchCriteria,
335
+ // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
336
+ // so switching paths without it multiplies the page size by ten.
337
+ limit: THREADS_PAGE_SIZE,
338
+ };
328
339
  // What the request actually narrows by, whoever set it. `placeholderData`
329
340
  // keeps the previous rows only under the same predicate, and a token
330
341
  // narrowing the list is as much that predicate as a chip is.
@@ -387,6 +398,16 @@ function MailboxPaneProvider({
387
398
  : undefined,
388
399
  });
389
400
 
401
+ const resultCount = useResultCount({
402
+ mailboxId,
403
+ criteria: searchCriteria,
404
+ enabled: shouldRequestResultCount({
405
+ hasSearchQuery,
406
+ freeText,
407
+ residualTokenCount: residualTokens.length,
408
+ }),
409
+ });
410
+
390
411
  const { accountId: mailboxAccountId, isLoading: mailboxAccountLoading } =
391
412
  useMailboxAccount(mailboxId);
392
413
  const mailboxName = useCurrentMailboxName({ accounts });
@@ -790,6 +811,7 @@ function MailboxPaneProvider({
790
811
  hasSearchQuery && residualTokens.length === 0
791
812
  ? searchThreadsQuery
792
813
  : undefined,
814
+ resultCount,
793
815
  onDeleteMessages: handleDeleteMessages,
794
816
  onMoveMessages: handleMoveMessages,
795
817
  isDeleting,
@@ -854,6 +876,7 @@ function MailboxList() {
854
876
  onClearFilters,
855
877
  listFilter,
856
878
  searchPredicate,
879
+ resultCount,
857
880
  } = useMailboxPane();
858
881
  const { searchQuery, searchInput, accounts, resultFolderIndex } =
859
882
  useMailContext();
@@ -978,6 +1001,7 @@ function MailboxList() {
978
1001
  onRetry={onRetry}
979
1002
  searchQuery={searchQuery}
980
1003
  searchPredicate={searchPredicate}
1004
+ resultCount={resultCount}
981
1005
  onDeleteMessages={onDeleteMessages}
982
1006
  isDeleting={isDeleting}
983
1007
  isMoving={isMoving}
@@ -4,10 +4,12 @@ import {
4
4
  cn,
5
5
  type Density,
6
6
  deriveIsMultiSelectMode,
7
+ ListResultHeader,
7
8
  type MessageListFilter,
8
9
  MessageListLoadingMore,
9
10
  MessageListPane,
10
11
  nextFocusId,
12
+ type ResultCount,
11
13
  type SelectionModifiers,
12
14
  SelectionTopBar,
13
15
  useSelection,
@@ -15,7 +17,6 @@ import {
15
17
  } from "@remit/ui";
16
18
  import { useBlocker } from "@tanstack/react-router";
17
19
  import { useVirtualizer } from "@tanstack/react-virtual";
18
- import { Search } from "lucide-react";
19
20
  import type { RefObject } from "react";
20
21
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
22
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
@@ -113,6 +114,12 @@ interface MessageListProps {
113
114
  * only the display string.
114
115
  */
115
116
  searchPredicate?: EscalationSearchQuery;
117
+ /**
118
+ * How many messages the search matches, as the server counted them — the
119
+ * whole match set, not the pages loaded. Omitted where the count was never
120
+ * asked for, which renders the header without a number.
121
+ */
122
+ resultCount?: ResultCount;
116
123
  onDeleteMessages?: (messageIds: string[]) => void;
117
124
  isDeleting?: boolean;
118
125
  isMoving?: boolean;
@@ -188,21 +195,6 @@ const readStoredDensity = (): Density => {
188
195
  return "comfortable";
189
196
  };
190
197
 
191
- /**
192
- * Names what the list is showing. No number: the only figure available here is
193
- * the length of the loaded pages, and a page length presented as a result total
194
- * contradicts the completeness the filtered empty state states in the same view
195
- * (#306). The exact count is #307's.
196
- */
197
- const SearchResultsHeader = ({ query }: { query: string }) => (
198
- <div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
199
- <Search className="size-4 text-fg-muted" />
200
- <span className="text-sm text-fg-muted">
201
- Results for &ldquo;{query}&rdquo;
202
- </span>
203
- </div>
204
- );
205
-
206
198
  /**
207
199
  * Why Move is withheld from a selection, in the toolbar's own words. Rows from
208
200
  * a per-mailbox endpoint carry no account of their own, so the list's own
@@ -230,6 +222,7 @@ export const MessageList = ({
230
222
  onRetry,
231
223
  searchQuery,
232
224
  searchPredicate,
225
+ resultCount = { kind: "unknown" },
233
226
  onDeleteMessages,
234
227
  isDeleting = false,
235
228
  isMoving = false,
@@ -1227,7 +1220,7 @@ export const MessageList = ({
1227
1220
  const virtualBody = (
1228
1221
  <>
1229
1222
  {isSearching && searchQuery && (
1230
- <SearchResultsHeader query={searchQuery} />
1223
+ <ListResultHeader query={searchQuery} count={resultCount} />
1231
1224
  )}
1232
1225
  <div
1233
1226
  ref={parentRef}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The result count is one question asked of the server, not a walk of the
3
+ * pages (#307).
4
+ *
5
+ * The header used to render `threads.length`, which understated every search
6
+ * matching more than one page and climbed as the reader scrolled. These mount
7
+ * the real hook against the real fetch seam and read what actually left: the
8
+ * whole match set from one request, no second request when the list pages, and
9
+ * nothing at all for a query still being typed.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { afterEach, describe, it } from "node:test";
14
+ import type { ResultCount } from "@remit/ui";
15
+ import { createElement } from "react";
16
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
17
+ import { type HttpMock, mockFetch } from "../test-support/http";
18
+ import { type ThreadSearchCriteria, useResultCount } from "./useResultCount.js";
19
+
20
+ const INBOX = "mbx-inbox";
21
+ /** Far more than a page holds — the number the header must not derive itself. */
22
+ const TOTAL_MATCHES = 1284;
23
+
24
+ let harness: DomHarness | undefined;
25
+ let http: HttpMock | undefined;
26
+
27
+ afterEach(() => {
28
+ harness?.close();
29
+ harness = undefined;
30
+ http?.restore();
31
+ http = undefined;
32
+ });
33
+
34
+ /** Count-only requests, in order — the ones that asked for a number. */
35
+ const countRequests = () =>
36
+ (http?.to("/threads/search") ?? []).filter(
37
+ (call) =>
38
+ new URL(call.url, "http://localhost").searchParams.get("count") ===
39
+ "true",
40
+ );
41
+
42
+ interface Probe {
43
+ count: () => ResultCount;
44
+ /**
45
+ * Render again on the same client, so a second search reaches the cache the
46
+ * first one filled — which is the only place a stale count can come from.
47
+ */
48
+ rerender: (next?: {
49
+ criteria?: ThreadSearchCriteria;
50
+ enabled?: boolean;
51
+ }) => Promise<void>;
52
+ }
53
+
54
+ const mount = async (
55
+ criteria: ThreadSearchCriteria,
56
+ enabled: boolean,
57
+ answer: unknown = { items: [], count: TOTAL_MATCHES },
58
+ ): Promise<Probe> => {
59
+ http = mockFetch((call) =>
60
+ call.path.endsWith("/threads/search") ? answer : { items: [] },
61
+ );
62
+
63
+ let value: ResultCount | undefined;
64
+ const CountProbe = ({
65
+ search,
66
+ on,
67
+ }: {
68
+ search: ThreadSearchCriteria;
69
+ on: boolean;
70
+ }) => {
71
+ value = useResultCount({ mailboxId: INBOX, criteria: search, enabled: on });
72
+ return null;
73
+ };
74
+
75
+ const mounted = createDomHarness();
76
+ harness = mounted;
77
+ let search = criteria;
78
+ let on = enabled;
79
+ const render: Probe["rerender"] = async (next) => {
80
+ if (next?.criteria) search = next.criteria;
81
+ if (next?.enabled !== undefined) on = next.enabled;
82
+ mounted.renderApp(createElement(CountProbe, { search, on }));
83
+ await mounted.flush();
84
+ await mounted.wait(20);
85
+ await mounted.flush();
86
+ };
87
+ await render();
88
+ return {
89
+ count: () => {
90
+ if (!value) throw new Error("hook did not render");
91
+ return value;
92
+ },
93
+ rerender: render,
94
+ };
95
+ };
96
+
97
+ describe("useResultCount (#307)", () => {
98
+ it("answers with the whole match set the server counted", async () => {
99
+ const probe = await mount({ query: "invoice" }, true);
100
+
101
+ assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
102
+ assert.equal(countRequests().length, 1);
103
+ });
104
+
105
+ it("asks with no cursor, so paging the list never re-counts", async () => {
106
+ const probe = await mount({ query: "invoice" }, true);
107
+ await probe.rerender();
108
+ await probe.rerender();
109
+
110
+ assert.equal(
111
+ countRequests().length,
112
+ 1,
113
+ "the count was asked again for a predicate that did not change",
114
+ );
115
+ const asked = new URL(countRequests()[0]?.url ?? "", "http://localhost")
116
+ .searchParams;
117
+ assert.equal(asked.get("continuationToken"), null);
118
+ assert.equal(asked.get("results"), "false");
119
+ });
120
+
121
+ it("issues no request at all while it is disabled", async () => {
122
+ const probe = await mount({ query: "in" }, false);
123
+
124
+ assert.equal(countRequests().length, 0);
125
+ assert.deepEqual(
126
+ probe.count(),
127
+ { kind: "unknown" },
128
+ "an uncounted search reported a number",
129
+ );
130
+ });
131
+
132
+ /**
133
+ * `enabled` gates the request, not the cache. Two searches reach the same key
134
+ * whenever what separates them never reaches the request — `invoice` and
135
+ * `invoice before:2020` send identical criteria — so the second must not read
136
+ * the first one's answer back out of the cache.
137
+ */
138
+ it("forgets the number the moment it stops being asked for", async () => {
139
+ const probe = await mount({ query: "invoice" }, true);
140
+ assert.deepEqual(probe.count(), { kind: "exact", value: TOTAL_MATCHES });
141
+
142
+ await probe.rerender({ enabled: false });
143
+
144
+ assert.deepEqual(
145
+ probe.count(),
146
+ { kind: "unknown" },
147
+ "a count nobody asked for was served from the cache",
148
+ );
149
+ assert.equal(countRequests().length, 1, "the gate stopped gating requests");
150
+ });
151
+
152
+ it("reports no number when the server withholds the count", async () => {
153
+ const probe = await mount({ query: "invoice" }, true, { items: [] });
154
+
155
+ assert.deepEqual(
156
+ probe.count(),
157
+ { kind: "unknown" },
158
+ "an absent count became a zero",
159
+ );
160
+ });
161
+ });
@@ -0,0 +1,68 @@
1
+ import { threadOperationsSearchThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { threadOperationsSearchThreads } from "@remit/api-http-client/sdk.gen.ts";
3
+ import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
4
+ import type { ResultCount } from "@remit/ui";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ import { toResultCount } from "@/lib/result-count";
7
+
8
+ /**
9
+ * What the list is asking about, with nothing about paging in it. The count is
10
+ * a property of the criteria alone, so a page fetch can never trigger one.
11
+ */
12
+ export type ThreadSearchCriteria = Omit<
13
+ NonNullable<ThreadOperationsSearchThreadsData["query"]>,
14
+ "continuationToken" | "limit" | "count" | "results"
15
+ >;
16
+
17
+ export interface UseResultCountOptions {
18
+ mailboxId: string;
19
+ criteria: ThreadSearchCriteria;
20
+ /**
21
+ * Whether this search is worth counting at all — see
22
+ * `shouldRequestResultCount`. Disabled, the surface renders no number rather
23
+ * than a stale one from the search before it.
24
+ */
25
+ enabled: boolean;
26
+ }
27
+
28
+ /** A minute: long enough that returning to a search just left costs nothing. */
29
+ const COUNT_STALE_TIME_MS = 60_000;
30
+
31
+ /**
32
+ * How many messages the search matches, from the server that resolves it —
33
+ * one request against the criteria, never a walk of the pages (#307).
34
+ *
35
+ * Its own query, keyed on the criteria without the cursor, so the list pages
36
+ * without re-counting. An answer that carries no count — off-row criteria, per
37
+ * #305 — stays absent rather than becoming a zero.
38
+ */
39
+ export const useResultCount = ({
40
+ mailboxId,
41
+ criteria,
42
+ enabled,
43
+ }: UseResultCountOptions): ResultCount => {
44
+ const query = { ...criteria, count: true, results: false };
45
+ const { data } = useQuery({
46
+ queryKey: threadOperationsSearchThreadsQueryKey({
47
+ path: { mailboxId },
48
+ query,
49
+ }),
50
+ queryFn: async () => {
51
+ const { data: response } = await threadOperationsSearchThreads({
52
+ path: { mailboxId },
53
+ query,
54
+ throwOnError: true,
55
+ });
56
+ return response;
57
+ },
58
+ enabled,
59
+ staleTime: COUNT_STALE_TIME_MS,
60
+ });
61
+ // `enabled` gates the request, not the cache: a criteria set that asks for no
62
+ // count still reads whatever the last identical key left behind. Two searches
63
+ // share a key whenever the terms that separate them reach the request as
64
+ // nothing — `before:`/`after:`/`account:` are stripped from the free text and
65
+ // carry no param — so a disabled count that read `data` would render the
66
+ // previous search's total over this one's rows.
67
+ return enabled ? toResultCount(data?.count) : { kind: "unknown" };
68
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The exact match count is opt-in because it is a second read over the whole
3
+ * match set (#307). These pin who pays for it and what happens when the server
4
+ * declines to answer.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, test } from "node:test";
9
+ import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
10
+ import {
11
+ type ResultCountRequestInput,
12
+ shouldRequestResultCount,
13
+ toResultCount,
14
+ } from "./result-count.js";
15
+
16
+ const request = (input: Partial<ResultCountRequestInput>): boolean =>
17
+ shouldRequestResultCount({
18
+ hasSearchQuery: true,
19
+ freeText: "",
20
+ residualTokenCount: 0,
21
+ ...input,
22
+ });
23
+
24
+ describe("shouldRequestResultCount", () => {
25
+ test("browsing a mailbox asks for no count", () => {
26
+ assert.equal(request({ hasSearchQuery: false }), false);
27
+ });
28
+
29
+ test("a free-text query is not counted while it is being typed", () => {
30
+ for (const freeText of ["k", "ku", " ku "]) {
31
+ assert.equal(
32
+ request({ freeText }),
33
+ false,
34
+ `"${freeText}" was counted per keystroke`,
35
+ );
36
+ }
37
+ });
38
+
39
+ test("a free-text query long enough for the index is counted", () => {
40
+ assert.equal(request({ freeText: "kub" }), true);
41
+ });
42
+
43
+ test("a query made only of filter tokens is counted straight away", () => {
44
+ assert.equal(request({}), true);
45
+ });
46
+
47
+ /**
48
+ * `before:`/`after:`/`account:` reach the request as nothing, so the server
49
+ * would count a wider set than the list shows — and `invoice before:2020`
50
+ * sends criteria identical to `invoice`, which is also the previous search's
51
+ * cache key.
52
+ */
53
+ test("a term the request could not carry stops the count", () => {
54
+ assert.equal(
55
+ request({ freeText: "invoice", residualTokenCount: 1 }),
56
+ false,
57
+ "the count covered rows the list filters back out",
58
+ );
59
+ assert.equal(
60
+ request({ freeText: "invoice", residualTokenCount: 0 }),
61
+ true,
62
+ "the same search without the residual term must still be counted",
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("toResultCount", () => {
68
+ test("the server's number is the whole match set", () => {
69
+ assert.deepEqual(toResultCount(1284), { kind: "exact", value: 1284 });
70
+ });
71
+
72
+ test("zero matches is a number, not an absent count", () => {
73
+ assert.deepEqual(toResultCount(0), { kind: "exact", value: 0 });
74
+ });
75
+
76
+ test("an absent count stays absent rather than becoming zero", () => {
77
+ assert.deepEqual(toResultCount(undefined), { kind: "unknown" });
78
+ });
79
+ });
80
+
81
+ /**
82
+ * `searchThreads` withholds the count when the criteria carry an off-row term
83
+ * (#305), and the escalated selection reads `count` as a required number to size
84
+ * its run. Neither term can reach that predicate today; this fails the day one
85
+ * is added, so the absent-count state gets designed rather than surfacing as a
86
+ * run that refuses to start.
87
+ */
88
+ describe("the escalation predicate carries no off-row criterion", () => {
89
+ test("senderTrust and dkimMismatch are not part of it", () => {
90
+ type OffRow = Extract<
91
+ keyof EscalationSearchQuery,
92
+ "senderTrust" | "dkimMismatch"
93
+ >;
94
+ const offRow: [OffRow] extends [never] ? true : false = true;
95
+ assert.equal(offRow, true);
96
+ });
97
+ });
@@ -0,0 +1,57 @@
1
+ import type { ResultCount } from "@remit/ui";
2
+
3
+ /**
4
+ * Shortest free text a count is asked for. `npm-scripts/sqlite-search-index.sql`
5
+ * documents that a query under three characters falls out of the trigram index
6
+ * onto a folded `LIKE` scan, so counting one is a mailbox scan per typed
7
+ * character. The list itself still pages — only the count waits.
8
+ */
9
+ export const MIN_COUNTED_QUERY_LENGTH = 3;
10
+
11
+ export interface ResultCountRequestInput {
12
+ /** True while the list is answering a search rather than plain browsing. */
13
+ hasSearchQuery: boolean;
14
+ /** The literal text of the search, with its filter tokens already taken out. */
15
+ freeText: string;
16
+ /**
17
+ * Tokens the request could not carry — `before:`, `after:`, `account:`, and
18
+ * any second value for a parameter that takes one — applied over the rows
19
+ * after they arrive.
20
+ */
21
+ residualTokenCount: number;
22
+ }
23
+
24
+ /**
25
+ * Whether the exact match count is worth one request against these criteria.
26
+ *
27
+ * Chip- and token-driven criteria are counted straight away: they change once
28
+ * per click, and the predicate is one the index answers. Free text is counted
29
+ * only once it is long enough to be indexed, which is what keeps a per-character
30
+ * query from paying for a count it will throw away on the next keystroke.
31
+ *
32
+ * A residual token stops the count outright. The server never saw that term, so
33
+ * it would count a wider set than the list shows — and two searches differing
34
+ * only by one reach the request as the same criteria, so the answer would also
35
+ * be the one the previous search left in the cache.
36
+ */
37
+ export const shouldRequestResultCount = ({
38
+ hasSearchQuery,
39
+ freeText,
40
+ residualTokenCount,
41
+ }: ResultCountRequestInput): boolean => {
42
+ if (!hasSearchQuery) return false;
43
+ if (residualTokenCount > 0) return false;
44
+ const typed = freeText.trim();
45
+ return typed.length === 0 || typed.length >= MIN_COUNTED_QUERY_LENGTH;
46
+ };
47
+
48
+ /**
49
+ * The server's answer, or no number.
50
+ *
51
+ * The only input is `ThreadSearchResponse.count`, which is the whole match set
52
+ * or absent (#305) — never a page length. Absent stays absent: a count the
53
+ * server declined to compute is not zero, and the surface renders nothing
54
+ * rather than a figure it made up.
55
+ */
56
+ export const toResultCount = (count: number | undefined): ResultCount =>
57
+ count === undefined ? { kind: "unknown" } : { kind: "exact", value: count };