@remit/web-client 0.0.107 → 0.0.108

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.108",
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": {
@@ -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}
@@ -129,13 +129,13 @@ export interface MailListHeaderProps {
129
129
  relatedResultsLabel?: string;
130
130
  /**
131
131
  * 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.
132
+ * mailbox route's `MessageList` and the brief and Starred's own rows filter
133
+ * to the search and host the multi-select toolbar and (on the mailbox route)
134
+ * the "Select all N matching" escalation (#212). When set, a committed query
135
+ * keeps the body on tablet/desktop instead of swapping in the read-only
136
+ * two-engine `SearchResults` panel; the panel still shows while the query is
137
+ * being typed (uncommitted). A view whose body is not a search-result list
138
+ * leaves this unset and keeps the panel for every query.
139
139
  */
140
140
  searchResultsInBody?: boolean;
141
141
  }
@@ -336,12 +336,12 @@ export function MailListHeader({
336
336
  // It belongs to the search, not to any one way of showing it. The affordance
337
337
  // therefore sits in the pane, above whichever body is up — the read-only
338
338
  // 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.
339
+ // is mounted on the same condition as the query itself. Rendering it inside
340
+ // the results panel made it a mailbox's flash: the panel shows while the query
341
+ // is being typed and hands back to `MessageList` the moment the query commits
342
+ // to the URL, taking the affordance down with it a few hundred milliseconds
343
+ // after it appeared. Mounting it here instead keeps it up through that
344
+ // hand-back on every view.
345
345
  const searchHadSemanticReach = related.length > 0;
346
346
  const conversion = useMemo(
347
347
  () => convertSearchToRule(parsed, { searchHadSemanticReach }),
@@ -372,10 +372,11 @@ export function MailListHeader({
372
372
  // Tablet + desktop keep the inline toolbar search; while a query is being
373
373
  // typed the list-pane body swaps to the same sectioned results the phone
374
374
  // 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.
375
+ // the committed search as a selectable list (`searchResultsInBody` the
376
+ // mailbox route, the brief, Starred) keeps the panel only until the query
377
+ // commits to the URL, then hands back to its own rows so multi-select (and,
378
+ // on the mailbox route, the "Select all N matching" escalation) is reachable
379
+ // (#212). Clearing the query restores the normal list.
379
380
  const showInlineResults = showInlineSearchResults({
380
381
  tier,
381
382
  hasLiveInput: hasQuery,
@@ -54,7 +54,8 @@ interface MailViewChromeProps {
54
54
  /**
55
55
  * The body renders the committed search as a selectable list itself; see
56
56
  * `MailListHeader`. The mailbox route sets this so a committed search yields
57
- * its multi-select `MessageList` (#212); flagged leaves it unset.
57
+ * its multi-select `MessageList` (#212); Starred sets it for the same reason,
58
+ * over its own already query-narrowed rows.
58
59
  */
59
60
  searchResultsInBody?: boolean;
60
61
  }
@@ -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;