@remit/web-client 0.0.139 → 0.0.141

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.139",
3
+ "version": "0.0.141",
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": {
@@ -13,7 +13,9 @@
13
13
  * the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
14
14
  * Starred views. The category and the chips narrow the grouped sections
15
15
  * themselves, and the phone search takeover reads the same selection, so a
16
- * filter set on one surface holds on the other.
16
+ * filter set on one surface holds on the other. Under a query the chips are
17
+ * terms of that query — see `briefChipFilters` — so what narrows the list is
18
+ * readable and editable in the search field.
17
19
  *
18
20
  * Multi-select is the mailbox list's, not a copy of it: the same
19
21
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
@@ -39,7 +41,11 @@ import {
39
41
  BriefEmpty,
40
42
  type BriefFilterId,
41
43
  BriefSections,
44
+ briefChipCategory,
45
+ briefChipFilters,
42
46
  briefFilterConfig,
47
+ briefFilterHasTerm,
48
+ clearBriefFiltersInQuery,
43
49
  FilterPanelProvider,
44
50
  type FilterSheetProps,
45
51
  type FilterSheetSource,
@@ -50,8 +56,10 @@ import {
50
56
  type SearchResult,
51
57
  SelectionTopBar,
52
58
  SpamResultsOffer,
59
+ setBriefCategoryInQuery,
53
60
  type ThreadRowData,
54
61
  type ThreadSection,
62
+ toggleBriefFilterInQuery,
55
63
  } from "@remit/ui";
56
64
  import { useQueries, useQuery } from "@tanstack/react-query";
57
65
  import { useNavigate } from "@tanstack/react-router";
@@ -69,6 +77,7 @@ import {
69
77
  useInitialSyncProgress,
70
78
  } from "@/hooks/useInitialSyncProgress";
71
79
  import { useLabelList } from "@/hooks/useLabels";
80
+ import { useLayoutTier } from "@/hooks/useLayoutTier";
72
81
  import { useIsDesktop } from "@/hooks/useMediaQuery";
73
82
  import { useRefreshControl } from "@/hooks/useRefreshControl";
74
83
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
@@ -89,6 +98,7 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
89
98
  import { useMailContext } from "@/lib/mail-context";
90
99
  import { useMailFreshness } from "@/lib/mail-freshness";
91
100
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
101
+ import { showInlineSearchResults } from "@/lib/search-surface";
92
102
  import { parseSearchTokens } from "@/lib/search-tokens";
93
103
  import { spamOfferForResults } from "@/lib/spam-offer";
94
104
  import {
@@ -394,9 +404,11 @@ export function DailyBrief({
394
404
  onTriageContextChange,
395
405
  onDeleteMessages,
396
406
  }: DailyBriefProps) {
397
- const { searchQuery, searchInput, resultFolderIndex } = useMailContext();
407
+ const { searchQuery, searchInput, resultFolderIndex, onSearchChange } =
408
+ useMailContext();
398
409
  const tokenContext = useSearchTokenContext();
399
410
  const isDesktop = useIsDesktop();
411
+ const tier = useLayoutTier();
400
412
  const wizard = useSelectionWizard();
401
413
  const navigate = useNavigate();
402
414
 
@@ -430,14 +442,54 @@ export function DailyBrief({
430
442
  }, []);
431
443
  const clearFilters = useCallback(() => setActiveFilters(new Set()), []);
432
444
 
433
- // A query owns the pane: the filter panel and the search's own affordance
434
- // narrow the same list from the same place, so the panel stands down for as
435
- // long as something is being searched. Its state survives, so clearing the
436
- // query brings it back with the same category and chips. The header caret is
437
- // gone under a query, and a panel left open with nothing to collapse it is
438
- // what makes this load-bearing rather than tidy.
439
445
  const searching = searchInput.trim().length > 0;
440
446
 
447
+ // Under a query the chips and the query are one state: a chip writes its term
448
+ // into the query — `is:unread`, `has:attachment`, `category:newsletter` — and
449
+ // a term typed by hand ticks its chip. What narrows the rows is then legible
450
+ // in the field, editable there, and gone when the term is deleted. The
451
+ // panel's own set survives a search and comes back with it, and carries the
452
+ // two chips the vocabulary cannot spell (see `briefChipFilters`).
453
+ const chipFilters = useMemo(
454
+ () => briefChipFilters({ query: searchInput, ownFilters: activeFilters }),
455
+ [searchInput, activeFilters],
456
+ );
457
+ const chipCategory = useMemo(
458
+ () =>
459
+ briefChipCategory({ query: searchInput, ownCategory: selectedCategory }),
460
+ [searchInput, selectedCategory],
461
+ );
462
+
463
+ const toggleChip = useCallback(
464
+ (id: BriefFilterId) => {
465
+ if (!searching || !briefFilterHasTerm(id)) {
466
+ toggleFilter(id);
467
+ return;
468
+ }
469
+ const next = toggleBriefFilterInQuery(searchInput, id);
470
+ if (next !== undefined) onSearchChange(next);
471
+ },
472
+ [searching, searchInput, onSearchChange, toggleFilter],
473
+ );
474
+
475
+ const selectChipCategory = useCallback(
476
+ (category: BriefCategoryFilter) => {
477
+ if (!searching) {
478
+ setSelectedCategory(category);
479
+ return;
480
+ }
481
+ onSearchChange(setBriefCategoryInQuery(searchInput, category));
482
+ },
483
+ [searching, searchInput, onSearchChange],
484
+ );
485
+
486
+ const clearChips = useCallback(() => {
487
+ setSelectedCategory("all");
488
+ setSelectedAccountId("all");
489
+ clearFilters();
490
+ if (searching) onSearchChange(clearBriefFiltersInQuery(searchInput));
491
+ }, [searching, searchInput, onSearchChange, clearFilters]);
492
+
441
493
  // --- Unified threads query ---
442
494
  const {
443
495
  data: threadsData,
@@ -647,11 +699,11 @@ export function DailyBrief({
647
699
  filteredRows
648
700
  .filter(
649
701
  (t) =>
650
- (selectedCategory === "all" || t.category === selectedCategory) &&
651
- matchesBriefFilters(t, activeFilters),
702
+ (chipCategory === "all" || t.category === chipCategory) &&
703
+ matchesBriefFilters(t, chipFilters),
652
704
  )
653
705
  .map((row) => rowToSearchResult(row, resultFolderIndex)),
654
- [filteredRows, selectedCategory, activeFilters, resultFolderIndex],
706
+ [filteredRows, chipCategory, chipFilters, resultFolderIndex],
655
707
  );
656
708
 
657
709
  // "Related" (semantic) spans every account here — the brief is the
@@ -688,28 +740,25 @@ export function DailyBrief({
688
740
  filters: preset.filters,
689
741
  sources: preset.sources,
690
742
  sourcesNote: mutedCount > 0 ? `+${mutedCount} muted` : undefined,
691
- selectedCategory,
692
- activeFilters,
743
+ selectedCategory: chipCategory,
744
+ activeFilters: chipFilters,
693
745
  expanded: filterExpanded,
694
746
  onExpandedChange: setFilterExpanded,
695
747
  onSelectCategory: (id: string) =>
696
- setSelectedCategory(id as BriefCategoryFilter),
748
+ selectChipCategory(id as BriefCategoryFilter),
697
749
  onSelectSource: setSelectedAccountId,
698
- onToggleFilter: (id: string) => toggleFilter(id as BriefFilterId),
699
- onClear: () => {
700
- setSelectedCategory("all");
701
- setSelectedAccountId("all");
702
- clearFilters();
703
- },
750
+ onToggleFilter: (id: string) => toggleChip(id as BriefFilterId),
751
+ onClear: clearChips,
704
752
  };
705
753
  }, [
706
754
  accountSources,
707
755
  mutedCount,
708
- selectedCategory,
709
- activeFilters,
756
+ chipCategory,
757
+ chipFilters,
710
758
  filterExpanded,
711
- toggleFilter,
712
- clearFilters,
759
+ toggleChip,
760
+ selectChipCategory,
761
+ clearChips,
713
762
  ]);
714
763
 
715
764
  // The brief is genuinely empty (caught up) only when nothing is narrowing the
@@ -753,6 +802,18 @@ export function DailyBrief({
753
802
  // rather than opening nothing over a skeleton or an empty state.
754
803
  const showsRows = !isLoading && !isError && !caughtUp;
755
804
 
805
+ // A search does not take the panel down — the chips compose into the query
806
+ // rather than competing with it. The one window where the brief's own body is
807
+ // not on screen is the two-engine results panel, which owns the pane while a
808
+ // first query is still being typed; the caret stands down for exactly that,
809
+ // on the same answer the header swaps the body on.
810
+ const resultsPanelOwnsBody = showInlineSearchResults({
811
+ tier,
812
+ hasLiveInput: searching,
813
+ hasCommittedQuery: searchQuery.trim().length > 0,
814
+ bodyRendersCommittedResults: true,
815
+ });
816
+
756
817
  const stateBody = showsRows ? (
757
818
  <div className="flex h-full min-h-0 flex-col">
758
819
  {briefSpamOffer && (
@@ -774,18 +835,17 @@ export function DailyBrief({
774
835
  <div className="min-h-0 flex-1">
775
836
  <BriefSections
776
837
  sections={sections}
777
- briefCategory={selectedCategory}
838
+ briefCategory={chipCategory}
778
839
  Row={MessageRow}
779
840
  selectedThreadId={selectedMessageId}
780
841
  onSelectThread={openRow}
781
- onSelectBriefCategory={setSelectedCategory}
842
+ onSelectBriefCategory={selectChipCategory}
782
843
  sources={accountSources}
783
844
  sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
784
845
  onSelectSource={setSelectedAccountId}
785
- activeFilters={activeFilters}
786
- onToggleFilter={toggleFilter}
787
- onClearFilters={clearFilters}
788
- hideChrome={searching}
846
+ activeFilters={chipFilters}
847
+ onToggleFilter={toggleChip}
848
+ onClearFilters={clearChips}
789
849
  />
790
850
  </div>
791
851
  </div>
@@ -822,7 +882,7 @@ export function DailyBrief({
822
882
  // same pane for the same reason — the caret is in the header, the panel is
823
883
  // above the rows.
824
884
  return (
825
- <FilterPanelProvider hasSheet={showsRows && !searching}>
885
+ <FilterPanelProvider hasSheet={showsRows && !resultsPanelOwnsBody}>
826
886
  <ThreadListInteraction
827
887
  selectedMessageId={selectedMessageId}
828
888
  onOpen={openRow}
@@ -45,6 +45,7 @@ import {
45
45
  isConvertible,
46
46
  MakeFilterAction,
47
47
  MobileSearchView,
48
+ makeFilterBlockedCopy,
48
49
  SearchBar,
49
50
  type SearchCaretRequest,
50
51
  type SearchFieldSuggest,
@@ -368,7 +369,9 @@ export function MailListHeader({
368
369
  },
369
370
  blockedReason: isConvertible(conversion)
370
371
  ? undefined
371
- : "Add a sender or words to filter on",
372
+ : makeFilterBlockedCopy(
373
+ conversion.droppedFacets.map((facet) => facet.label),
374
+ ),
372
375
  }
373
376
  : undefined;
374
377
  // Handed to the bar rather than rendered here: the bar knows whether rows
@@ -484,6 +487,7 @@ export function MailListHeader({
484
487
  aria-label="Close search"
485
488
  className="shrink-0"
486
489
  />
490
+ <FilterToggle />
487
491
  </>
488
492
  ),
489
493
  }),
@@ -0,0 +1,85 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+ import {
5
+ briefQueryCategory,
6
+ briefQueryFilters,
7
+ setBriefCategoryInQuery,
8
+ toggleBriefFilterInQuery,
9
+ } from "@remit/ui";
10
+ import { matchesSearchTokens } from "./brief.js";
11
+ import { parseSearchTokens } from "./search-tokens.js";
12
+
13
+ /**
14
+ * The brief's chips and the token parser read one query, and this checks them
15
+ * against each other rather than against a second copy of either: a chip may
16
+ * only read ticked for a facet the parser actually applies, and a term a chip
17
+ * writes must narrow the rows the parser narrows.
18
+ *
19
+ * The spellings here are the ones a hand-typed query carries and a whitespace
20
+ * splitter got wrong.
21
+ */
22
+
23
+ const row = (overrides: Partial<ThreadRowData> = {}): ThreadRowData => ({
24
+ id: "m1",
25
+ accountId: "acc_1",
26
+ fromName: "Odido",
27
+ fromEmail: "info@odido.example",
28
+ subject: "Je factuur van juni",
29
+ snippet: "Snippet",
30
+ timeLabel: "09:00",
31
+ isRead: false,
32
+ hasAttachment: false,
33
+ starred: false,
34
+ category: "newsletter",
35
+ ...overrides,
36
+ });
37
+
38
+ const applies = (query: string, thread: ThreadRowData): boolean =>
39
+ matchesSearchTokens(thread, parseSearchTokens(query).tokens);
40
+
41
+ describe("the chips agree with the parser on what a query applies", () => {
42
+ it("leaves a facet spelled inside a quoted value to that value", () => {
43
+ const query = 'subject:"a is:unread b"';
44
+ assert.deepEqual(
45
+ parseSearchTokens(query).tokens.map((token) => token.type),
46
+ ["subject"],
47
+ );
48
+ assert.equal(briefQueryFilters(query).has("unread"), false);
49
+ });
50
+
51
+ it("ticks the chip for a facet whose own value is quoted", () => {
52
+ const query = 'is:"unread"';
53
+ assert.deepEqual(
54
+ parseSearchTokens(query).tokens.map((token) => token.type),
55
+ ["isUnread"],
56
+ );
57
+ assert.equal(briefQueryFilters(query).has("unread"), true);
58
+ });
59
+
60
+ it("scopes to the category a quoted value names", () => {
61
+ const query = 'category:"Newsletter"';
62
+ assert.equal(briefQueryCategory(query), "newsletter");
63
+ assert.equal(applies(query, row()), true);
64
+ });
65
+ });
66
+
67
+ describe("a term a chip writes narrows the rows", () => {
68
+ it("hides a read row once Unread is ticked", () => {
69
+ const query = toggleBriefFilterInQuery("Odido", "unread");
70
+ assert.ok(query);
71
+ assert.equal(applies(query, row({ isRead: false })), true);
72
+ assert.equal(applies(query, row({ isRead: true })), false);
73
+ });
74
+
75
+ // Facet tokens are ANDed, so a category pill that left the previous term in
76
+ // place matched nothing at all — an empty list one click from a full one.
77
+ it("keeps a category pill from emptying the list it was picked from", () => {
78
+ const query = setBriefCategoryInQuery('category:"Newsletter"', "marketing");
79
+ assert.deepEqual(
80
+ parseSearchTokens(query).tokens.map((token) => token.type),
81
+ ["category"],
82
+ );
83
+ assert.equal(applies(query, row({ category: "marketing" })), true);
84
+ });
85
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
- import { isConvertible } from "@remit/ui";
3
+ import { isConvertible, makeFilterBlockedCopy } from "@remit/ui";
4
4
  import { parseSearchTokens, type SearchTokenContext } from "../search-tokens";
5
5
  import { convertSearchToRule, searchRuleAccountId } from "./search-to-rule";
6
6
 
@@ -123,6 +123,20 @@ describe("convertSearchToRule — nothing left to make a filter from", () => {
123
123
  it("yields a clause once a term rides along with the folder scope", () => {
124
124
  assert.equal(isConvertible(convert("in:archive receipts")), true);
125
125
  });
126
+
127
+ // The brief's chips write their terms into the query, so a query can be made
128
+ // entirely of them. What is in the way is then the facets themselves, and the
129
+ // reason names them rather than asking for something that was just supplied.
130
+ it("names the facets a query composed only of chips is made of", () => {
131
+ const conversion = convert("is:unread category:newsletter");
132
+ assert.equal(isConvertible(conversion), false);
133
+ assert.equal(
134
+ makeFilterBlockedCopy(
135
+ conversion.droppedFacets.map((facet) => facet.label),
136
+ ),
137
+ "Unread and Category: Newsletter aren't filter conditions — add a sender or words to filter on",
138
+ );
139
+ });
126
140
  });
127
141
 
128
142
  describe("searchRuleAccountId — whose account the filter belongs to", () => {
@@ -29,9 +29,9 @@
29
29
  * that can't actually apply. `category:` resolves the same way against a fixed
30
30
  * vocabulary, so `category:nonsense` stays free text too.
31
31
  *
32
- * Token names are case-insensitive. A value containing whitespace is written in
33
- * double quotes — `in:"Sent Items"` and an unterminated quote runs to the end
34
- * of the input, so a value stays readable while it is still being typed.
32
+ * Token names are case-insensitive. Terms are cut from the query by
33
+ * `search-query-words.ts` in the kit, which the brief's chips also write and
34
+ * remove terms with, so a quoted value is one term to both.
35
35
  *
36
36
  * Pure functions only: no React, no fetch — the name index is injected, not
37
37
  * fetched here. `MailListHeader` renders the chips and the callers that issue
@@ -42,6 +42,23 @@
42
42
 
43
43
  import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
44
44
  import { MessageCategory } from "@remit/domain-enums";
45
+ import {
46
+ quoteSearchTokenValue,
47
+ type SearchQueryWord,
48
+ type SearchTermParts,
49
+ searchTokenTerm,
50
+ splitSearchTerm,
51
+ splitSearchWords,
52
+ } from "@remit/ui";
53
+
54
+ export {
55
+ quoteSearchTokenValue,
56
+ type SearchQueryWord,
57
+ type SearchTermParts,
58
+ searchTokenTerm,
59
+ splitSearchTerm,
60
+ splitSearchWords,
61
+ };
45
62
 
46
63
  export type SearchToken =
47
64
  | { type: "from"; raw: string; value: string }
@@ -186,89 +203,6 @@ export const searchTokenSpec = (name: string): SearchTokenSpec | undefined =>
186
203
 
187
204
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
188
205
 
189
- const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
190
-
191
- /**
192
- * A value as it is written in a query: quoted when it carries whitespace, bare
193
- * otherwise. The inverse of the unquoting the parser does, so a suggestion the
194
- * user picks parses back to the value it was built from.
195
- */
196
- export const quoteSearchTokenValue = (value: string): string =>
197
- needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
198
-
199
- /** `name:value`, quoted as needed — the text a query carries for one token. */
200
- export const searchTokenTerm = (name: string, value: string): string =>
201
- `${name}:${quoteSearchTokenValue(value)}`;
202
-
203
- const unquote = (value: string): string => {
204
- if (!value.startsWith('"')) return value;
205
- const inner = value.slice(1);
206
- return inner.endsWith('"') ? inner.slice(0, -1) : inner;
207
- };
208
-
209
- /** One whitespace-separated term of a query, with where it sits in the input. */
210
- export interface SearchQueryWord {
211
- /** The term exactly as typed, quotes included. */
212
- raw: string;
213
- /** Index of the term's first character in the query. */
214
- start: number;
215
- /** Index just past the term's last character. */
216
- end: number;
217
- }
218
-
219
- /**
220
- * Split a query into terms on whitespace, except inside double quotes, so
221
- * `in:"Sent Items"` is one term. An unterminated quote runs to the end of the
222
- * input — a half-typed value is still one term, not a broken one.
223
- */
224
- export function splitSearchWords(query: string): SearchQueryWord[] {
225
- const words: SearchQueryWord[] = [];
226
- let start = -1;
227
- let quoted = false;
228
- for (let i = 0; i < query.length; i++) {
229
- const char = query[i] as string;
230
- if (char === '"') {
231
- quoted = !quoted;
232
- if (start < 0) start = i;
233
- continue;
234
- }
235
- if (!quoted && /\s/.test(char)) {
236
- if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
237
- start = -1;
238
- continue;
239
- }
240
- if (start < 0) start = i;
241
- }
242
- if (start >= 0) {
243
- words.push({ raw: query.slice(start), start, end: query.length });
244
- }
245
- return words;
246
- }
247
-
248
- /** A term split at its first colon: the token name and the value as typed. */
249
- export interface SearchTermParts {
250
- name: string;
251
- /** The value with its quotes removed. */
252
- value: string;
253
- /** The value exactly as typed, quotes included. */
254
- rawValue: string;
255
- }
256
-
257
- /**
258
- * Split `name:value` at the first colon. A term with no colon, or one starting
259
- * with a colon, is not a token attempt and returns `undefined`.
260
- */
261
- export function splitSearchTerm(word: string): SearchTermParts | undefined {
262
- const colon = word.indexOf(":");
263
- if (colon <= 0) return undefined;
264
- const rawValue = word.slice(colon + 1);
265
- return {
266
- name: word.slice(0, colon).toLowerCase(),
267
- value: unquote(rawValue),
268
- rawValue,
269
- };
270
- }
271
-
272
206
  function parseDateToken(
273
207
  type: "before" | "after",
274
208
  raw: string,
@@ -1,10 +1,10 @@
1
1
  import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
2
2
 
3
3
  /**
4
- * `GET /addresses/search` is a prefix search over both the display-name
5
- * compound and the normalized email, so a query for one sender's address can
6
- * legitimately return several rows (`sup@x.com` also prefixes `support@x.com`,
7
- * and any display name starting with the same characters matches too).
4
+ * `GET /addresses/search` matches the query as a substring of a display name, a
5
+ * local part, a domain or a whole address, and answers in relevance order. A
6
+ * query for one sender's address therefore returns several rows: everyone
7
+ * sharing that domain matches it too.
8
8
  *
9
9
  * Asking for a single row and taking `items[0]` is therefore wrong twice over:
10
10
  * the row it returns may belong to a different sender, and the row we actually
@@ -22,10 +22,9 @@ export const senderAddressSearchQuery = (
22
22
  });
23
23
 
24
24
  /**
25
- * Select the address row for exactly this sender. A prefix match on another
26
- * sender is not this sender, so it resolves to `undefined` rather than the
27
- * wrong address — silently flagging the wrong sender is worse than not
28
- * resolving.
25
+ * Select the address row for exactly this sender. Another sender under the same
26
+ * domain is not this sender, so it resolves to `undefined` rather than the wrong
27
+ * address — silently flagging the wrong sender is worse than not resolving.
29
28
  */
30
29
  export const pickSenderAddress = (
31
30
  items: RemitImapAddressResponse[] | undefined,