@remit/web-client 0.0.109 → 0.0.110

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.109",
3
+ "version": "0.0.110",
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": {
@@ -7,13 +7,13 @@
7
7
  * brief defaults to the cross-account aggregate, and `MailListHeader` provides
8
8
  * the title, unread count, and search.
9
9
  *
10
- * The list's filter control (categories + attribute chips + the account source
11
- * group) is hidden the brief is being tried without it. The kit
12
- * `BriefSections` renders that control above the list body, so the body is
13
- * rendered here by `BriefListBody` instead; every input the control consumed is
14
- * still computed below, so bringing it back is swapping `BriefListBody` for
15
- * `<BriefSections />`. Account switching also lives in the nav sidebar, and
16
- * the phone search takeover keeps its own copy of the filter sheet.
10
+ * The list's filter surface is the kit `BriefSections`: categories, attribute
11
+ * chips and the account source group, in a panel the list header's caret opens
12
+ * over the rows. `FilterPanelProvider` shares that panel's open state between
13
+ * the caret and the sheet, the same shape `MailViewChrome` gives the mailbox and
14
+ * Starred views. The category and the chips narrow the grouped sections
15
+ * themselves, and the phone search takeover reads the same selection, so a
16
+ * filter set on one surface holds on the other.
17
17
  *
18
18
  * Multi-select is the mailbox list's, not a copy of it: the same
19
19
  * `ThreadListInteraction` cursor and `useSelection` state, and the same
@@ -37,11 +37,14 @@ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.
37
37
  import {
38
38
  type BriefCategoryFilter,
39
39
  BriefEmpty,
40
- BriefSection,
40
+ type BriefFilterId,
41
+ BriefSections,
41
42
  briefFilterConfig,
43
+ FilterPanelProvider,
42
44
  type FilterSheetProps,
43
45
  type FilterSheetSource,
44
46
  KeyboardHintBar,
47
+ matchesBriefFilters,
45
48
  partitionSpamResults,
46
49
  type SearchResult,
47
50
  SelectionTopBar,
@@ -102,21 +105,10 @@ import {
102
105
  useThreadListSelection,
103
106
  } from "./ThreadListInteraction";
104
107
 
105
- /* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
106
- ids) so the phone search takeover narrows results the same way the list does. */
107
108
  /* Page size for the unscoped cross-folder search. One page is what the takeover
108
109
  and the "Top matches" list render; the server caps it at 500. */
109
110
  const UNSCOPED_SEARCH_PAGE_SIZE = 200;
110
111
 
111
- const BRIEF_SEARCH_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
112
- unread: (t) => !t.isRead,
113
- attachment: (t) => t.hasAttachment === true,
114
- contacts: (t) => t.trust === "vip" || t.trust === "wellknown",
115
- today: (t) =>
116
- t.sentDate != null &&
117
- new Date(t.sentDate).toDateString() === new Date().toDateString(),
118
- };
119
-
120
112
  // ---------------------------------------------------------------------------
121
113
  // Skeleton
122
114
  // ---------------------------------------------------------------------------
@@ -169,73 +161,6 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
169
161
  );
170
162
  };
171
163
 
