@remit/web-client 0.0.253 → 0.0.254

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.253",
3
+ "version": "0.0.254",
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": {
@@ -45,8 +45,8 @@ import {
45
45
  } from "@/lib/brief";
46
46
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
47
47
  import { flaggedCriteria } from "@/lib/flagged-criteria";
48
- import { categoryLabel } from "@/lib/inbox-filters";
49
48
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
49
+ import { listNarrowing } from "@/lib/list-narrowing";
50
50
  import { useMailContext } from "@/lib/mail-context";
51
51
  import { rowToSearchResult } from "@/lib/search-result";
52
52
  import { parseSearchTokens } from "@/lib/search-tokens";
@@ -126,7 +126,8 @@ export function FlaggedList({
126
126
  onTriageContextChange,
127
127
  onDeleteMessages,
128
128
  }: FlaggedListProps) {
129
- const { searchQuery, resultFolderIndex } = useMailContext();
129
+ const { searchQuery, resultFolderIndex, onSearchClearQuery } =
130
+ useMailContext();
130
131
  const tokenContext = useSearchTokenContext();
131
132
  const isDesktop = useIsDesktop();
132
133
  const wizard = useSelectionWizard();
@@ -150,10 +151,24 @@ export function FlaggedList({
150
151
  setActiveFilters(new Set());
151
152
  }, []);
152
153
 
154
+ // The empty state's way out has to clear everything its headline names, or
155
+ // it is a button that leaves the list exactly as narrowed as it found it.
156
+ const clearNarrowing = useCallback(() => {
157
+ clearFilters();
158
+ onSearchClearQuery();
159
+ }, [clearFilters, onSearchClearQuery]);
160
+
153
161
  const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
154
162
  searchQuery.trim().toLowerCase(),
155
163
  tokenContext,
156
164
  );
165
+ // The same free text in the reader's own casing. The request's copy is folded
166
+ // to lowercase so equivalent searches share a cache entry; a sentence quoting
167
+ // the query back to the reader must not be.
168
+ const typedFreeText = parseSearchTokens(
169
+ searchQuery.trim(),
170
+ tokenContext,
171
+ ).freeText;
157
172
 
158
173
  // The chips and the tokens together, as query parameters: a category typed as
159
174
  // `category:personal` narrows the request exactly as the chip does.
