@remit/web-client 0.0.254 → 0.0.255

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.254",
3
+ "version": "0.0.255",
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";
48
49
  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,8 +126,7 @@ export function FlaggedList({
126
126
  onTriageContextChange,
127
127
  onDeleteMessages,
128
128
  }: FlaggedListProps) {
129
- const { searchQuery, resultFolderIndex, onSearchClearQuery } =
130
- useMailContext();
129
+ const { searchQuery, resultFolderIndex } = useMailContext();
131
130
  const tokenContext = useSearchTokenContext();
132
131
  const isDesktop = useIsDesktop();
133
132
  const wizard = useSelectionWizard();
@@ -151,24 +150,10 @@ export function FlaggedList({
151
150
  setActiveFilters(new Set());
152
151
  }, []);
153
152
 
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
-
161
153
  const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
162
154
  searchQuery.trim().toLowerCase(),
163
155
  tokenContext,
164
156
  );
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;
172
157
 
173
158
  // The chips and the tokens together, as query parameters: a category typed as
174
159
  // `category:personal` narrows the request exactly as the chip does.
@@ -253,19 +238,15 @@ export function FlaggedList({
253
238
  // the request. Every chip and every carried token is a column on the row, so
254
239
  // the server answered over the whole collection; a residual token, or the
255
240
  // snippet half of a free-text search, only ever saw the pages loaded so far.
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
- });
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;
269
250
 
270
251
  const listState = isLoading
271
252
  ? "loading"
@@ -346,9 +327,7 @@ export function FlaggedList({
346
327
  // Flagged spans accounts and folders, so it is a collection and
347
328
  // never "this mailbox". It names itself.
348
329
  listScopeLabel="Starred"
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}
330
+ searchQuery={sq ? searchQuery : undefined}
352
331
  errorMessage={isError ? formatErrorMessage(error) : undefined}
353
332
  onRetry={() => refetch()}
354
333
  onReportError={handleReportError}
@@ -100,6 +100,7 @@ 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,
103
104
  filterReach,
104
105
  hasInboxFilter,
105
106
  type InboxFilterCriteria,
@@ -108,7 +109,6 @@ import {
108
109
  sameInboxFilter,
109
110
  } from "@/lib/inbox-filters";
110
111
  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,17 +178,11 @@ interface MailboxPaneContextValue {
178
178
  onToggleFilterAttribute: (id: string) => void;
179
179
  onClearFilters: () => void;
180
180
  /**
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.
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.
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;
192
186
  onToggleIntelligence: () => void;
193
187
  /**
194
188
  * Where the mounted reading surface publishes the intelligence commands the
@@ -283,8 +277,7 @@ function MailboxPaneProvider({
283
277
  const threadId = thread?.threadId;
284
278
  const pointedAtMessageId = thread?.messageId;
285
279
  const telemetry = useTelemetry();
286
- const { accounts, searchQuery, onToggleIntelligence, onSearchClearQuery } =
287
- useMailContext();
280
+ const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
288
281
  const tokenContext = useSearchTokenContext();
289
282
 
290
283
  const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
@@ -302,13 +295,6 @@ function MailboxPaneProvider({
302
295
  normalizedSearchQuery,
303
296
  tokenContext,
304
297
  );
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;
312
298
 
313
299
  const [filterCategory, setFilterCategory] = useState("all");
314
300
  const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
@@ -459,25 +445,20 @@ function MailboxPaneProvider({
459
445
  setFilterAttributes(new Set());
460
446
  }, []);
461
447
 
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
-
469
448
  // The empty state has to say how much was read, and the reach comes off the
470
449
  // request rather than the call site: the day a chip is answered over a window
471
450
  // instead of the whole mailbox, the sentence changes with it.
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
- });
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;
481
462
 
482
463
  // The row this folder itself lists, preferred because a mutation patches it in
483
464
  // place. A thread the loaded pages do not hold — a chip that paged it out, a
@@ -818,7 +799,6 @@ function MailboxPaneProvider({
818
799
  onToggleFilterAttribute,
819
800
  onClearFilters,
820
801
  listFilter,
821
- listSearchText: typedFreeText || undefined,
822
802
  onToggleIntelligence,
823
803
  intelligenceRef,
824
804
  handleDeselectIfRemoved,
@@ -895,7 +875,6 @@ function MailboxList() {
895
875
  onToggleFilterAttribute,
896
876
  onClearFilters,
897
877
  listFilter,
898
- listSearchText,
899
878
  searchPredicate,
900
879
  resultCount,
901
880
  } = useMailboxPane();
@@ -1032,7 +1011,6 @@ function MailboxList() {
1032
1011
  accountId={mailboxAccountId}
1033
1012
  listTitle={listTitle}
1034
1013
  listFilter={listFilter}
1035
- listSearchText={listSearchText}
1036
1014
  listScopeLabel={listTitle}
1037
1015
  hideHeader
1038
1016
  onTriageContextChange={onTriageContextChange}
@@ -142,18 +142,12 @@ interface MessageListProps {
142
142
  */
143
143
  listMeta?: string;
144
144
  /**
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.
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.
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;
157
151
  /** Name of the collection being listed, e.g. "Inbox", for the empty state. */
158
152
  listScopeLabel?: string;
159
153
  /**
@@ -239,7 +233,6 @@ export const MessageList = ({
239
233
  listTitle,
240
234
  listMeta,
241
235
  listFilter,
242
- listSearchText,
243
236
  listScopeLabel,
244
237
  onTriageContextChange,
245
238
  commandsRef,
@@ -1308,7 +1301,7 @@ export const MessageList = ({
1308
1301
  // The results panel is a body, not a list state: it stands in for the
1309
1302
  // rows and must not fall behind a skeleton while the query re-keys.
1310
1303
  listState={listHeaderChrome.searchResults ? "ready" : listState}
1311
- searchQuery={listSearchText}
1304
+ searchQuery={isSearching ? searchQuery : undefined}
1312
1305
  listFilter={listFilter}
1313
1306
  listScopeLabel={listScopeLabel}
1314
1307
  errorMessage={errorMessage}
@@ -1,240 +0,0 @@
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
- });
@@ -1,88 +0,0 @@
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
- });
@@ -1,172 +0,0 @@
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
- };