@remit/web-client 0.0.247 → 0.0.248

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.247",
3
+ "version": "0.0.248",
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": {
@@ -12,9 +12,16 @@
12
12
  * the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
13
13
  * `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
14
14
  * with a consumer-supplied `listBody` so the real rows render at every width.
15
+ *
16
+ * The category and attribute chips are query parameters, and the header's
17
+ * unread count is the server's own (#308). Both used to be computed over the
18
+ * pages the user happened to have loaded, so a category whose mail sat below
19
+ * the newest page showed an empty list, and the count grew with every press of
20
+ * "load more" while being presented as a total.
15
21
  */
16
22
  import {
17
23
  flaggedFilterConfig,
24
+ type MessageListFilter,
18
25
  MessageListPane,
19
26
  type SearchResult,
20
27
  type ThreadRowData,
@@ -24,14 +31,21 @@ import { type RefObject, useCallback, useMemo, useState } from "react";
24
31
  import { formatErrorMessage } from "@/components/ui/ErrorState";
25
32
  import { useIsDesktop } from "@/hooks/useMediaQuery";
26
33
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
27
- import { useStarredThreads } from "@/hooks/useStarredThreads";
34
+ import {
35
+ useStarredTextSearch,
36
+ useStarredThreads,
37
+ useStarredUnreadCount,
38
+ } from "@/hooks/useStarredThreads";
28
39
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
29
40
  import {
30
41
  matchesBriefSearch,
31
42
  matchesSearchTokens,
43
+ mergeSearchRows,
32
44
  toThreadRowData,
33
45
  } from "@/lib/brief";
34
46
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
47
+ import { flaggedCriteria } from "@/lib/flagged-criteria";
48
+ import { categoryLabel } from "@/lib/inbox-filters";
35
49
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
36
50
  import { useMailContext } from "@/lib/mail-context";
37
51
  import { rowToSearchResult } from "@/lib/search-result";
@@ -51,10 +65,8 @@ import {
51
65
  useThreadListSelection,
52
66
  } from "./ThreadListInteraction";
53
67
 
54
- const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
55
- unread: (t) => !t.isRead,
56
- attachment: (t) => t.hasAttachment === true,
57
- };
68
+ /** One page of the server-filtered text search, merged with the listing below. */
69
+ const TEXT_SEARCH_PAGE_SIZE = 200;
58
70
 
59
71
  /**
60
72
  * The wizard this view's verbs walk, and the one its search entry lands on.
@@ -138,6 +150,26 @@ export function FlaggedList({
138
150
  setActiveFilters(new Set());
139
151
  }, []);
140
152
 
153
+ const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
154
+ searchQuery.trim().toLowerCase(),
155
+ tokenContext,
156
+ );
157
+
158
+ // The chips and the tokens together, as query parameters: a category typed as
159
+ // `category:personal` narrows the request exactly as the chip does.
160
+ const { criteria, residual: residualTokens } = useMemo(
161
+ () =>
162
+ flaggedCriteria(
163
+ { category: selectedCategory, attributes: activeFilters },
164
+ queryTokens,
165
+ ),
166
+ [selectedCategory, activeFilters, queryTokens],
167
+ );
168
+ const textCriteria = useMemo(
169
+ () => (sq ? { ...criteria, query: sq } : criteria),
170
+ [criteria, sq],
171
+ );
172
+
141
173
  const {
142
174
  threads,
143
175
  isLoading,
@@ -147,27 +179,27 @@ export function FlaggedList({
147
179
  fetchNextPage,
148
180
  hasNextPage,
149
181
  isFetchingNextPage,
150
- } = useStarredThreads();
151
-
152
- const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
153
- searchQuery.trim().toLowerCase(),
154
- tokenContext,
155
- );
182
+ } = useStarredThreads(criteria);
183
+ const textMatches = useStarredTextSearch(textCriteria, TEXT_SEARCH_PAGE_SIZE);
156
184
 
157
185
  const rows = useMemo<ThreadRowData[]>(() => {
158
- const predicates = Array.from(activeFilters)
159
- .map((id) => FILTER_PREDICATES[id])
160
- .filter((p): p is (t: ThreadRowData) => boolean => p != null);
161
- return dedupeByThread(threads)
162
- .map(toThreadRowData)
163
- .filter(
164
- (t) =>
165
- (selectedCategory === "all" || t.category === selectedCategory) &&
166
- predicates.every((p) => p(t)) &&
167
- (!sq || matchesBriefSearch(t, sq)) &&
168
- matchesSearchTokens(t, queryTokens),
169
- );
170
- }, [threads, selectedCategory, activeFilters, sq, queryTokens]);
186
+ const listed = dedupeByThread(threads).map(toThreadRowData);
187
+ // No free text: the server-filtered listing as it comes.
188
+ const matched = sq
189
+ ? mergeSearchRows(
190
+ // The snippet half, over the rows already loaded. It only ever adds
191
+ // to the server's set — it is never what decides membership.
192
+ listed.filter((t) => matchesBriefSearch(t, sq)),
193
+ dedupeByThread(textMatches).map(toThreadRowData),
194
+ )
195
+ : listed;
196
+ // What is left is what no parameter can carry: `before:`, `after:`, `in:`,
197
+ // `account:`, and a token the chips overruled. The collapse runs last so
198
+ // the two halves of a text search cannot land one conversation twice.
199
+ return dedupeByThread(matched).filter((t) =>
200
+ matchesSearchTokens(t, residualTokens),
201
+ );
202
+ }, [threads, textMatches, sq, residualTokens]);
171
203
 
172
204
  const openRow = useCallback(
173
205
  (id: string, options?: OpenMessageOptions) => {
@@ -197,10 +229,24 @@ export function FlaggedList({
197
229
  [rows, resultFolderIndex],
198
230
  );
199
231
 
200
- const unreadCount = useMemo(
201
- () => rows.filter((t) => !t.isRead).length,
202
- [rows],
203
- );
232
+ // The server's count over the whole collection under the active criteria.
233
+ // `null` while it is in flight and `null` when it cannot be had, and the
234
+ // header then shows no number — never a page length dressed as a total.
235
+ const unreadCount = useStarredUnreadCount(textCriteria) ?? null;
236
+
237
+ // An empty list has to say how much was looked at, and the answer comes off
238
+ // the request. Every chip and every carried token is a column on the row, so
239
+ // the server answered over the whole collection; a residual token, or the
240
+ // snippet half of a free-text search, only ever saw the pages loaded so far.
241
+ const filterLabel = categoryLabel(selectedCategory);
242
+ const listFilter: MessageListFilter | undefined = filterLabel
243
+ ? {
244
+ label: filterLabel,
245
+ reach:
246
+ residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
247
+ onClear: clearFilters,
248
+ }
249
+ : undefined;
204
250
 
205
251
  const listState = isLoading
206
252
  ? "loading"
@@ -277,6 +323,10 @@ export function FlaggedList({
277
323
  flatList
278
324
  hideHeader
279
325
  listState={chrome.searchResults ? "ready" : listState}
326
+ listFilter={listFilter}
327
+ // Flagged spans accounts and folders, so it is a collection and
328
+ // never "this mailbox". It names itself.
329
+ listScopeLabel="Starred"
280
330
  searchQuery={sq ? searchQuery : undefined}
281
331
  errorMessage={isError ? formatErrorMessage(error) : undefined}
282
332
  onRetry={() => refetch()}
@@ -95,7 +95,13 @@ import {
95
95
 
96
96
  export interface MailListHeaderProps {
97
97
  title: string;
98
- unreadCount: number;
98
+ /**
99
+ * Unread count beside the title. `null` is the one way to say the number is
100
+ * not known, and the header then shows none: a count the server has not
101
+ * answered is never replaced by a figure derived from the rows that happen to
102
+ * be loaded (#308). Required so a caller that has no number says so.
103
+ */
104
+ unreadCount: number | null;
99
105
  /** The list body (filter sheet / sections / virtualized rows). */
100
106
  children: ReactNode;
101
107
  /**
@@ -442,9 +448,11 @@ export function MailListHeader({
442
448
  ),
443
449
  titleMeta: (
444
450
  <>
445
- <span className="shrink-0 text-2xs text-fg-subtle">
446
- {unreadCount.toLocaleString()} unread
447
- </span>
451
+ {unreadCount === null ? null : (
452
+ <span className="shrink-0 text-2xs text-fg-subtle">
453
+ {unreadCount.toLocaleString()} unread
454
+ </span>
455
+ )}
448
456
  <FilterToggle />
449
457
  {refreshControl}
450
458
  </>
@@ -30,7 +30,8 @@ import { MailListHeader } from "./MailListHeader";
30
30
 
31
31
  interface MailViewChromeProps {
32
32
  title: string;
33
- unreadCount: number;
33
+ /** Unread count beside the title; `null` shows no number — see `MailListHeader`. */
34
+ unreadCount: number | null;
34
35
  preset: FilterPreset;
35
36
  selectedCategory: string;
36
37
  activeFilters: ReadonlySet<string>;
@@ -98,6 +98,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
98
98
  import type { ConversationTarget } from "@/lib/conversation-target";
99
99
  import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
100
100
  import {
101
+ categoryLabel,
101
102
  filterReach,
102
103
  hasInboxFilter,
103
104
  type InboxFilterCriteria,
@@ -156,7 +157,8 @@ interface MailboxPaneContextValue {
156
157
  mailboxAccountId: string | undefined;
157
158
  mailboxAccountLoading: boolean;
158
159
  mailboxName: string | null;
159
- unreadCount: number;
160
+ /** The mailbox's unseen total, or null until it resolves — never a page length. */
161
+ unreadCount: number | null;
160
162
  isDraftsMailbox: boolean;
161
163
  // Rescue-from-Spam: true on the account's Junk/Spam folder, with the
162
164
  // suspected-safe messages `useRescueCandidates` fetched. Drives the rescue
@@ -240,14 +242,6 @@ interface MailboxPaneContextValue {
240
242
  * filtered path pages like the unfiltered one. */
241
243
  const THREADS_PAGE_SIZE = 50;
242
244
 
243
- /** Chip id → the label the empty state names the filter by. `all` is absent:
244
- * it is how the category is cleared, not a category. */
245
- const CATEGORY_LABELS = new Map(
246
- inboxFilterConfig()
247
- .categories.filter((category) => category.id !== "all")
248
- .map((category) => [category.id, category.label]),
249
- );
250
-
251
245
  const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
252
246
 
253
247
  function useMailboxPane(): MailboxPaneContextValue {
@@ -433,7 +427,7 @@ function MailboxPaneProvider({
433
427
  // The empty state has to say how much was read, and the reach comes off the
434
428
  // request rather than the call site: the day a chip is answered over a window
435
429
  // instead of the whole mailbox, the sentence changes with it.
436
- const filterLabel = CATEGORY_LABELS.get(filterCategory);
430
+ const filterLabel = categoryLabel(filterCategory);
437
431
  const listFilter: MessageListFilter | undefined = filterLabel
438
432
  ? {
439
433
  label: filterLabel,
@@ -512,7 +506,7 @@ function MailboxPaneProvider({
512
506
  // The mailbox's own unseen total. A count over the loaded pages undercounts
513
507
  // every mailbox larger than one page and creeps upward as the user scrolls,
514
508
  // so there is no fallback: until the mailbox resolves there is no number.
515
- const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
509
+ const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? null;
516
510
 
517
511
  const toolbarActions = useThreadActions({
518
512
  thread: selectedThread,
@@ -22,6 +22,7 @@ import { renderToString } from "react-dom/server";
22
22
  import {
23
23
  starredThreadsQueryKey,
24
24
  useStarredThreads,
25
+ useStarredUnreadCount,
25
26
  } from "./useStarredThreads.js";
26
27
 
27
28
  interface Page {
@@ -141,3 +142,78 @@ describe("useStarredThreads", () => {
141
142
  assert.deepEqual(threads, []);
142
143
  });
143
144
  });
145
+
146
+ // #308: the chips were a pass over the pages loaded so far, and the header
147
+ // count was their length. Both are the server's answer now.
148
+ describe("starred criteria", () => {
149
+ test("carries the chips as query parameters", () => {
150
+ const [params] = starredThreadsQueryKey({
151
+ category: ["social"],
152
+ unread: true,
153
+ attachments: true,
154
+ }) as [
155
+ {
156
+ query?: {
157
+ starred?: boolean;
158
+ category?: string[];
159
+ unread?: boolean;
160
+ attachments?: boolean;
161
+ };
162
+ },
163
+ ];
164
+ assert.equal(params.query?.starred, true);
165
+ assert.deepEqual(params.query?.category, ["social"]);
166
+ assert.equal(params.query?.unread, true);
167
+ assert.equal(params.query?.attachments, true);
168
+ });
169
+
170
+ test("an unfiltered listing asks for exactly what it asked for before", () => {
171
+ assert.deepEqual(starredThreadsQueryKey({}), starredThreadsQueryKey());
172
+ });
173
+ });
174
+
175
+ describe("useStarredUnreadCount", () => {
176
+ function renderCount(seed?: { count?: number }): number | undefined {
177
+ const client = new QueryClient({
178
+ defaultOptions: { queries: { retry: false } },
179
+ });
180
+ if (seed) {
181
+ client.setQueryData(
182
+ unifiedThreadOperationsListAllThreadsQueryKey({
183
+ query: {
184
+ starred: true,
185
+ order: "desc",
186
+ unread: true,
187
+ count: true,
188
+ results: false,
189
+ },
190
+ }),
191
+ seed,
192
+ );
193
+ }
194
+
195
+ let captured: number | undefined;
196
+ function Capture() {
197
+ captured = useStarredUnreadCount();
198
+ return null;
199
+ }
200
+ renderToString(
201
+ createElement(
202
+ QueryClientProvider,
203
+ { client },
204
+ createElement(Capture),
205
+ ) as never,
206
+ );
207
+ return captured;
208
+ }
209
+
210
+ test("reports the server's count", () => {
211
+ assert.equal(renderCount({ count: 137 }), 137);
212
+ });
213
+
214
+ // A count the server has not answered is no number at all. A page length
215
+ // dressed as a total is what this replaces.
216
+ test("reports nothing when the server has not answered", () => {
217
+ assert.equal(renderCount(), undefined);
218
+ });
219
+ });
@@ -8,20 +8,43 @@
8
8
  * selection from any other listing reintroduces issue #70 — the INBOX-scoped
9
9
  * unified listing cannot see a starred thread filed elsewhere, so its row is
10
10
  * clickable but resolves to nothing and no reading pane opens.
11
+ *
12
+ * The filters travel as query parameters rather than as a pass over the pages
13
+ * loaded so far. A starred collection is paged, so a category whose mail sits
14
+ * below the newest page rendered an empty list however much of it the
15
+ * collection held (#308); the server answers the predicate over the whole
16
+ * collection instead, and a page comes back full of matches.
11
17
  */
12
18
  import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
13
19
  import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
14
20
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
15
- import { useInfiniteQuery } from "@tanstack/react-query";
21
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
16
22
  import { useMemo } from "react";
23
+ import type { InboxFilterParams } from "@/lib/inbox-filters";
24
+
25
+ /** The criteria the Flagged view narrows its listing by, all server-applied. */
26
+ export interface StarredCriteria extends InboxFilterParams {
27
+ /** Free text, matched against subject and From over the whole collection. */
28
+ query?: string;
29
+ /** Page size, set only by the single-page text search. */
30
+ limit?: number;
31
+ }
32
+
33
+ // `starred` and `order` are the view itself, not a criterion, so they are set
34
+ // last: no chip can widen the listing past starred mail or reorder it.
35
+ const starredQuery = (criteria: StarredCriteria) => ({
36
+ ...criteria,
37
+ starred: true as const,
38
+ order: "desc" as const,
39
+ });
17
40
 
18
- export const starredThreadsQueryKey = () =>
41
+ export const starredThreadsQueryKey = (criteria: StarredCriteria = {}) =>
19
42
  unifiedThreadOperationsListAllThreadsQueryKey({
20
- query: { starred: true, order: "desc" },
43
+ query: starredQuery(criteria),
21
44
  });
22
45
 
23
46
  interface StarredThreads {
24
- /** Every starred thread across the pages loaded so far, newest first. */
47
+ /** Every matching starred thread across the pages loaded so far, newest first. */
25
48
  threads: RemitImapThreadMessageResponse[];
26
49
  isLoading: boolean;
27
50
  isError: boolean;
@@ -32,7 +55,9 @@ interface StarredThreads {
32
55
  isFetchingNextPage: boolean;
33
56
  }
34
57
 
35
- export function useStarredThreads(): StarredThreads {
58
+ export function useStarredThreads(
59
+ criteria: StarredCriteria = {},
60
+ ): StarredThreads {
36
61
  const {
37
62
  data,
38
63
  isLoading,
@@ -43,12 +68,11 @@ export function useStarredThreads(): StarredThreads {
43
68
  hasNextPage,
44
69
  isFetchingNextPage,
45
70
  } = useInfiniteQuery({
46
- queryKey: starredThreadsQueryKey(),
71
+ queryKey: starredThreadsQueryKey(criteria),
47
72
  queryFn: async ({ pageParam }) => {
48
73
  const { data: page } = await unifiedThreadOperationsListAllThreads({
49
74
  query: {
50
- starred: true,
51
- order: "desc",
75
+ ...starredQuery(criteria),
52
76
  continuationToken: pageParam,
53
77
  },
54
78
  throwOnError: true,
@@ -76,3 +100,70 @@ export function useStarredThreads(): StarredThreads {
76
100
  isFetchingNextPage,
77
101
  };
78
102
  }
103
+
104
+ /**
105
+ * The server's own text match over the whole starred collection, as one page.
106
+ *
107
+ * The free-text filter has two halves that no single request covers: the server
108
+ * matches subject and From over every starred message, and the client can also
109
+ * match a snippet, but only on rows it has already loaded. The server's set is
110
+ * the filter — it is the half that sees the mail below the newest page — and
111
+ * the snippet pass complements it (#308). Same shape as the daily brief's
112
+ * unscoped search, which merges the same two halves.
113
+ */
114
+ export function useStarredTextSearch(
115
+ criteria: StarredCriteria,
116
+ limit: number,
117
+ ): RemitImapThreadMessageResponse[] {
118
+ const text = criteria.query ?? "";
119
+ const query = { ...starredQuery(criteria), limit };
120
+ const { data } = useQuery({
121
+ queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
122
+ queryFn: async () => {
123
+ const { data: page } = await unifiedThreadOperationsListAllThreads({
124
+ query,
125
+ throwOnError: true,
126
+ });
127
+ return page;
128
+ },
129
+ enabled: text.length > 0,
130
+ staleTime: 30_000,
131
+ });
132
+ return useMemo(() => data?.items ?? [], [data]);
133
+ }
134
+
135
+ /**
136
+ * How many of the matching starred messages are unread, counted by the server.
137
+ *
138
+ * Its own request, so no cursor enters the key and pressing "load more" cannot
139
+ * move the number — the count is over the whole collection under the active
140
+ * criteria, which is what the header claims it is. A page-length count grows
141
+ * with every press and reads as a total, which is the defect this replaces
142
+ * (#308). `results: false` reads the count alone: no rows are fetched or
143
+ * enriched to answer it.
144
+ *
145
+ * Undefined until the server answers, and undefined when it cannot. The header
146
+ * shows no number rather than a made-up one.
147
+ */
148
+ export function useStarredUnreadCount(
149
+ criteria: StarredCriteria = {},
150
+ ): number | undefined {
151
+ const query = {
152
+ ...starredQuery(criteria),
153
+ unread: true,
154
+ count: true,
155
+ results: false,
156
+ };
157
+ const { data } = useQuery({
158
+ queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
159
+ queryFn: async () => {
160
+ const { data: page } = await unifiedThreadOperationsListAllThreads({
161
+ query,
162
+ throwOnError: true,
163
+ });
164
+ return page;
165
+ },
166
+ staleTime: 60_000,
167
+ });
168
+ return data?.count;
169
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What the Flagged view asks the server for.
3
+ *
4
+ * #308: the chips became query parameters, and the tokens that ask for the same
5
+ * thing have to travel the same way. A token evaluated over the pages loaded so
6
+ * far reintroduces the defect one control down — the field would show an empty
7
+ * list for a category whose starred mail sits below the newest page, while the
8
+ * chip beside it showed the mail.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { describe, test } from "node:test";
12
+ import { flaggedCriteria } from "./flagged-criteria.js";
13
+ import type { InboxFilterCriteria } from "./inbox-filters.js";
14
+ import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
15
+
16
+ const NO_CHIPS: InboxFilterCriteria = {
17
+ category: "all",
18
+ attributes: new Set<string>(),
19
+ };
20
+
21
+ const chips = (
22
+ category: string,
23
+ attributes: string[] = [],
24
+ ): InboxFilterCriteria => ({ category, attributes: new Set(attributes) });
25
+
26
+ const tokensOf = (query: string): SearchToken[] =>
27
+ parseSearchTokens(query, {
28
+ accountsByName: new Map([["alice", "acc_1"]]),
29
+ mailboxesByName: new Map([["sent", "mb2"]]),
30
+ }).tokens;
31
+
32
+ const criteriaOf = (query: string, filters = NO_CHIPS) =>
33
+ flaggedCriteria(filters, tokensOf(query)).criteria;
34
+
35
+ const residualTypes = (query: string, filters = NO_CHIPS) =>
36
+ flaggedCriteria(filters, tokensOf(query)).residual.map((t) => t.type);
37
+
38
+ describe("flaggedCriteria", () => {
39
+ test("a typed token asks for exactly what its chip asks for", () => {
40
+ assert.deepEqual(
41
+ criteriaOf("is:unread"),
42
+ criteriaOf("", chips("all", ["unread"])),
43
+ );
44
+ assert.deepEqual(
45
+ criteriaOf("has:attachment"),
46
+ criteriaOf("", chips("all", ["attachment"])),
47
+ );
48
+ assert.deepEqual(
49
+ criteriaOf("category:personal"),
50
+ criteriaOf("", chips("personal")),
51
+ );
52
+ });
53
+
54
+ test("carries the row criteria as parameters", () => {
55
+ assert.deepEqual(criteriaOf("is:unread"), { unread: true });
56
+ assert.deepEqual(criteriaOf("is:read"), { unread: false });
57
+ assert.deepEqual(criteriaOf("has:attachment"), { attachments: true });
58
+ assert.deepEqual(criteriaOf("category:social"), { category: ["social"] });
59
+ });
60
+
61
+ test("leaves nothing carried in the residue", () => {
62
+ assert.deepEqual(
63
+ residualTypes("is:unread has:attachment category:personal"),
64
+ [],
65
+ );
66
+ });
67
+
68
+ // The view is starred mail, so the request always carries `starred`. A token
69
+ // asking for what the request already asks for is answered, not re-applied.
70
+ test("answers is:starred from the view itself", () => {
71
+ assert.deepEqual(residualTypes("is:starred"), []);
72
+ });
73
+
74
+ // No `from`/`subject` parameter on this endpoint: its one text parameter
75
+ // matches both at once, so these stay a pass over the returned rows rather
76
+ // than being dropped from a request that never carried them.
77
+ test("keeps the tokens no parameter carries", () => {
78
+ assert.deepEqual(residualTypes("from:alice"), ["from"]);
79
+ assert.deepEqual(residualTypes("before:2026-01-01"), ["before"]);
80
+ assert.deepEqual(residualTypes("in:sent"), ["in"]);
81
+ assert.deepEqual(residualTypes("account:alice"), ["account"]);
82
+ });
83
+
84
+ test("the chip wins over a token that contradicts it", () => {
85
+ const { criteria, residual } = flaggedCriteria(
86
+ chips("all", ["unread"]),
87
+ tokensOf("is:read"),
88
+ );
89
+ assert.equal(criteria.unread, true);
90
+ assert.deepEqual(
91
+ residual.map((t) => t.type),
92
+ ["isRead"],
93
+ );
94
+ });
95
+
96
+ test("a cleared category sets no category parameter", () => {
97
+ assert.deepEqual(criteriaOf("", NO_CHIPS), {});
98
+ });
99
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What the Flagged view asks `listAllThreads` for, and what is left over.
3
+ *
4
+ * Every criterion the view offers — the category chip, the Unread and Has
5
+ * attachment chips, and the `category:` / `is:unread` / `is:read` /
6
+ * `has:attachment` / `is:starred` tokens typed in the field — is a column on
7
+ * the ThreadMessage row, so all of them travel as query parameters. A criterion
8
+ * applied over the pages loaded so far is the defect this replaces: a starred
9
+ * collection is paged, so anything below the newest page was invisible to it
10
+ * (#308).
11
+ *
12
+ * `starred` is the view rather than a criterion: the request always carries it,
13
+ * which is what makes `is:starred` a carried token instead of residue.
14
+ *
15
+ * A chip beats a token where both set the same parameter — a control on screen
16
+ * beats text in a field — and the overruled token drops to the residue, where
17
+ * it matches nothing, which is the honest answer to a contradiction.
18
+ */
19
+ import type {
20
+ InboxFilterCriteria,
21
+ InboxFilterParams,
22
+ } from "./inbox-filters.js";
23
+ import { inboxFilterParams } from "./inbox-filters.js";
24
+ import type { SearchToken } from "./search-tokens.js";
25
+ import {
26
+ type ThreadSearchTokenParamName,
27
+ threadSearchTokens,
28
+ } from "./thread-search-tokens.js";
29
+
30
+ /**
31
+ * The token parameters `listAllThreads` carries. It has no `from` or `subject`
32
+ * of its own — its one text parameter matches both at once — so those two
33
+ * tokens stay residue rather than being dropped from a request that never had
34
+ * them.
35
+ */
36
+ export const FLAGGED_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
37
+ "category",
38
+ "unread",
39
+ "starred",
40
+ "attachments",
41
+ ];
42
+
43
+ export interface FlaggedCriteria {
44
+ /** What the request narrows by, chips and carried tokens together. */
45
+ criteria: InboxFilterParams;
46
+ /** The tokens no parameter carries; apply these over the returned rows. */
47
+ residual: SearchToken[];
48
+ }
49
+
50
+ export const flaggedCriteria = (
51
+ chips: InboxFilterCriteria,
52
+ tokens: readonly SearchToken[],
53
+ ): FlaggedCriteria => {
54
+ const chipParams = inboxFilterParams(chips);
55
+ const { params, residual } = threadSearchTokens(
56
+ tokens,
57
+ { ...chipParams, starred: true },
58
+ FLAGGED_TOKEN_PARAMS,
59
+ );
60
+ return { criteria: { ...params, ...chipParams }, residual };
61
+ };
@@ -3,20 +3,26 @@ import type {
3
3
  ThreadOperationsSearchThreadsData,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
5
  import { MessageCategory } from "@remit/domain-enums";
6
- import type { FilterReach } from "@remit/ui";
6
+ import { type FilterReach, inboxFilterConfig } from "@remit/ui";
7
7
 
8
8
  /** The request `threadOperationsSearchThreads` takes, whole. */
9
9
  export type ThreadSearchQuery = NonNullable<
10
10
  ThreadOperationsSearchThreadsData["query"]
11
11
  >;
12
12
 
13
- /** The parameters the inbox chips set on that request. */
13
+ /**
14
+ * The parameters the filter chips set on a thread listing.
15
+ *
16
+ * Named for the inbox, which had them first, but shared: `listAllThreads` takes
17
+ * the same four (#308), so the Flagged view sends the chips through this rather
18
+ * than growing a second translation of the same chip ids.
19
+ */
14
20
  export type InboxFilterParams = Pick<
15
21
  ThreadSearchQuery,
16
22
  "category" | "unread" | "starred" | "attachments"
17
23
  >;
18
24
 
19
- /** The chip state the inbox holds: one category, any number of attributes. */
25
+ /** The chip state a filtered list holds: one category, any number of attributes. */
20
26
  export interface InboxFilterCriteria {
21
27
  /** A category id, or `"all"` when the category is cleared. */
22
28
  category: string;
@@ -121,3 +127,18 @@ export const sameInboxFilter = (
121
127
  if (!previous) return false;
122
128
  return filterIdentity(previous) === filterIdentity(params);
123
129
  };
130
+
131
+ /**
132
+ * The label the empty state names an active category filter by, or undefined
133
+ * when the id names no category — `"all"` is how the category is cleared, not a
134
+ * category. Read off the preset so the chip and the sentence about it can never
135
+ * disagree; every list preset draws its categories from the same set.
136
+ */
137
+ const CATEGORY_LABELS = new Map(
138
+ inboxFilterConfig()
139
+ .categories.filter((category) => category.id !== "all")
140
+ .map((category) => [category.id, category.label]),
141
+ );
142
+
143
+ export const categoryLabel = (id: string): string | undefined =>
144
+ CATEGORY_LABELS.get(id);
@@ -1,5 +1,3 @@
1
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
-
3
1
  /**
4
2
  * Collapse rows that are the same conversation.
5
3
  *
@@ -12,12 +10,19 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
12
10
  * conversation single even when its copies straddle a page boundary — a single
13
11
  * page cannot know what earlier pages already showed. The first row wins, which
14
12
  * is the newest under the server's descending order.
13
+ *
14
+ * Generic over anything naming its thread, so the same collapse runs over the
15
+ * API rows and over the merged display rows a text search produces — two
16
+ * definitions of "same conversation" is one too many. A row that names no
17
+ * thread is never merged: there is nothing to say it is the same conversation
18
+ * as any other.
15
19
  */
16
- export const dedupeByThread = (
17
- items: RemitImapThreadMessageResponse[],
18
- ): RemitImapThreadMessageResponse[] => {
20
+ export const dedupeByThread = <T extends { threadId?: string }>(
21
+ items: T[],
22
+ ): T[] => {
19
23
  const seen = new Set<string>();
20
24
  return items.filter((item) => {
25
+ if (item.threadId === undefined) return true;
21
26
  if (seen.has(item.threadId)) return false;
22
27
  seen.add(item.threadId);
23
28
  return true;
@@ -33,6 +33,19 @@ export type ThreadSearchTokenParams = Pick<
33
33
  "from" | "subject" | "category" | "unread" | "starred" | "attachments"
34
34
  >;
35
35
 
36
+ /** A parameter name a listing endpoint accepts. */
37
+ export type ThreadSearchTokenParamName = keyof ThreadSearchTokenParams;
38
+
39
+ /**
40
+ * Every parameter `searchThreads` takes, the default set.
41
+ *
42
+ * `listAllThreads` takes all of these except `from` and `subject`, so a caller
43
+ * on that endpoint names its own set and the two tokens come back as residue
44
+ * rather than being silently dropped from a request that never carried them.
45
+ */
46
+ export const THREAD_SEARCH_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] =
47
+ ["from", "subject", "category", "unread", "starred", "attachments"];
48
+
36
49
  export interface ThreadSearchTokens {
37
50
  /** What the tokens add to the request. */
38
51
  params: ThreadSearchTokenParams;
@@ -106,17 +119,32 @@ const isCarried = (
106
119
  }
107
120
  };
108
121
 
122
+ const narrowTo = (
123
+ params: ThreadSearchTokenParams,
124
+ carries: readonly ThreadSearchTokenParamName[],
125
+ ): ThreadSearchTokenParams =>
126
+ Object.fromEntries(
127
+ Object.entries(params).filter(([name]) =>
128
+ carries.includes(name as ThreadSearchTokenParamName),
129
+ ),
130
+ );
131
+
109
132
  /**
110
- * Split the tokens into the parameters the thread search takes and the residue
133
+ * Split the tokens into the parameters the thread listing takes and the residue
111
134
  * the caller has to apply itself. `chipParams` are the parameters already set
112
- * by the inbox filter chips, which win where they overlap.
135
+ * by the filter chips, which win where they overlap; `carries` names the
136
+ * parameters the endpoint being called actually accepts.
113
137
  */
114
138
  export function threadSearchTokens(
115
139
  tokens: readonly SearchToken[],
116
140
  chipParams: InboxFilterParams = {},
141
+ carries: readonly ThreadSearchTokenParamName[] = THREAD_SEARCH_TOKEN_PARAMS,
117
142
  ): ThreadSearchTokens {
118
- const params = paramsFromTokens(tokens);
119
- const request: ThreadSearchTokenParams = { ...params, ...chipParams };
143
+ const params = narrowTo(paramsFromTokens(tokens), carries);
144
+ const request: ThreadSearchTokenParams = {
145
+ ...params,
146
+ ...narrowTo(chipParams, carries),
147
+ };
120
148
  return {
121
149
  params,
122
150
  residual: tokens.filter((token) => !isCarried(request, token)),