@remit/web-client 0.0.249 → 0.0.251

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.249",
3
+ "version": "0.0.251",
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": {
@@ -2,10 +2,23 @@
2
2
  * DailyBrief — unified cross-account message digest.
3
3
  *
4
4
  * Renders one section per message category (Personal / Transactional /
5
- * Newsletter / Marketing / Social / Automated) from the GET /threads endpoint.
6
- * Starred mail is not a section — Flagged is a virtual mailbox in the nav. The
7
- * brief defaults to the cross-account aggregate, and `MailListHeader` provides
8
- * the title, unread count, and search.
5
+ * Newsletter / Marketing / Social / Automated / Unclassified) from the GET
6
+ * /threads endpoint. Starred mail is not a section — Flagged is a virtual
7
+ * mailbox in the nav. The brief defaults to the cross-account aggregate, and
8
+ * `MailListHeader` provides the title, unread count, and search.
9
+ *
10
+ * The brief paginates by section, not as a whole. Each section is its own
11
+ * category-scoped request for its newest rows plus one count of the whole
12
+ * category, so a header states how much mail that category holds rather than how
13
+ * many of a shared 50-row window fell into it, and a category whose mail is all
14
+ * older than that window still has a section with rows in it (#312). "Show all"
15
+ * hands the reader to the brief's own filtered list for that category; nothing
16
+ * here loads a mailbox.
17
+ *
18
+ * Sections are the unsearched brief only. A search is one request and one flat
19
+ * list, newest first across every category: sectioning the matches would order
20
+ * them by category first and recency second, putting last spring's newsletter
21
+ * above a mail that arrived this morning.
9
22
  *
10
23
  * The list's filter surface is the kit `BriefSections`: categories, attribute
11
24
  * chips and the account source group, in a panel the list header's caret opens
@@ -21,21 +34,26 @@
21
34
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
22
35
  * `SelectionTopBar` in place of the pane header. A shift-range spans the rendered rows in
23
36
  * document order, so it crosses category sections exactly as the eye reads
24
- * them and never picks up a row hidden behind "Show N more" or a collapsed
25
- * header. Folder-scoped verbs (Move, Apply label, Organize) resolve their
37
+ * them and never picks up a row behind a collapsed header or one the section's
38
+ * page did not reach. Folder-scoped verbs (Move, Apply label, Organize) resolve their
26
39
  * account and source folder from the selection — see
27
40
  * `resolveBriefSelectionScope`.
28
41
  *
29
- * Loading: skeleton rows on first paint, patch-in-place on refetch.
30
- * Error: per-section; the brief still renders other sections.
42
+ * Loading: skeleton rows on first paint, per-section from then on, and the rows
43
+ * already on screen stay put while the same predicate is re-fetched.
44
+ * Error: a section whose own request never got an answer says so in its own
45
+ * place and the rest of the brief stands. A 5xx is not that case — the API
46
+ * breaking escalates globally (`shouldEscalate`, #1059), and nothing here softens
47
+ * it.
31
48
  * Empty: "You're caught up", but only once the server confirms no sync is
32
49
  * running — while one is, the same empty list says it is still syncing.
33
50
  */
34
- import {
35
- mailboxOperationsListMailboxesOptions,
36
- unifiedThreadOperationsListAllThreadsOptions,
37
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
38
- import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
51
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
52
+ import type {
53
+ RemitImapAccountResponse,
54
+ RemitImapMessageCategory,
55
+ RemitImapThreadMessageResponse,
56
+ } from "@remit/api-http-client/types.gen.ts";
39
57
  import {
40
58
  type BriefCategoryFilter,
41
59
  BriefEmpty,
@@ -49,10 +67,12 @@ import {
49
67
  FilterPanelProvider,
50
68
  type FilterSheetProps,
51
69
  type FilterSheetSource,
70
+ isBriefCategory,
52
71
  KeyboardHintBar,
53
72
  matchesBriefFilters,
54
73
  partitionSpamResults,
55
74
  RefreshButton,
75
+ SECTION_ROW_CAP,
56
76
  type SearchResult,
57
77
  type SelectionRestriction,
58
78
  SelectionTopBar,
@@ -62,7 +82,7 @@ import {
62
82
  type ThreadSection,
63
83
  toggleBriefFilterInQuery,
64
84
  } from "@remit/ui";
65
- import { useQueries, useQuery } from "@tanstack/react-query";
85
+ import { useQueries } from "@tanstack/react-query";
66
86
  import { AlertCircle, RefreshCw } from "lucide-react";
67
87
  import {
68
88
  type ReactNode,
@@ -72,6 +92,7 @@ import {
72
92
  useState,
73
93
  } from "react";
74
94
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
95
+ import { useBriefSearchRows, useBriefSections } from "@/hooks/useBriefSections";
75
96
  import {
76
97
  isSyncingPhase,
77
98
  useInitialSyncProgress,
@@ -85,13 +106,15 @@ import { useSemanticSearch } from "@/hooks/useSemanticSearch";
85
106
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
86
107
  import { sortAccountsByCreatedAt } from "@/lib/account-order";
87
108
  import {
109
+ BRIEF_CATEGORIES,
110
+ type BriefCategoryResult,
111
+ briefSections,
112
+ briefSectionTotal,
88
113
  excludeMutedSenders,
89
- groupBriefSections,
90
- matchesBriefSearch,
91
114
  matchesSearchTokens,
92
- mergeSearchRows,
93
115
  toThreadRowData,
94
116
  } from "@/lib/brief";
117
+ import { briefCountsMatchRows, briefCriteria } from "@/lib/brief-criteria";
95
118
  import { isServerError } from "@/lib/error-classifier";
96
119
  import { junkDestination } from "@/lib/junk-destination";
97
120
  import type { ListHeaderChrome } from "@/lib/list-header-chrome";
@@ -102,6 +125,7 @@ import { showInlineSearchResults } from "@/lib/search-surface";
102
125
  import { parseSearchTokens } from "@/lib/search-tokens";
103
126
  import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
104
127
  import { spamOfferForResults } from "@/lib/spam-offer";
128
+ import { dedupeByThread } from "@/lib/starred-rows";
105
129
  import { wizardSelectionFrom } from "@/lib/wizard-selection";
106
130
  import {
107
131
  type OpenThreadTarget,
@@ -121,9 +145,17 @@ import {
121
145
  useThreadListSelection,
122
146
  } from "./ThreadListInteraction";
123
147
 
124
- /* Page size for the unscoped cross-folder search. One page is what the takeover
125
- and the "Top matches" list render; the server caps it at 500. */
126
- const UNSCOPED_SEARCH_PAGE_SIZE = 200;
148
+ /* Rows one category's own list renders once the brief is narrowed to it — the
149
+ "show all" destination, which is a page of that category rather than the whole
150
+ of it. */
151
+ const CATEGORY_PAGE_SIZE = 50;
152
+
153
+ /* One page of matches for a searched brief. The server orders the whole match
154
+ set; this is how much of the front of it the list renders. */
155
+ const SEARCH_PAGE_SIZE = 200;
156
+
157
+ /* The brief asks for no sections while it is answering a search. */
158
+ const NO_CATEGORIES: RemitImapMessageCategory[] = [];
127
159
 
128
160
  // ---------------------------------------------------------------------------
129
161
  // Skeleton
@@ -433,6 +465,15 @@ export function DailyBrief({
433
465
 
434
466
  const searching = searchInput.trim().length > 0;
435
467
 
468
+ // The committed query, split into the free text the request carries and the
469
+ // tokens. Free text is what turns the brief from seven category sections into
470
+ // one ordered list of matches, so it is read before anything downstream of it.
471
+ const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
472
+ searchQuery.trim().toLowerCase(),
473
+ tokenContext,
474
+ );
475
+ const underQuery = sq.length > 0;
476
+
436
477
  // Under a query the chips and the query are one state: a chip writes its term
437
478
  // into the query — `is:unread`, `has:attachment`, `category:newsletter` — and
438
479
  // a term typed by hand ticks its chip. What narrows the rows is then legible
@@ -449,6 +490,21 @@ export function DailyBrief({
449
490
  [searchInput, selectedCategory],
450
491
  );
451
492
 
493
+ // The same two, read off the committed query rather than the field. The chips
494
+ // above tick as the reader types, which is what the panel is for; the requests
495
+ // must not. Half-typed `is:unre` would otherwise fire a fresh burst of section
496
+ // requests on the way to `is:unread`, and the whole point of counting on a
497
+ // deliberate act is that nothing here rides a keystroke.
498
+ const requestFilters = useMemo(
499
+ () => briefChipFilters({ query: searchQuery, ownFilters: activeFilters }),
500
+ [searchQuery, activeFilters],
501
+ );
502
+ const requestCategory = useMemo(
503
+ () =>
504
+ briefChipCategory({ query: searchQuery, ownCategory: selectedCategory }),
505
+ [searchQuery, selectedCategory],
506
+ );
507
+
452
508
  const toggleChip = useCallback(
453
509
  (id: BriefFilterId) => {
454
510
  if (!searching || !briefFilterHasTerm(id)) {
@@ -472,6 +528,20 @@ export function DailyBrief({
472
528
  [searching, searchInput, onSearchChange],
473
529
  );
474
530
 
531
+ // The way to a section's whole category: the brief's own list, narrowed to
532
+ // that category, which is a request for it rather than more of a window
533
+ // already fetched. Offered only at the "all" scope — inside one category the
534
+ // list already is the destination.
535
+ const showAllSection = useMemo(
536
+ () =>
537
+ requestCategory === "all" && !underQuery
538
+ ? (sectionId: string) => {
539
+ if (isBriefCategory(sectionId)) selectChipCategory(sectionId);
540
+ }
541
+ : undefined,
542
+ [requestCategory, underQuery, selectChipCategory],
543
+ );
544
+
475
545
  const clearChips = useCallback(() => {
476
546
  setSelectedCategory("all");
477
547
  setSelectedAccountId("all");
@@ -479,17 +549,6 @@ export function DailyBrief({
479
549
  if (searching) onSearchChange(clearBriefFiltersInQuery(searchInput));
480
550
  }, [searching, searchInput, onSearchChange, clearFilters]);
481
551
 
482
- // --- Unified threads query ---
483
- const {
484
- data: threadsData,
485
- isLoading,
486
- isError,
487
- refetch,
488
- } = useQuery({
489
- ...unifiedThreadOperationsListAllThreadsOptions(),
490
- staleTime: 60_000,
491
- });
492
-
493
552
  // --- Per-account mailbox list for unread counts and error detection ---
494
553
  const mailboxQueries = useQueries({
495
554
  queries: nonMuted.map((account) => ({
@@ -534,77 +593,160 @@ export function DailyBrief({
534
593
  });
535
594
  }, [nonMuted, mailboxQueries]);
536
595
 
537
- const { freeText: sq, tokens: queryTokens } = parseSearchTokens(
538
- searchQuery.trim().toLowerCase(),
539
- tokenContext,
596
+ // --- The brief's rows ---
597
+ // The chips and the tokens as request parameters. The category is not among
598
+ // them: each section is its own category-scoped request, and the category
599
+ // chip decides which sections are on screen rather than narrowing a shared
600
+ // one.
601
+ const { criteria: chipCriteria, residual: residualTokens } = useMemo(
602
+ () => briefCriteria(requestCategory, requestFilters, queryTokens),
603
+ [requestCategory, requestFilters, queryTokens],
540
604
  );
541
-
542
- // --- Unscoped search query ---
543
- // The brief's list is the unified INBOX, so filtering it client-side can only
544
- // ever find inbox mail. The same endpoint in search mode (`query`) widens to
545
- // every non-muted folder of every account — Archive, Sent, Spam, custom
546
- // folders — and matches subject/From in the query. Its rows are merged with
547
- // the client-side pass below, which still contributes snippet matches the
548
- // server does not index.
549
- const { data: searchData, isFetching: searchFetching } = useQuery({
550
- ...unifiedThreadOperationsListAllThreadsOptions({
551
- query: { query: sq, limit: UNSCOPED_SEARCH_PAGE_SIZE },
605
+ const shownCategories = useMemo<RemitImapMessageCategory[]>(
606
+ () =>
607
+ requestCategory === "all" ? [...BRIEF_CATEGORIES] : [requestCategory],
608
+ [requestCategory],
609
+ );
610
+ // Under a query the category is a parameter again rather than a section
611
+ // scope: there is one request, and the chip narrows it.
612
+ const searchCriteria = useMemo(
613
+ () => ({
614
+ ...chipCriteria,
615
+ query: sq,
616
+ ...(requestCategory === "all" ? {} : { category: [requestCategory] }),
552
617
  }),
553
- enabled: sq.length > 0,
554
- staleTime: 30_000,
618
+ [chipCriteria, sq, requestCategory],
619
+ );
620
+ // The account pills and the tokens no parameter carries narrow the rows after
621
+ // they arrive, so while either is active the count is of a wider set than the
622
+ // list and the sections show no number at all.
623
+ const counted = briefCountsMatchRows({
624
+ residual: residualTokens,
625
+ attributes: requestFilters,
626
+ accountScoped: selectedAccountId !== "all",
555
627
  });
556
628
 
557
- // Convert API rows to ThreadRowData, narrowing only by the selected account
558
- // and the free-text search plus any filter tokens (`from:`, `has:attachment`,
559
- // `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
560
- // query. The category axis is applied further down, on the grouped sections,
561
- // so the list body can flatten them when narrowed to one category.
562
- const filteredRows = useMemo<ThreadRowData[]>(() => {
563
- const briefRows = excludeMutedSenders(threadsData?.items ?? []).map(
564
- toThreadRowData,
565
- );
566
- // No free text: the brief list as it comes, order untouched.
567
- const rows = sq
568
- ? mergeSearchRows(
569
- briefRows.filter((t) => matchesBriefSearch(t, sq)),
570
- excludeMutedSenders(searchData?.items ?? []).map(toThreadRowData),
571
- )
572
- : briefRows;
573
- return rows.filter(
574
- (t) =>
575
- (selectedAccountId === "all" || t.accountId === selectedAccountId) &&
576
- matchesSearchTokens(t, queryTokens),
577
- );
578
- }, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
579
-
580
- // The unscoped search reaches every folder, Spam included (see `filteredRows`
581
- // above), and the brief is the one global-scope view whose own rows now stand
582
- // in for the read-only results panel once the query commits. The panel held
583
- // Spam out and offered a way to it instead (`MailListHeader`'s `spamOffer`);
584
- // the brief's own body has to do the same, or a committed search surfaces
585
- // junk mail inline and drops the way back to it. A bare token query (e.g.
586
- // `is:unread`) never reaches the widened endpoint, so there is nothing to
587
- // hold out.
588
- const { bodyRows, briefSpamOffer } = useMemo(() => {
589
- if (!sq) return { bodyRows: filteredRows, briefSpamOffer: undefined };
629
+ const {
630
+ sections: sectionRows,
631
+ isLoading: sectionsLoading,
632
+ isError: sectionsError,
633
+ refetch: refetchSections,
634
+ } = useBriefSections({
635
+ categories: underQuery ? NO_CATEGORIES : shownCategories,
636
+ criteria: chipCriteria,
637
+ limit: requestCategory === "all" ? SECTION_ROW_CAP : CATEGORY_PAGE_SIZE,
638
+ counted,
639
+ });
640
+
641
+ // A search is answered by one request and rendered as one list. Splitting the
642
+ // matches into category sections orders them by category first and recency
643
+ // second, which puts a newsletter from last spring above a mail that arrived
644
+ // this morning — the reading the search is asked to give (#312).
645
+ const {
646
+ rows: searchRows,
647
+ isLoading: searchLoading,
648
+ isError: searchError,
649
+ refetch: refetchSearch,
650
+ } = useBriefSearchRows(searchCriteria, SEARCH_PAGE_SIZE, underQuery);
651
+
652
+ const isLoading = underQuery ? searchLoading : sectionsLoading;
653
+ const isError = underQuery ? searchError : sectionsError;
654
+ const refetch = useCallback(() => {
655
+ if (underQuery) {
656
+ refetchSearch();
657
+ return;
658
+ }
659
+ refetchSections();
660
+ }, [underQuery, refetchSearch, refetchSections]);
661
+
662
+ // What the request could not carry: muted senders, the account pill, and the
663
+ // residual tokens (`from:`, `subject:`, `before:`, `after:`, `in:`,
664
+ // `account:`) that `listAllThreads` has no parameter for. Everything else was
665
+ // answered over the whole scope by the request itself, and the order the rows
666
+ // arrived in is kept — narrowing never re-sorts.
667
+ const narrowRows = useCallback(
668
+ (rows: RemitImapThreadMessageResponse[]): ThreadRowData[] =>
669
+ // One row per conversation, because that is what the server counted:
670
+ // `count` is thread-distinct, and a list keying on messageId puts twelve
671
+ // rows under a header reading one. Collapsed before anything reads a
672
+ // length, so the rows and the number are the same unit.
673
+ dedupeByThread(excludeMutedSenders(rows))
674
+ .map(toThreadRowData)
675
+ .filter(
676
+ (t) =>
677
+ (selectedAccountId === "all" ||
678
+ t.accountId === selectedAccountId) &&
679
+ matchesSearchTokens(t, residualTokens),
680
+ ),
681
+ [selectedAccountId, residualTokens],
682
+ );
683
+
684
+ const briefRows = useMemo<BriefCategoryResult[]>(
685
+ () =>
686
+ sectionRows.map((section) => ({
687
+ category: section.category,
688
+ total: briefSectionTotal(section.total, section.rows),
689
+ atCap: section.atCap,
690
+ loading: section.loading,
691
+ failed: section.failed,
692
+ rows: narrowRows(section.rows),
693
+ })),
694
+ [sectionRows, narrowRows],
695
+ );
696
+
697
+ const matchRows = useMemo<ThreadRowData[]>(
698
+ () => narrowRows(searchRows),
699
+ [searchRows, narrowRows],
700
+ );
701
+
702
+ // Each section answers for itself, so a retry is that section's own request.
703
+ const retrySection = useCallback(
704
+ (sectionId: string) => {
705
+ sectionRows.find((section) => section.category === sectionId)?.retry();
706
+ },
707
+ [sectionRows],
708
+ );
709
+
710
+ const filteredRows = useMemo<ThreadRowData[]>(
711
+ () =>
712
+ underQuery ? matchRows : briefRows.flatMap((section) => section.rows),
713
+ [underQuery, matchRows, briefRows],
714
+ );
715
+
716
+ // A search reaches every folder, Spam included, and the brief is the one
717
+ // global-scope view whose own rows stand in for the read-only results panel
718
+ // once the query commits. The panel held Spam out and offered a way to it
719
+ // instead (`MailListHeader`'s `spamOffer`); the brief's own body has to do the
720
+ // same, or a committed search surfaces junk mail inline and drops the way back
721
+ // to it. A bare token query (e.g. `is:unread`) never reaches search mode, so
722
+ // there is nothing to hold out.
723
+ const { spamIds, briefSpamOffer } = useMemo(() => {
724
+ const none = { spamIds: undefined, briefSpamOffer: undefined };
725
+ if (!sq) return none;
590
726
  const asResults = filteredRows.map((row) =>
591
727
  rowToSearchResult(row, resultFolderIndex),
592
728
  );
593
729
  const { spam } = partitionSpamResults(asResults);
594
- if (spam.length === 0) {
595
- return { bodyRows: filteredRows, briefSpamOffer: undefined };
596
- }
597
- const spamIds = new Set(spam.map((result) => result.id));
730
+ if (spam.length === 0) return none;
598
731
  return {
599
- bodyRows: filteredRows.filter((row) => !spamIds.has(row.id)),
732
+ spamIds: new Set(spam.map((result) => result.id)),
600
733
  briefSpamOffer: spamOfferForResults(asResults),
601
734
  };
602
735
  }, [filteredRows, sq, resultFolderIndex]);
603
736
 
604
- const sections = useMemo<ThreadSection[]>(
605
- () => groupBriefSections(bodyRows),
606
- [bodyRows],
607
- );
737
+ const sections = useMemo<ThreadSection[]>(() => {
738
+ const keep = (rows: ThreadRowData[]): ThreadRowData[] =>
739
+ spamIds === undefined ? rows : rows.filter((row) => !spamIds.has(row.id));
740
+ // One unlabelled section under a query: the body renders it flat, so the
741
+ // matches stay in the single order the server put them in.
742
+ if (underQuery) return [{ id: "matches", threads: keep(matchRows) }];
743
+ return briefSections(
744
+ briefRows.map((section) => ({
745
+ ...section,
746
+ rows: keep(section.rows),
747
+ })),
748
+ );
749
+ }, [underQuery, matchRows, briefRows, spamIds]);
608
750
 
609
751
  // A committed query puts rows the widened cross-folder search found into the
610
752
  // body, and those messages are in folders the brief itself never loads. They
@@ -704,10 +846,9 @@ export function DailyBrief({
704
846
  // its thread via the raw threads.
705
847
  const { hits: semanticHits, isLoading: relatedLoading } = useSemanticSearch();
706
848
  const relatedResults = useMemo<SearchResult[]>(() => {
707
- const threadByMessageId = new Map<string, string>();
708
- for (const thread of threadsData?.items ?? []) {
709
- threadByMessageId.set(thread.messageId, thread.threadId);
710
- }
849
+ const threadByMessageId = new Map<string, string | undefined>(
850
+ filteredRows.map((row) => [row.id, row.threadId]),
851
+ );
711
852
  const literalThreadIds = searchResults
712
853
  .map((result) => threadByMessageId.get(result.id))
713
854
  .filter((id): id is string => id != null);
@@ -716,7 +857,7 @@ export function DailyBrief({
716
857
  literalThreadIds,
717
858
  resultFolderIndex,
718
859
  );
719
- }, [semanticHits, searchResults, threadsData, resultFolderIndex]);
860
+ }, [semanticHits, searchResults, filteredRows, resultFolderIndex]);
720
861
 
721
862
  const filterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
722
863
  const preset = briefFilterConfig(
@@ -823,6 +964,9 @@ export function DailyBrief({
823
964
  Row={MessageRow}
824
965
  selectedThreadId={selectedMessageId}
825
966
  onSelectThread={openRow}
967
+ onShowAllSection={showAllSection}
968
+ onRetrySection={retrySection}
969
+ flat={underQuery}
826
970
  onSelectBriefCategory={selectChipCategory}
827
971
  sources={accountSources}
828
972
  sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
@@ -884,15 +1028,15 @@ export function DailyBrief({
884
1028
  footer: isDesktop ? <KeyboardHintBar /> : undefined,
885
1029
  searchFilter: filterConfig,
886
1030
  searchResults,
887
- searchLoading: isLoading || searchFetching,
1031
+ searchLoading: isLoading,
888
1032
  relatedResults,
889
1033
  relatedLoading,
890
1034
  onSelectSearchResult: openResult,
891
- // The body already narrows to the committed query
892
- // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
893
- // above), so a committed search is a selectable list here exactly as
894
- // it is on the mailbox route (#212) — the two-engine panel stays for
895
- // the typing/uncommitted state only.
1035
+ // The body already narrows to the committed query (the server
1036
+ // `query` on every section request, plus `matchesSearchTokens` for
1037
+ // the residue), so a committed search is a selectable list here
1038
+ // exactly as it is on the mailbox route (#212) — the two-engine
1039
+ // panel stays for the typing/uncommitted state only.
896
1040
  searchResultsInBody: true,
897
1041
  refreshControl,
898
1042
  }}