172
- // ---------------------------------------------------------------------------
173
- // List body (the kit `BriefSections` body without its filter control)
174
- // ---------------------------------------------------------------------------
175
-
176
- interface BriefListBodyProps {
177
- sections: ThreadSection[];
178
- /** Category scope. "all" keeps the per-category sections; anything else flattens. */
179
- briefCategory: BriefCategoryFilter;
180
- selectedThreadId?: string;
181
- onSelectThread?: (id: string) => void;
182
- }
183
-
184
- /**
185
- * The brief's list body: one capped section per category at the "all" scope, a
186
- * headerless flat list once narrowed to a single category (the section headers
187
- * are redundant there).
188
- *
189
- * This is the kit `BriefSections` body minus the filter control it renders above
190
- * it. The category scope still arrives from the brief — the phone search
191
- * takeover's filter sheet sets it — so the flatten path stays reachable; the
192
- * attribute chips are part of the hidden control and narrow nothing here.
193
- */
194
- function BriefListBody({
195
- sections,
196
- briefCategory,
197
- selectedThreadId,
198
- onSelectThread,
199
- }: BriefListBodyProps) {
200
- const showSections = briefCategory === "all";
201
- const flatRows = sections
202
- .flatMap((section) => section.threads)
203
- .filter((thread) => thread.category === briefCategory);
204
- const empty = showSections ? sections.length === 0 : flatRows.length === 0;
205
-
206
- return (
207
- <div className="h-full overflow-y-auto">
208
- {showSections ? (
209
- sections.map((section) => (
210
- <BriefSection
211
- key={section.id}
212
- section={section}
213
- Row={MessageRow}
214
- selectedThreadId={selectedThreadId}
215
- onSelectThread={onSelectThread}
216
- />
217
- ))
218
- ) : (
219
- <div className="divide-y divide-line">
220
- {flatRows.map((thread) => (
221
- <MessageRow
222
- key={thread.id}
223
- thread={thread}
224
- active={thread.id === selectedThreadId}
225
- onClick={() => onSelectThread?.(thread.id)}
226
- />
227
- ))}
228
- </div>
229
- )}
230
- {empty && (
231
- <div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
232
- No threads match these filters.
233
- </div>
234
- )}
235
- </div>
236
- );
237
- }
238
-
239
164
  // ---------------------------------------------------------------------------
240
165
  // Selection surface (the mailbox list's, mounted on the brief)
241
166
  // ---------------------------------------------------------------------------