@@ -238,15 +253,19 @@ export function FlaggedList({
238
253
  // the request. Every chip and every carried token is a column on the row, so
239
254
  // the server answered over the whole collection; a residual token, or the
240
255
  // 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;
256
+ //
257
+ // `is:starred` is dropped: this view is starred mail, so the token restates
258
+ // the collection rather than narrowing it.
259
+ const narrowingTokens = useMemo(
260
+ () => queryTokens.filter((token) => token.type !== "isStarred"),
261
+ [queryTokens],
262
+ );
263
+ const listFilter: MessageListFilter | undefined = listNarrowing({
264
+ chips: { category: selectedCategory, attributes: activeFilters },
265
+ tokens: narrowingTokens,
266
+ reach: residualTokens.length > 0 || sq ? "loaded-pages" : "whole-folder",
267
+ onClear: clearNarrowing,
268
+ });
250
269
 
251
270
  const listState = isLoading
252
271
  ? "loading"
@@ -327,7 +346,9 @@ export function FlaggedList({
327
346
  // Flagged spans accounts and folders, so it is a collection and
328
347
  // never "this mailbox". It names itself.
329
348
  listScopeLabel="Starred"
330
- searchQuery={sq ? searchQuery : undefined}
349
+ // The typed tokens narrow the list too, but `listFilter` already
350
+ // names them, and a headline saying one narrowing twice reads as two.
351
+ searchQuery={typedFreeText || undefined}
331
352
  errorMessage={isError ? formatErrorMessage(error) : undefined}
332
353
  onRetry={() => refetch()}
333
354
  onReportError={handleReportError}
@@ -100,7 +100,6 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
100
100
  import type { ConversationTarget } from "@/lib/conversation-target";
101
101
  import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
102
102
  import {
103
- categoryLabel,
104
103
  filterReach,
105
104
  hasInboxFilter,
106
105
  type InboxFilterCriteria,
@@ -109,6 +108,7 @@ import {
109
108
  sameInboxFilter,
110
109
  } from "@/lib/inbox-filters";
111
110
  import { junkDestination } from "@/lib/junk-destination";
111
+ import { listNarrowing } from "@/lib/list-narrowing";
112
112
  import { useMailContext } from "@/lib/mail-context";
113
113
  import { useMailFreshness } from "@/lib/mail-freshness";
114
114
  import { isRescueCandidate } from "@/lib/rescue-candidates";
@@ -178,11 +178,17 @@ interface MailboxPaneContextValue {
178
178
  onToggleFilterAttribute: (id: string) => void;
179
179
  onClearFilters: () => void;
180
180
  /**
181
- * The active category filter as the empty state renders it — its label, the
182
- * way out of it, and how much of the mailbox the request reached. Undefined
183
- * when no category is selected.
181
+ * What narrows the list as the empty state renders it — the narrowing named,
182
+ * the way out of it, and how much of the mailbox the request reached.
183
+ * Undefined when nothing narrows the list.
184
184
  */
185
185
  listFilter: MessageListFilter | undefined;
186
+ /**
187
+ * The free text the empty state names. The typed tokens narrow the list too,
188
+ * but `listFilter` already names them, and a headline saying the same
189
+ * narrowing twice reads as two.
190
+ */
191
+ listSearchText: string | undefined;
186
192
  onToggleIntelligence: () => void;
187
193
  /**
188
194
  * Where the mounted reading surface publishes the intelligence commands the
@@ -277,7 +283,8 @@ function MailboxPaneProvider({
277
283
  const threadId = thread?.threadId;
278
284
  const pointedAtMessageId = thread?.messageId;
279
285
  const telemetry = useTelemetry();
280
- const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
286
+ const { accounts, searchQuery, onToggleIntelligence, onSearchClearQuery } =
287
+ useMailContext();
281
288
  const tokenContext = useSearchTokenContext();
282
289
 
283
290
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -295,6 +302,13 @@ function MailboxPaneProvider({
295
302
  normalizedSearchQuery,
296
303
  tokenContext,
297
304
  );
305
+ // The same free text in the reader's own casing. The request's copy is folded
306
+ // to lowercase so equivalent searches share a cache entry; a sentence quoting
307
+ // the query back to the reader must not be.
308
+ const typedFreeText = parseSearchTokens(
309
+ searchQuery.trim(),
310
+ tokenContext,
311
+ ).freeText;
298
312
 
299
313
  const [filterCategory, setFilterCategory] = useState("all");
300
314
  const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
@@ -445,20 +459,25 @@ function MailboxPaneProvider({
445
459
  setFilterAttributes(new Set());
446
460
  }, []);
447
461
 
462
+ // The empty state's way out has to clear everything its headline names, or
463
+ // it is a button that leaves the list exactly as narrowed as it found it.
464
+ const onClearNarrowing = useCallback(() => {
465
+ onClearFilters();
466
+ onSearchClearQuery();
467
+ }, [onClearFilters, onSearchClearQuery]);
468
+
448
469
  // The empty state has to say how much was read, and the reach comes off the
449
470
  // request rather than the call site: the day a chip is answered over a window
450
471
  // instead of the whole mailbox, the sentence changes with it.
451
- const filterLabel = categoryLabel(filterCategory);
452
- const listFilter: MessageListFilter | undefined = filterLabel
453
- ? {
454
- label: filterLabel,
455
- reach:
456
- residualTokens.length > 0
457
- ? "loaded-pages"
458
- : filterReach(searchThreadsQuery),
459
- onClear: onClearFilters,
460
- }
461
- : undefined;
472
+ const listFilter: MessageListFilter | undefined = listNarrowing({
473
+ chips: filterCriteria,
474
+ tokens: searchTokens,
475
+ reach:
476
+ residualTokens.length > 0
477
+ ? "loaded-pages"
478
+ : filterReach(searchThreadsQuery),
479
+ onClear: onClearNarrowing,
480
+ });
462
481
 
463
482
  // The row this folder itself lists, preferred because a mutation patches it in
464
483
  // place. A thread the loaded pages do not hold — a chip that paged it out, a
@@ -799,6 +818,7 @@ function MailboxPaneProvider({
799
818
  onToggleFilterAttribute,
800
819
  onClearFilters,
801
820
  listFilter,
821
+ listSearchText: typedFreeText || undefined,
802
822
  onToggleIntelligence,
803
823
  intelligenceRef,
804
824
  handleDeselectIfRemoved,
@@ -875,6 +895,7 @@ function MailboxList() {
875
895
  onToggleFilterAttribute,
876
896
  onClearFilters,
877
897
  listFilter,
898
+ listSearchText,
878
899
  searchPredicate,
879
900
  resultCount,
880
901
  } = useMailboxPane();
@@ -1011,6 +1032,7 @@ function MailboxList() {
1011
1032
  accountId={mailboxAccountId}
1012
1033
  listTitle={listTitle}
1013
1034
  listFilter={listFilter}
1035
+ listSearchText={listSearchText}
1014
1036
  listScopeLabel={listTitle}
1015
1037
  hideHeader
1016
1038
  onTriageContextChange={onTriageContextChange}
@@ -142,12 +142,18 @@ interface MessageListProps {
142
142
  */
143
143
  listMeta?: string;
144
144
  /**
145
- * The active category filter, when the view has one. The empty state needs
146
- * it to say it is filtered and how much of the collection the request
147
- * reached; without it a narrowed list renders as an empty mailbox, which is
148
- * the shape #315's bug hid behind.
145
+ * What narrows the list, when anything does. The empty state needs it to say
146
+ * it is filtered and how much of the collection the request reached; without
147
+ * it a narrowed list renders as an empty mailbox, which is the shape #315's
148
+ * bug hid behind.
149
149
  */
150
150
  listFilter?: MessageListFilter;
151
+ /**
152
+ * The free text the empty state names, which is `searchQuery` with the typed
153
+ * filter tokens taken out: `listFilter` already names those, and a headline
154
+ * saying one narrowing twice reads as two.
155
+ */
156
+ listSearchText?: string;
151
157
  /** Name of the collection being listed, e.g. "Inbox", for the empty state. */
152
158
  listScopeLabel?: string;
153
159
  /**
@@ -233,6 +239,7 @@ export const MessageList = ({
233
239
  listTitle,
234
240
  listMeta,
235
241
  listFilter,
242
+ listSearchText,
236
243
  listScopeLabel,
237
244
  onTriageContextChange,
238
245
  commandsRef,
@@ -1301,7 +1308,7 @@ export const MessageList = ({
1301
1308
  // The results panel is a body, not a list state: it stands in for the
1302
1309
  // rows and must not fall behind a skeleton while the query re-keys.
1303
1310
  listState={listHeaderChrome.searchResults ? "ready" : listState}
1304
- searchQuery={isSearching ? searchQuery : undefined}
1311
+ searchQuery={listSearchText}
1305
1312
  listFilter={listFilter}
1306
1313
  listScopeLabel={listScopeLabel}
1307
1314
  errorMessage={errorMessage}
@@ -0,0 +1,240 @@
1
+ /**
2
+ * A list narrowed by anything at all says so when it comes back empty (#1126).
3
+ *
4
+ * Both list panes built the empty state's filter from the category chip alone,
5
+ * and `MessageListEmpty` reads that object's presence as "a filter is active".
6
+ * So the Unread chip on its own — or `is:unread` typed in the field — fell
7
+ * through to the unfiltered copy: "No messages in Starred" over a collection
8
+ * holding thousands of starred messages, with no way out offered.
9
+ *
10
+ * Driven through the chips the reader actually presses, in the real panes, so
11
+ * the assertion is about what the screen says rather than what a helper returns.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import {
17
+ type AnyRouter,
18
+ createMemoryHistory,
19
+ createRootRoute,
20
+ createRoute,
21
+ createRouter,
22
+ Outlet,
23
+ RouterProvider,
24
+ } from "@tanstack/react-router";
25
+ import { type ComponentType, createElement, type ReactNode } from "react";
26
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
27
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
28
+ import { MailFreshnessProvider } from "@/lib/mail-freshness";
29
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
30
+ import { useOpenThreadPath } from "@/routing";
31
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
32
+ import { type HttpMock, mockFetch } from "@/test-support/http";
33
+ import { FlaggedPane } from "./FlaggedPane";
34
+ import { MailboxPane } from "./MailboxPane";
35
+
36
+ const MAILBOX_ID = "11111111-1111-4111-8111-111111111111";
37
+ const DESKTOP_WIDTH = 1440;
38
+
39
+ let harness: DomHarness | undefined;
40
+ let http: HttpMock | undefined;
41
+
42
+ afterEach(() => {
43
+ harness?.close();
44
+ harness = undefined;
45
+ http?.restore();
46
+ http = undefined;
47
+ });
48
+
49
+ // The router reads `self` at construction; the shared jsdom globals stop at
50
+ // `window`.
51
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
52
+
53
+ interface PaneUnderTest {
54
+ name: string;
55
+ /** The list's own segment under `/mail`, which is its whole route. */
56
+ segment: string;
57
+ /** What the empty state calls the collection. */
58
+ scopeLabel: string;
59
+ List: ComponentType;
60
+ mount: (children: ReactNode) => ReactNode;
61
+ }
62
+
63
+ const panes: PaneUnderTest[] = [
64
+ {
65
+ name: "Starred",
66
+ segment: "flagged",
67
+ scopeLabel: "Starred",
68
+ List: FlaggedPane.List,
69
+ mount: (children) =>
70
+ createElement(FlaggedPane, {
71
+ thread: useOpenThreadPath(),
72
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
73
+ children,
74
+ }),
75
+ },
76
+ {
77
+ name: "the mailbox",
78
+ segment: MAILBOX_ID,
79
+ scopeLabel: "Inbox",
80
+ List: MailboxPane.List,
81
+ mount: (children) =>
82
+ createElement(MailboxPane, {
83
+ mailboxId: MAILBOX_ID,
84
+ thread: useOpenThreadPath(),
85
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
86
+ children,
87
+ }),
88
+ },
89
+ ];
90
+
91
+ /** The layout context the panes read the search field out of. */
92
+ const mailContext = (query: string): MailContextValue => ({
93
+ accounts: [],
94
+ mailboxNameIndex: new Map(),
95
+ accountNameIndex: new Map(),
96
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
97
+ searchQuery: query,
98
+ searchInput: query,
99
+ searchViewKey: "list",
100
+ onSearchChange: () => undefined,
101
+ onSearchClear: () => undefined,
102
+ onSearchClearQuery: () => undefined,
103
+ intelligenceOpen: false,
104
+ onToggleIntelligence: () => undefined,
105
+ onRaiseIntelligence: () => undefined,
106
+ });
107
+
108
+ const testRouter = (pane: PaneUnderTest, query: string): AnyRouter => {
109
+ const rootRoute = createRootRoute({
110
+ component: () =>
111
+ createElement(MailFreshnessProvider, {
112
+ accountIds: [],
113
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
114
+ children: createElement(
115
+ MailContext.Provider,
116
+ { value: mailContext(query) },
117
+ createElement(ComposeProvider, null, createElement(Outlet)),
118
+ ),
119
+ }),
120
+ });
121
+ const mailRoute = createRoute({
122
+ getParentRoute: () => rootRoute,
123
+ path: "/mail",
124
+ validateSearch: (search: Record<string, unknown>) => search,
125
+ component: Outlet,
126
+ });
127
+ const mailboxRoute = createRoute({
128
+ getParentRoute: () => mailRoute,
129
+ path: "/$mailboxId",
130
+ component: () => pane.mount(createElement(pane.List)),
131
+ });
132
+ const flaggedRoute = createRoute({
133
+ getParentRoute: () => mailRoute,
134
+ path: "/flagged",
135
+ component: () => pane.mount(createElement(pane.List)),
136
+ });
137
+ const routeTree = rootRoute.addChildren([
138
+ mailRoute.addChildren([mailboxRoute, flaggedRoute]),
139
+ ]);
140
+ return createRouter({
141
+ routeTree,
142
+ history: createMemoryHistory({
143
+ initialEntries: [`/mail/${pane.segment}`],
144
+ }),
145
+ }) as unknown as AnyRouter;
146
+ };
147
+
148
+ const mount = async (pane: PaneUnderTest, query = ""): Promise<DomHarness> => {
149
+ http = mockFetch((call) => {
150
+ if (call.path.endsWith("/config")) return { accounts: [] };
151
+ if (call.path.endsWith("/mailboxes")) {
152
+ return {
153
+ items: [{ mailboxId: MAILBOX_ID, name: "Inbox", fullPath: "INBOX" }],
154
+ };
155
+ }
156
+ return { items: [] };
157
+ });
158
+
159
+ const router = testRouter(pane, query);
160
+ await router.load();
161
+ const mounted = createDomHarness({ viewportWidth: DESKTOP_WIDTH });
162
+ harness = mounted;
163
+ mounted.renderApp(createElement(RouterProvider, { router }));
164
+ await mounted.waitFor(
165
+ () => mounted.text().includes("No "),
166
+ "the empty list to settle",
167
+ );
168
+ return mounted;
169
+ };
170
+
171
+ /** Press the Unread chip, which is the whole narrowing this test is about. */
172
+ const pressUnread = async (mounted: DomHarness): Promise<void> => {
173
+ mounted.click(mounted.byLabel("Expand filters"));
174
+ await mounted.flush();
175
+ const attributes = mounted.query('fieldset[aria-label="Attributes"]');
176
+ assert.ok(attributes, "the attribute chips are not on screen");
177
+ const unread = [...attributes.querySelectorAll("button")].find(
178
+ (button) => button.textContent === "Unread",
179
+ );
180
+ assert.ok(unread, "there is no Unread chip to press");
181
+ mounted.click(unread);
182
+ await mounted.waitFor(
183
+ () => mounted.text().includes("No "),
184
+ "the narrowed list to answer",
185
+ );
186
+ };
187
+
188
+ describe("an empty list narrowed by a chip or a token (#1126)", () => {
189
+ for (const pane of panes) {
190
+ it(`${pane.name} names the narrowing instead of the collection`, async () => {
191
+ const mounted = await mount(pane);
192
+ assert.match(
193
+ mounted.text(),
194
+ new RegExp(`No messages in ${pane.scopeLabel}`),
195
+ "the unfiltered copy is the state this test starts from",
196
+ );
197
+
198
+ await pressUnread(mounted);
199
+
200
+ const shown = mounted.text();
201
+ assert.match(shown, new RegExp(`No unread mail in ${pane.scopeLabel}`));
202
+ assert.doesNotMatch(
203
+ shown,
204
+ new RegExp(`No messages in ${pane.scopeLabel}`),
205
+ "a filtered list told the reader the collection itself was empty",
206
+ );
207
+ });
208
+
209
+ it(`${pane.name} answers a token-only search the same way`, async () => {
210
+ const mounted = await mount(pane, "is:unread");
211
+
212
+ const shown = mounted.text();
213
+ assert.match(shown, new RegExp(`No unread mail in ${pane.scopeLabel}`));
214
+ assert.doesNotMatch(
215
+ shown,
216
+ new RegExp(`No messages in ${pane.scopeLabel}`),
217
+ "a search of tokens alone read as an empty collection",
218
+ );
219
+ assert.doesNotMatch(
220
+ shown,
221
+ /No messages match your search/,
222
+ "the narrowing is named, so the generic search line is wrong here",
223
+ );
224
+ });
225
+
226
+ it(`${pane.name} says how much was checked and offers the way out`, async () => {
227
+ const mounted = await mount(pane);
228
+ await pressUnread(mounted);
229
+
230
+ const shown = mounted.text();
231
+ assert.match(shown, /Every message in this folder was checked\./);
232
+ assert.ok(
233
+ [...mounted.queryAll("button")].some(
234
+ (button) => button.textContent === "Clear filter",
235
+ ),
236
+ "a narrowed empty list offered no way back out of the narrowing",
237
+ );
238
+ });
239
+ }
240
+ });
@@ -0,0 +1,88 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { narrowingLabel } from "./list-narrowing.js";
4
+ import { parseSearchTokens, type SearchToken } from "./search-tokens.js";
5
+
6
+ const chips = (
7
+ category = "all",
8
+ attributes: string[] = [],
9
+ ): { category: string; attributes: ReadonlySet<string> } => ({
10
+ category,
11
+ attributes: new Set(attributes),
12
+ });
13
+
14
+ const tokensOf = (query: string): SearchToken[] =>
15
+ parseSearchTokens(query).tokens;
16
+
17
+ describe("narrowingLabel", () => {
18
+ it("says nothing when nothing narrows the list", () => {
19
+ assert.equal(narrowingLabel(chips(), []), undefined);
20
+ });
21
+
22
+ it("treats free text as no narrowing — the search copy already names it", () => {
23
+ assert.equal(narrowingLabel(chips(), tokensOf("invoice")), undefined);
24
+ });
25
+
26
+ it("names the category chip exactly as it always did", () => {
27
+ assert.equal(narrowingLabel(chips("personal"), []), "Personal mail");
28
+ });
29
+
30
+ it("names an attribute chip with no category chosen (#1126)", () => {
31
+ assert.equal(narrowingLabel(chips("all", ["unread"]), []), "unread mail");
32
+ assert.equal(
33
+ narrowingLabel(chips("all", ["attachment"]), []),
34
+ "mail with an attachment",
35
+ );
36
+ });
37
+
38
+ it("names a token-only search (#1126)", () => {
39
+ assert.equal(narrowingLabel(chips(), tokensOf("is:unread")), "unread mail");
40
+ assert.equal(
41
+ narrowingLabel(chips(), tokensOf("has:attachment")),
42
+ "mail with an attachment",
43
+ );
44
+ });
45
+
46
+ it("orders every narrowing into one phrase", () => {
47
+ assert.equal(
48
+ narrowingLabel(chips("personal", ["unread", "attachment"]), []),
49
+ "Personal unread mail with an attachment",
50
+ );
51
+ });
52
+
53
+ it("says one narrowing once when a chip and a token agree", () => {
54
+ assert.equal(
55
+ narrowingLabel(chips("all", ["unread"]), tokensOf("is:unread")),
56
+ "unread mail",
57
+ );
58
+ });
59
+
60
+ it("lets the chip win where a token contradicts it, as the request does", () => {
61
+ assert.equal(
62
+ narrowingLabel(chips("personal"), tokensOf("category:newsletter")),
63
+ "Personal mail",
64
+ );
65
+ assert.equal(
66
+ narrowingLabel(chips("all", ["unread"]), tokensOf("is:read")),
67
+ "unread mail",
68
+ );
69
+ });
70
+
71
+ it("names the tokens no request parameter carries", () => {
72
+ assert.equal(
73
+ narrowingLabel(chips(), tokensOf("before:2024-01-01")),
74
+ "mail sent before 2024-01-01",
75
+ );
76
+ assert.equal(
77
+ narrowingLabel(chips(), tokensOf("from:alex@example.com")),
78
+ "mail from alex@example.com",
79
+ );
80
+ });
81
+
82
+ it("keeps two values of the same token, which narrow to neither one", () => {
83
+ assert.equal(
84
+ narrowingLabel(chips(), tokensOf("from:alex from:priya")),
85
+ "mail from alex from priya",
86
+ );
87
+ });
88
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Whether a list is narrowed, and by what — one derived fact for every list.
3
+ *
4
+ * `MessageListEmpty` reads the presence of a `MessageListFilter` as "something
5
+ * is filtering this list". Both list panes used to build that object from the
6
+ * category chip alone, so a reader who had narrowed by the Unread chip, the Has
7
+ * attachment chip, or a typed `is:unread` was told the collection itself was
8
+ * empty — "No messages in Starred" over a mailbox full of starred mail (#1126).
9
+ *
10
+ * Every narrowing the two panes offer therefore lands here: the chips the view
11
+ * holds and the tokens parsed out of the search field. A chip beats a token
12
+ * where both set the same parameter, exactly as the request builders resolve
13
+ * them, so the sentence and the query can never disagree.
14
+ */
15
+
16
+ import type { FilterReach, MessageListFilter } from "@remit/ui";
17
+ import { categoryLabel, type InboxFilterCriteria } from "./inbox-filters.js";
18
+ import type { SearchToken } from "./search-tokens.js";
19
+
20
+ /**
21
+ * One narrowing, as words. `pre` qualifies "mail" before it ("unread mail"),
22
+ * `post` after it ("mail with an attachment"). `dimension` is what it narrows
23
+ * by, so two sources naming the same thing are said once.
24
+ */
25
+ interface NarrowingPart {
26
+ dimension: string;
27
+ position: "pre" | "post";
28
+ text: string;
29
+ }
30
+
31
+ const UNREAD: NarrowingPart = {
32
+ dimension: "read",
33
+ position: "pre",
34
+ text: "unread",
35
+ };
36
+ const READ: NarrowingPart = {
37
+ dimension: "read",
38
+ position: "pre",
39
+ text: "read",
40
+ };
41
+ const STARRED: NarrowingPart = {
42
+ dimension: "starred",
43
+ position: "pre",
44
+ text: "starred",
45
+ };
46
+ const ATTACHMENT: NarrowingPart = {
47
+ dimension: "attachment",
48
+ position: "post",
49
+ text: "with an attachment",
50
+ };
51
+
52
+ /** The attribute chips, in the order the phrase says them. */
53
+ const ATTRIBUTE_PARTS: readonly (readonly [string, NarrowingPart])[] = [
54
+ ["unread", UNREAD],
55
+ ["flagged", STARRED],
56
+ ["attachment", ATTACHMENT],
57
+ ];
58
+
59
+ const tokenPart = (token: SearchToken): NarrowingPart => {
60
+ switch (token.type) {
61
+ case "category":
62
+ return {
63
+ dimension: "category",
64
+ position: "pre",
65
+ text: categoryLabel(token.category) ?? token.value,
66
+ };
67
+ case "isUnread":
68
+ return UNREAD;
69
+ case "isRead":
70
+ return READ;
71
+ case "isStarred":
72
+ return STARRED;
73
+ case "hasAttachment":
74
+ return ATTACHMENT;
75
+ case "from":
76
+ return {
77
+ dimension: token.raw,
78
+ position: "post",
79
+ text: `from ${token.value}`,
80
+ };
81
+ case "subject":
82
+ return {
83
+ dimension: token.raw,
84
+ position: "post",
85
+ text: `about “${token.value}”`,
86
+ };
87
+ case "before":
88
+ return {
89
+ dimension: token.raw,
90
+ position: "post",
91
+ text: `sent before ${token.value}`,
92
+ };
93
+ case "after":
94
+ return {
95
+ dimension: token.raw,
96
+ position: "post",
97
+ text: `sent on or after ${token.value}`,
98
+ };
99
+ case "in":
100
+ return {
101
+ dimension: token.raw,
102
+ position: "post",
103
+ text: `in ${token.value}`,
104
+ };
105
+ case "account":
106
+ return {
107
+ dimension: token.raw,
108
+ position: "post",
109
+ text: `in the ${token.value} account`,
110
+ };
111
+ }
112
+ };
113
+
114
+ /** Everything narrowing the list, chips first, each dimension said once. */
115
+ const narrowingParts = (
116
+ chips: InboxFilterCriteria,
117
+ tokens: readonly SearchToken[],
118
+ ): NarrowingPart[] => {
119
+ const byDimension = new Map<string, NarrowingPart>();
120
+ const add = (part: NarrowingPart) => {
121
+ if (byDimension.has(part.dimension)) return;
122
+ byDimension.set(part.dimension, part);
123
+ };
124
+
125
+ const category = categoryLabel(chips.category);
126
+ if (category) add({ dimension: "category", position: "pre", text: category });
127
+ for (const [id, part] of ATTRIBUTE_PARTS) {
128
+ if (chips.attributes.has(id)) add(part);
129
+ }
130
+ for (const token of tokens) add(tokenPart(token));
131
+
132
+ return [...byDimension.values()];
133
+ };
134
+
135
+ /**
136
+ * The narrowing as the noun phrase the empty state's headline reads "No " in
137
+ * front of, or `undefined` when nothing narrows the list.
138
+ */
139
+ export const narrowingLabel = (
140
+ chips: InboxFilterCriteria,
141
+ tokens: readonly SearchToken[],
142
+ ): string | undefined => {
143
+ const parts = narrowingParts(chips, tokens);
144
+ if (parts.length === 0) return undefined;
145
+ const said = (position: "pre" | "post") =>
146
+ parts.filter((part) => part.position === position).map((p) => p.text);
147
+ return [...said("pre"), "mail", ...said("post")].join(" ");
148
+ };
149
+
150
+ export interface ListNarrowing {
151
+ /** The chip state the view holds. */
152
+ chips: InboxFilterCriteria;
153
+ /** Every token the search field carries, sent as a parameter or not. */
154
+ tokens: readonly SearchToken[];
155
+ /** How much of the collection the request reached, read off the request. */
156
+ reach: FilterReach;
157
+ /** Drops every narrowing the label names — the chips and the query alike. */
158
+ onClear: () => void;
159
+ }
160
+
161
+ /** The filter a narrowed list hands its empty state, or `undefined` when it is
162
+ * not narrowed at all. */
163
+ export const listNarrowing = ({
164
+ chips,
165
+ tokens,
166
+ reach,
167
+ onClear,
168
+ }: ListNarrowing): MessageListFilter | undefined => {
169
+ const label = narrowingLabel(chips, tokens);
170
+ if (!label) return undefined;
171
+ return { label, reach, onClear };
172
+ };