@remit/web-client 0.0.107 → 0.0.109

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.107",
3
+ "version": "0.0.109",
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": {
@@ -1,6 +1,5 @@
1
1
  import { Avatar } from "@remit/ui";
2
- import { useNavigate } from "@tanstack/react-router";
3
- import { LogOut, Settings } from "lucide-react";
2
+ import { LogOut } from "lucide-react";
4
3
  import {
5
4
  DropdownMenu,
6
5
  DropdownMenuItem,
@@ -14,7 +13,6 @@ interface AccountMenuViewProps {
14
13
  }
15
14
 
16
15
  const AccountMenuView = ({ email, onSignOut }: AccountMenuViewProps) => {
17
- const navigate = useNavigate();
18
16
  const displayName = email ?? "Account";
19
17
 
20
18
  return (
@@ -36,11 +34,6 @@ const AccountMenuView = ({ email, onSignOut }: AccountMenuViewProps) => {
36
34
  <DropdownMenuSeparator />
37
35
  </>
38
36
  )}
39
- <DropdownMenuItem onClick={() => navigate({ to: "/settings/accounts" })}>
40
- <Settings className="size-4" />
41
- Settings
42
- </DropdownMenuItem>
43
- <DropdownMenuSeparator />
44
37
  <DropdownMenuItem onClick={onSignOut}>
45
38
  <LogOut className="size-4" />
46
39
  Sign out
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * MailTopBar — the app's one search surface and its global actions.
3
3
  *
4
- * Mounted by the `/mail` shell over the list, the reading pane and the
5
- * intelligence rail, starting on the list's left edge. It is the app's search,
6
- * not the list's: the list header drops its own field wherever this bar is
7
- * mounted, so exactly one search input exists on the page and the "/" shortcut
8
- * has one target.
4
+ * Mounted by the `/mail` shell across the top of the whole layout the nav
5
+ * column, the list, the reading pane and the intelligence rail. It is the app's
6
+ * search, not the list's: the list header drops its own field wherever this bar
7
+ * is mounted, so exactly one search input exists on the page and the "/"
8
+ * shortcut has one target.
9
9
  *
10
10
  * The actions here are the ones that belong to the app rather than to whatever
11
- * is currently listed or open — compose, bug report, account. Reply, delete,
12
- * move and the rest stay on the reading pane's own toolbar, under this bar.
11
+ * is currently listed or open — the nav toggle, compose, bug report, settings,
12
+ * account. Reply, delete, move and the rest stay on the reading pane's own
13
+ * toolbar, under this bar.
13
14
  *
14
15
  * The field carries one chip: the scope of the view the user navigated into
15
16
  * (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
@@ -19,8 +20,9 @@
19
20
  * sections instead, where the text is not repeated.
20
21
  */
21
22
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
22
- import { AppTopBar, Button, SearchBar } from "@remit/ui";
23
- import { SquarePen } from "lucide-react";
23
+ import { AppTopBar, Button, NavToggleButton, SearchBar } from "@remit/ui";
24
+ import { useNavigate } from "@tanstack/react-router";
25
+ import { Settings, SquarePen } from "lucide-react";
24
26
  import { AccountMenu } from "@/auth/AccountMenu";
25
27
  import { BugReportButton } from "@/components/ui/BugReportButton";
26
28
  import { useGlobalCompose } from "@/hooks/useComposeTarget";
@@ -42,6 +44,7 @@ interface MailTopBarProps {
42
44
  export function MailTopBar({ accounts }: MailTopBarProps) {
43
45
  const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
44
46
  useMailContext();
47
+ const navigate = useNavigate();
45
48
  const compose = useGlobalCompose(accounts);
46
49
  const { scope, clearScope } = useSearchScope(accounts);
47
50
  const chips =
@@ -55,6 +58,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
55
58
 
56
59
  return (
57
60
  <AppTopBar
61
+ leading={<NavToggleButton />}
58
62
  search={
59
63
  <SearchBar
60
64
  value={searchInput}
@@ -64,7 +68,6 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
64
68
  chips={chips}
65
69
  onRemoveChip={clearScope}
66
70
  placeholder={placeholder}
67
- size="lg"
68
71
  />
69
72
  }
70
73
  actions={
@@ -78,6 +81,14 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
78
81
  onClick={compose}
79
82
  />
80
83
  <BugReportButton />
84
+ <Button
85
+ variant="ghost"
86
+ size="sm"
87
+ icon={<Settings className="size-4" />}
88
+ title="Settings"
89
+ aria-label="Settings"
90
+ onClick={() => navigate({ to: "/settings/accounts" })}
91
+ />
81
92
  <AccountMenu />
82
93
  </>
83
94
  }
@@ -42,8 +42,10 @@ import {
42
42
  type FilterSheetProps,
43
43
  type FilterSheetSource,
44
44
  KeyboardHintBar,
45
+ partitionSpamResults,
45
46
  type SearchResult,
46
47
  SelectionTopBar,
48
+ SpamResultsOffer,
47
49
  type ThreadRowData,
48
50
  type ThreadSection,
49
51
  } from "@remit/ui";
@@ -81,6 +83,7 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
81
83
  import { useMailContext } from "@/lib/mail-context";
82
84
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
83
85
  import { parseSearchTokens } from "@/lib/search-tokens";
86
+ import { spamOfferForResults } from "@/lib/spam-offer";
84
87
  import {
85
88
  type SelectionWizardControl,
86
89
  useSelectionWizard,
@@ -463,6 +466,7 @@ export function DailyBrief({
463
466
  const tokenContext = useSearchTokenContext();
464
467
  const isDesktop = useIsDesktop();
465
468
  const wizard = useSelectionWizard();
469
+ const navigate = useNavigate();
466
470
 
467
471
  const nonMuted = useMemo(
468
472
  () => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
@@ -592,9 +596,33 @@ export function DailyBrief({
592
596
  );
593
597
  }, [threadsData, searchData, selectedAccountId, sq, queryTokens]);
594
598
 
599
+ // The unscoped search reaches every folder, Spam included (see `filteredRows`
600
+ // above), and the brief is the one global-scope view whose own rows now stand
601
+ // in for the read-only results panel once the query commits. The panel held
602
+ // Spam out and offered a way to it instead (`MailListHeader`'s `spamOffer`);
603
+ // the brief's own body has to do the same, or a committed search surfaces
604
+ // junk mail inline and drops the way back to it. A bare token query (e.g.
605
+ // `is:unread`) never reaches the widened endpoint, so there is nothing to
606
+ // hold out.
607
+ const { bodyRows, briefSpamOffer } = useMemo(() => {
608
+ if (!sq) return { bodyRows: filteredRows, briefSpamOffer: undefined };
609
+ const asResults = filteredRows.map((row) =>
610
+ rowToSearchResult(row, resultFolderIndex),
611
+ );
612
+ const { spam } = partitionSpamResults(asResults);
613
+ if (spam.length === 0) {
614
+ return { bodyRows: filteredRows, briefSpamOffer: undefined };
615
+ }
616
+ const spamIds = new Set(spam.map((result) => result.id));
617
+ return {
618
+ bodyRows: filteredRows.filter((row) => !spamIds.has(row.id)),
619
+ briefSpamOffer: spamOfferForResults(asResults),
620
+ };
621
+ }, [filteredRows, sq, resultFolderIndex]);
622
+
595
623
  const sections = useMemo<ThreadSection[]>(
596
- () => groupBriefSections(filteredRows),
597
- [filteredRows],
624
+ () => groupBriefSections(bodyRows),
625
+ [bodyRows],
598
626
  );
599
627
 
600
628
  const accountSources = useMemo<FilterSheetSource[]>(() => {
@@ -693,10 +721,14 @@ export function DailyBrief({
693
721
  ]);
694
722
 
695
723
  // The brief is genuinely empty (caught up) only when nothing is narrowing the
696
- // view: no account source and no search. When a source/search yields nothing,
697
- // the list says so instead, so the narrowing is still visible as the reason.
724
+ // view: no account source and no search — free text or token. When a
725
+ // source/search yields nothing, the list says so instead, so the narrowing
726
+ // is still visible as the reason.
698
727
  const caughtUp =
699
- sections.length === 0 && selectedAccountId === "all" && sq.length === 0;
728
+ sections.length === 0 &&
729
+ selectedAccountId === "all" &&
730
+ sq.length === 0 &&
731
+ queryTokens.length === 0;
700
732
 
701
733
  // "Caught up" is a claim about the user's mail, so it may only be made once
702
734
  // the server has confirmed no sync is running (#452). The config snapshot
@@ -752,12 +784,32 @@ export function DailyBrief({
752
784
  briefSkeleton
753
785
  )
754
786
  ) : (
755
- <BriefListBody
756
- sections={sections}
757
- briefCategory={selectedCategory}
758
- selectedThreadId={selectedMessageId}
759
- onSelectThread={onSelectMessage}
760
- />
787
+ <div className="flex h-full min-h-0 flex-col">
788
+ {briefSpamOffer && (
789
+ <SpamResultsOffer
790
+ count={briefSpamOffer.count}
791
+ onScopeToSpam={() =>
792
+ navigate({
793
+ to: "/mail/$mailboxId",
794
+ params: { mailboxId: briefSpamOffer.mailboxId },
795
+ search: {
796
+ q: searchQuery || undefined,
797
+ selectedMessageId: undefined,
798
+ selectedThreadId: undefined,
799
+ },
800
+ })
801
+ }
802
+ />
803
+ )}
804
+ <div className="min-h-0 flex-1">
805
+ <BriefListBody
806
+ sections={sections}
807
+ briefCategory={selectedCategory}
808
+ selectedThreadId={selectedMessageId}
809
+ onSelectThread={onSelectMessage}
810
+ />
811
+ </div>
812
+ </div>
761
813
  );
762
814
 
763
815
  // The cursor and selection wrap the whole pane, not just the list body: the
@@ -785,6 +837,12 @@ export function DailyBrief({
785
837
  relatedResults,
786
838
  relatedLoading,
787
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,
788
846
  }}
789
847
  rows={filteredRows}
790
848
  >
@@ -237,6 +237,11 @@ export function FlaggedList({
237
237
  searchResults={searchResults}
238
238
  searchLoading={isLoading}
239
239
  onSelectSearchResult={(result) => onSelectMessage?.(result.id)}
240
+ // A committed search renders in this view's own rows (`rows` already
241
+ // narrows to the query above), so the multi-select toolbar stays
242
+ // reachable exactly as it does on the mailbox route (#212). The
243
+ // two-engine panel stays for the typing/uncommitted state only.
244
+ searchResultsInBody
240
245
  >
241
246
  <ThreadListInteraction
242
247
  selectedMessageId={selectedMessageId}
@@ -41,6 +41,7 @@
41
41
  import {
42
42
  Button,
43
43
  type FilterSheetProps,
44
+ FilterToggle,
44
45
  isConvertible,
45
46
  MakeFilterAction,
46
47
  MobileSearchView,
@@ -129,13 +130,13 @@ export interface MailListHeaderProps {
129
130
  relatedResultsLabel?: string;
130
131
  /**
131
132
  * The body renders the committed search itself as a selectable list — the
132
- * mailbox route's `MessageList` filters to the search results and hosts the
133
- * multi-select toolbar and the "Select all N matching" escalation (#212).
134
- * When set, a committed query keeps the body on tablet/desktop instead of
135
- * swapping in the read-only two-engine `SearchResults` panel; the panel still
136
- * shows while the query is being typed (uncommitted). Views whose body is not
137
- * a search-result list (the brief, flagged, global search) leave this unset
138
- * and keep the panel for every query.
133
+ * mailbox route's `MessageList` and the brief and Starred's own rows filter
134
+ * to the search and host the multi-select toolbar and (on the mailbox route)
135
+ * the "Select all N matching" escalation (#212). When set, a committed query
136
+ * keeps the body on tablet/desktop instead of swapping in the read-only
137
+ * two-engine `SearchResults` panel; the panel still shows while the query is
138
+ * being typed (uncommitted). A view whose body is not a search-result list
139
+ * leaves this unset and keeps the panel for every query.
139
140
  */
140
141
  searchResultsInBody?: boolean;
141
142
  }
@@ -336,12 +337,12 @@ export function MailListHeader({
336
337
  // It belongs to the search, not to any one way of showing it. The affordance
337
338
  // therefore sits in the pane, above whichever body is up — the read-only
338
339
  // results panel, or a list whose own rows narrow to the committed query — and
339
- // is mounted on the same condition as the query itself. Rendering it inside the
340
- // results panel made it a mailbox's flash: the panel shows while the query is
341
- // being typed and hands back to `MessageList` the moment the query commits to
342
- // the URL, taking the affordance down with it a few hundred milliseconds after
343
- // it appeared, and leaving the brief (which keeps the panel for any query) as
344
- // the only place it survived.
340
+ // is mounted on the same condition as the query itself. Rendering it inside
341
+ // the results panel made it a mailbox's flash: the panel shows while the query
342
+ // is being typed and hands back to `MessageList` the moment the query commits
343
+ // to the URL, taking the affordance down with it a few hundred milliseconds
344
+ // after it appeared. Mounting it here instead keeps it up through that
345
+ // hand-back on every view.
345
346
  const searchHadSemanticReach = related.length > 0;
346
347
  const conversion = useMemo(
347
348
  () => convertSearchToRule(parsed, { searchHadSemanticReach }),
@@ -372,10 +373,11 @@ export function MailListHeader({
372
373
  // Tablet + desktop keep the inline toolbar search; while a query is being
373
374
  // typed the list-pane body swaps to the same sectioned results the phone
374
375
  // takeover shows, under the same FilterSheet. A view whose own body renders
375
- // the committed search as a selectable list (`searchResultsInBody`, the
376
- // mailbox route) keeps the panel only until the query commits to the URL,
377
- // then hands back to its `MessageList` so the multi-select toolbar and the
378
- // escalation are reachable (#212). Clearing the query restores the normal list.
376
+ // the committed search as a selectable list (`searchResultsInBody` the
377
+ // mailbox route, the brief, Starred) keeps the panel only until the query
378
+ // commits to the URL, then hands back to its own rows so multi-select (and,
379
+ // on the mailbox route, the "Select all N matching" escalation) is reachable
380
+ // (#212). Clearing the query restores the normal list.
379
381
  const showInlineResults = showInlineSearchResults({
380
382
  tier,
381
383
  hasLiveInput: hasQuery,
@@ -434,9 +436,12 @@ export function MailListHeader({
434
436
  />
435
437
  ),
436
438
  titleMeta: (
437
- <span className="shrink-0 text-2xs text-fg-subtle">
438
- {unreadCount.toLocaleString()} unread
439
- </span>
439
+ <>
440
+ <span className="shrink-0 text-2xs text-fg-subtle">
441
+ {unreadCount.toLocaleString()} unread
442
+ </span>
443
+ {!hasQuery && <FilterToggle />}
444
+ </>
440
445
  ),
441
446
  searchSlot: ownsSearch && !searchExpanded && (
442
447
  <Button
@@ -487,6 +492,7 @@ export function MailListHeader({
487
492
  chromeResults,
488
493
  makeFilterAction,
489
494
  searchConversion,
495
+ hasQuery,
490
496
  ],
491
497
  );
492
498
 
@@ -18,12 +18,13 @@
18
18
  * surface and the section headers flatten correctly when filtered).
19
19
  */
20
20
  import {
21
+ FilterPanelProvider,
21
22
  type FilterPreset,
22
23
  FilterSheet,
23
24
  type FilterSheetProps,
24
25
  type SearchResult,
25
26
  } from "@remit/ui";
26
- import { type ReactNode, useState } from "react";
27
+ import type { ReactNode } from "react";
27
28
  import { useMailContext } from "@/lib/mail-context";
28
29
  import { MailListHeader } from "./MailListHeader";
29
30
 
@@ -54,7 +55,8 @@ interface MailViewChromeProps {
54
55
  /**
55
56
  * The body renders the committed search as a selectable list itself; see
56
57
  * `MailListHeader`. The mailbox route sets this so a committed search yields
57
- * its multi-select `MessageList` (#212); flagged leaves it unset.
58
+ * its multi-select `MessageList` (#212); Starred sets it for the same reason,
59
+ * over its own already query-narrowed rows.
58
60
  */
59
61
  searchResultsInBody?: boolean;
60
62
  }
@@ -80,7 +82,6 @@ export function MailViewChrome({
80
82
  relatedResultsLabel,
81
83
  searchResultsInBody,
82
84
  }: MailViewChromeProps) {
83
- const [expanded, setExpanded] = useState(false);
84
85
  // A query owns the pane: the filter chrome and the search's own affordance
85
86
  // narrow the same list from the same place, so the filter sheet stands down
86
87
  // for as long as something is being searched. Its state survives — clearing
@@ -94,8 +95,6 @@ export function MailViewChrome({
94
95
  sources: preset.sources,
95
96
  selectedCategory,
96
97
  activeFilters,
97
- expanded,
98
- onExpandedChange: setExpanded,
99
98
  onSelectCategory,
100
99
  onSelectSource,
101
100
  onToggleFilter,
@@ -103,26 +102,28 @@ export function MailViewChrome({
103
102
  };
104
103
 
105
104
  return (
106
- <MailListHeader
107
- title={title}
108
- unreadCount={unreadCount}
109
- footer={footer}
110
- searchFilter={filterConfig}
111
- searchResults={searchResults}
112
- searchLoading={searchLoading}
113
- relatedResults={relatedResults}
114
- relatedLoading={relatedLoading}
115
- onSelectSearchResult={onSelectSearchResult}
116
- searchResultsLabel={searchResultsLabel}
117
- relatedResultsLabel={relatedResultsLabel}
118
- searchResultsInBody={searchResultsInBody}
119
- >
120
- {/* One shell either way: the children's parent must not change when a
121
- query starts, or everything under it — including the header's own
122
- search fieldremounts mid-keystroke. */}
123
- <FilterSheet {...filterConfig} hideChrome={searching}>
124
- {children}
125
- </FilterSheet>
126
- </MailListHeader>
105
+ <FilterPanelProvider>
106
+ <MailListHeader
107
+ title={title}
108
+ unreadCount={unreadCount}
109
+ footer={footer}
110
+ searchFilter={filterConfig}
111
+ searchResults={searchResults}
112
+ searchLoading={searchLoading}
113
+ relatedResults={relatedResults}
114
+ relatedLoading={relatedLoading}
115
+ onSelectSearchResult={onSelectSearchResult}
116
+ searchResultsLabel={searchResultsLabel}
117
+ relatedResultsLabel={relatedResultsLabel}
118
+ searchResultsInBody={searchResultsInBody}
119
+ >
120
+ {/* One shell either way: the children's parent must not change when a
121
+ query starts, or everything under it including the header's own
122
+ search field — remounts mid-keystroke. */}
123
+ <FilterSheet {...filterConfig} hideChrome={searching}>
124
+ {children}
125
+ </FilterSheet>
126
+ </MailListHeader>
127
+ </FilterPanelProvider>
127
128
  );
128
129
  }
@@ -13,12 +13,12 @@ import type { LayoutTier } from "@/hooks/useLayoutTier";
13
13
  * Phone never swaps: it shows the body and keeps the panel in its full-screen
14
14
  * takeover instead, so this only decides tablet/desktop.
15
15
  *
16
- * A view whose body renders committed results itself (`bodyRendersCommittedResults`,
17
- * the mailbox route) keeps the panel only while the query is still being typed —
18
- * uncommitted, not yet mirrored to the URL — and hands back to the body the moment
19
- * the query commits, so the committed search is a selectable list. Every other view
20
- * (the brief, flagged, global search) leaves the flag off and keeps the panel for
21
- * any query, committed or not.
16
+ * A view whose body renders committed results itself (`bodyRendersCommittedResults`
17
+ * the mailbox route, the brief, Starred) keeps the panel only while the query is
18
+ * still being typed — uncommitted, not yet mirrored to the URL — and hands back to
19
+ * the body the moment the query commits, so the committed search is a selectable
20
+ * list. A view that leaves the flag off keeps the panel for any query, committed
21
+ * or not.
22
22
  */
23
23
  export const showInlineSearchResults = (input: {
24
24
  tier: LayoutTier;