@@ -462,7 +387,7 @@ export function DailyBrief({
462
387
  onTriageContextChange,
463
388
  onDeleteMessages,
464
389
  }: DailyBriefProps) {
465
- const { searchQuery, resultFolderIndex } = useMailContext();
390
+ const { searchQuery, searchInput, resultFolderIndex } = useMailContext();
466
391
  const tokenContext = useSearchTokenContext();
467
392
  const isDesktop = useIsDesktop();
468
393
  const wizard = useSelectionWizard();
@@ -474,29 +399,37 @@ export function DailyBrief({
474
399
  );
475
400
 
476
401
  // "all" = the cross-account aggregate (the brief's default), and "all"
477
- // categories = the full set of sections. Both stay at their default while the
478
- // list's filter control is hidden; the phone search takeover still drives them
479
- // (the category also drives the flatten-when-filtered path), and account
480
- // switching lives in the nav sidebar.
402
+ // categories = the full set of sections. Account switching also lives in the
403
+ // nav sidebar; the category also drives the flatten-when-filtered path.
481
404
  const [selectedAccountId, setSelectedAccountId] = useState("all");
482
405
  const [selectedCategory, setSelectedCategory] =
483
406
  useState<BriefCategoryFilter>("all");
484
407
 
485
- // Attribute chips for the phone search takeover a separate surface from the
486
- // list, so it carries its own additive set (category + account are shared
487
- // above) and keeps its filter sheet while the list's control is hidden.
488
- const [searchAttributes, setSearchAttributes] = useState<ReadonlySet<string>>(
489
- new Set(),
490
- );
491
- const [searchExpanded, setSearchExpanded] = useState(false);
492
- const toggleSearchAttribute = useCallback((id: string) => {
493
- setSearchAttributes((prev) => {
408
+ // Held here rather than inside the list's filter sheet: the phone search
409
+ // takeover narrows the same rows by the same chips, and the body is unmounted
410
+ // and remounted around a query being typed, which would take a set living
411
+ // below with it.
412
+ const [activeFilters, setActiveFilters] = useState<
413
+ ReadonlySet<BriefFilterId>
414
+ >(new Set());
415
+ const [filterExpanded, setFilterExpanded] = useState(false);
416
+ const toggleFilter = useCallback((id: BriefFilterId) => {
417
+ setActiveFilters((prev) => {
494
418
  const next = new Set(prev);
495
419
  if (next.has(id)) next.delete(id);
496
420
  else next.add(id);
497
421
  return next;
498
422
  });
499
423
  }, []);
424
+ const clearFilters = useCallback(() => setActiveFilters(new Set()), []);
425
+
426
+ // A query owns the pane: the filter panel and the search's own affordance
427
+ // narrow the same list from the same place, so the panel stands down for as
428
+ // long as something is being searched. Its state survives, so clearing the
429
+ // query brings it back with the same category and chips. The header caret is
430
+ // gone under a query, and a panel left open with nothing to collapse it is
431
+ // what makes this load-bearing rather than tidy.
432
+ const searching = searchInput.trim().length > 0;
500
433
 
501
434
  // --- Unified threads query ---
502
435
  const {
@@ -649,19 +582,18 @@ export function DailyBrief({
649
582
  );
650
583
 
651
584
  // The phone search takeover renders the account/free-text-narrowed rows,
652
- // further narrowed by the shared category and the takeover's attribute chips.
653
- const searchResults = useMemo<SearchResult[]>(() => {
654
- const predicates = Array.from(searchAttributes)
655
- .map((id) => BRIEF_SEARCH_PREDICATES[id])
656
- .filter((p): p is (t: ThreadRowData) => boolean => p != null);
657
- return filteredRows
658
- .filter(
659
- (t) =>
660
- (selectedCategory === "all" || t.category === selectedCategory) &&
661
- predicates.every((p) => p(t)),
662
- )
663
- .map((row) => rowToSearchResult(row, resultFolderIndex));
664
- }, [filteredRows, selectedCategory, searchAttributes, resultFolderIndex]);
585
+ // further narrowed by the same category and attribute chips the list applies.
586
+ const searchResults = useMemo<SearchResult[]>(
587
+ () =>
588
+ filteredRows
589
+ .filter(
590
+ (t) =>
591
+ (selectedCategory === "all" || t.category === selectedCategory) &&
592
+ matchesBriefFilters(t, activeFilters),
593
+ )
594
+ .map((row) => rowToSearchResult(row, resultFolderIndex)),
595
+ [filteredRows, selectedCategory, activeFilters, resultFolderIndex],
596
+ );
665
597
 
666
598
  // "Related" (semantic) spans every account here — the brief is the
667
599
  // cross-account view, so no mailbox scope. Dedupe against the literal "Top
@@ -683,7 +615,7 @@ export function DailyBrief({
683
615
  );
684
616
  }, [semanticHits, searchResults, threadsData, resultFolderIndex]);
685
617
 
686
- const searchFilterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
618
+ const filterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
687
619
  const preset = briefFilterConfig(
688
620
  accountSources.map((s) => ({
689
621
  id: s.id,
@@ -698,26 +630,27 @@ export function DailyBrief({
698
630
  sources: preset.sources,
699
631
  sourcesNote: mutedCount > 0 ? `+${mutedCount} muted` : undefined,
700
632
  selectedCategory,
701
- activeFilters: searchAttributes,
702
- expanded: searchExpanded,
703
- onExpandedChange: setSearchExpanded,
633
+ activeFilters,
634
+ expanded: filterExpanded,
635
+ onExpandedChange: setFilterExpanded,
704
636
  onSelectCategory: (id: string) =>
705
637
  setSelectedCategory(id as BriefCategoryFilter),
706
638
  onSelectSource: setSelectedAccountId,
707
- onToggleFilter: toggleSearchAttribute,
639
+ onToggleFilter: (id: string) => toggleFilter(id as BriefFilterId),
708
640
  onClear: () => {
709
641
  setSelectedCategory("all");
710
642
  setSelectedAccountId("all");
711
- setSearchAttributes(new Set());
643
+ clearFilters();
712
644
  },
713
645
  };
714
646
  }, [
715
647
  accountSources,
716
648
  mutedCount,
717
649
  selectedCategory,
718
- searchAttributes,
719
- searchExpanded,
720
- toggleSearchAttribute,
650
+ activeFilters,
651
+ filterExpanded,
652
+ toggleFilter,
653
+ clearFilters,
721
654
  ]);
722
655
 
723
656
  // The brief is genuinely empty (caught up) only when nothing is narrowing the
@@ -756,34 +689,12 @@ export function DailyBrief({
756
689
  </div>
757
690
  );
758
691
 
759
- const stateBody = isLoading ? (
760
- briefSkeleton
761
- ) : isError ? (
762
- <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
763
- <AlertCircle className="size-8 text-danger" />
764
- <p>Couldn't load your messages</p>
765
- <button
766
- type="button"
767
- onClick={() => refetch()}
768
- className="flex items-center gap-1 text-accent underline text-xs"
769
- >
770
- <RefreshCw className="size-3.5" />
771
- Try again
772
- </button>
773
- </div>
774
- ) : caughtUp ? (
775
- syncProgress.resolved ? (
776
- <BriefEmpty
777
- sync={
778
- syncProgress.syncing
779
- ? { synced: syncProgress.synced, total: syncProgress.total }
780
- : undefined
781
- }
782
- />
783
- ) : (
784
- briefSkeleton
785
- )
786
- ) : (
692
+ // The filter sheet lives in the list body, so it is on screen only when the
693
+ // rows are. The caret reads the same answer and stands down everywhere else,
694
+ // rather than opening nothing over a skeleton or an empty state.
695
+ const showsRows = !isLoading && !isError && !caughtUp;
696
+
697
+ const stateBody = showsRows ? (
787
698
  <div className="flex h-full min-h-0 flex-col">
788
699
  {briefSpamOffer && (
789
700
  <SpamResultsOffer
@@ -802,61 +713,99 @@ export function DailyBrief({
802
713
  />
803
714
  )}
804
715
  <div className="min-h-0 flex-1">
805
- <BriefListBody
716
+ <BriefSections
806
717
  sections={sections}
807
718
  briefCategory={selectedCategory}
719
+ Row={MessageRow}
808
720
  selectedThreadId={selectedMessageId}
809
721
  onSelectThread={onSelectMessage}
722
+ onSelectBriefCategory={setSelectedCategory}
723
+ sources={accountSources}
724
+ sourcesNote={mutedCount > 0 ? `+${mutedCount} muted` : undefined}
725
+ onSelectSource={setSelectedAccountId}
726
+ activeFilters={activeFilters}
727
+ onToggleFilter={toggleFilter}
728
+ onClearFilters={clearFilters}
729
+ hideChrome={searching}
810
730
  />
811
731
  </div>
812
732
  </div>
733
+ ) : isLoading ? (
734
+ briefSkeleton
735
+ ) : isError ? (
736
+ <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
737
+ <AlertCircle className="size-8 text-danger" />
738
+ <p>Couldn't load your messages</p>
739
+ <button
740
+ type="button"
741
+ onClick={() => refetch()}
742
+ className="flex items-center gap-1 text-accent underline text-xs"
743
+ >
744
+ <RefreshCw className="size-3.5" />
745
+ Try again
746
+ </button>
747
+ </div>
748
+ ) : syncProgress.resolved ? (
749
+ <BriefEmpty
750
+ sync={
751
+ syncProgress.syncing
752
+ ? { synced: syncProgress.synced, total: syncProgress.total }
753
+ : undefined
754
+ }
755
+ />
756
+ ) : (
757
+ briefSkeleton
813
758
  );
814
759
 
815
760
  // The cursor and selection wrap the whole pane, not just the list body: the
816
761
  // selection toolbar takes the header's place while rows are selected, so it
817
- // has to sit inside the same provider the rows do.
762
+ // has to sit inside the same provider the rows do. The filter panel spans the
763
+ // same pane for the same reason — the caret is in the header, the panel is
764
+ // above the rows.
818
765
  return (
819
- <ThreadListInteraction
820
- selectedMessageId={selectedMessageId}
821
- onOpen={(id, options) => onSelectMessage?.(id, options)}
822
- onDeleteMessages={onDeleteMessages}
823
- onSelectionVerb={wizard.start}
824
- wizardOpen={wizard.isOpen}
825
- commandsRef={commandsRef}
826
- onTriageContextChange={onTriageContextChange}
827
- >
828
- <BriefSelectionChrome
829
- wizard={wizard}
830
- header={{
831
- title: "Daily brief",
832
- unreadCount: totalUnseen,
833
- footer: isDesktop ? <KeyboardHintBar /> : undefined,
834
- searchFilter: searchFilterConfig,
835
- searchResults,
836
- searchLoading: isLoading || searchFetching,
837
- relatedResults,
838
- relatedLoading,
839
- onSelectSearchResult,
840
- // The body already narrows to the committed query
841
- // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
842
- // above), so a committed search is a selectable list here exactly as
843
- // it is on the mailbox route (#212) — the two-engine panel stays for
844
- // the typing/uncommitted state only.
845
- searchResultsInBody: true,
846
- }}
847
- rows={filteredRows}
766
+ <FilterPanelProvider hasSheet={showsRows && !searching}>
767
+ <ThreadListInteraction
768
+ selectedMessageId={selectedMessageId}
769
+ onOpen={(id, options) => onSelectMessage?.(id, options)}
770
+ onDeleteMessages={onDeleteMessages}
771
+ onSelectionVerb={wizard.start}
772
+ wizardOpen={wizard.isOpen}
773
+ commandsRef={commandsRef}
774
+ onTriageContextChange={onTriageContextChange}
848
775
  >
849
- <div className="flex h-full flex-col">
850
- {failedAccounts.map((account) => (
851
- <ErrorBanner
852
- key={account.accountId}
853
- accountEmail={account.email}
854
- accountId={account.accountId}
855
- />
856
- ))}
857
- <div className="min-h-0 flex-1">{stateBody}</div>
858
- </div>
859
- </BriefSelectionChrome>
860
- </ThreadListInteraction>
776
+ <BriefSelectionChrome
777
+ wizard={wizard}
778
+ header={{
779
+ title: "Daily brief",
780
+ unreadCount: totalUnseen,
781
+ footer: isDesktop ? <KeyboardHintBar /> : undefined,
782
+ searchFilter: filterConfig,
783
+ searchResults,
784
+ searchLoading: isLoading || searchFetching,
785
+ relatedResults,
786
+ relatedLoading,
787
+ onSelectSearchResult,
788
+ // The body already narrows to the committed query
789
+ // (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
790
+ // above), so a committed search is a selectable list here exactly as
791
+ // it is on the mailbox route (#212) — the two-engine panel stays for
792
+ // the typing/uncommitted state only.
793
+ searchResultsInBody: true,
794
+ }}
795
+ rows={filteredRows}
796
+ >
797
+ <div className="flex h-full flex-col">
798
+ {failedAccounts.map((account) => (
799
+ <ErrorBanner
800
+ key={account.accountId}
801
+ accountEmail={account.email}
802
+ accountId={account.accountId}
803
+ />
804
+ ))}
805
+ <div className="min-h-0 flex-1">{stateBody}</div>
806
+ </div>
807
+ </BriefSelectionChrome>
808
+ </ThreadListInteraction>
809
+ </FilterPanelProvider>
861
810
  );
862
811
  }
@@ -13,9 +13,9 @@
13
13
  * time. The selection state is held here and survives, so clearing the query
14
14
  * restores the sheet exactly as it was.
15
15
  *
16
- * The daily brief no longer uses this: it composes `MailListHeader` with the kit
17
- * `BriefSections`, which owns its own filter row (so there is exactly one filter
18
- * surface and the section headers flatten correctly when filtered).
16
+ * The daily brief composes the same three parts itself provider, header,
17
+ * sheet around the kit `BriefSections`, whose sheet narrows the grouped
18
+ * sections and flattens them when scoped to one category.
19
19
  */
20
20
  import {
21
21
  FilterPanelProvider,