@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 +1 -1
- package/src/components/mail/DailyBrief.tsx +244 -100
- package/src/components/mail/brief-server-sections.render.test.ts +655 -0
- package/src/hooks/calendar/agenda-window.ts +2 -15
- package/src/hooks/useBriefSections.ts +310 -0
- package/src/lib/brief-criteria.test.ts +126 -0
- package/src/lib/brief-criteria.ts +98 -0
- package/src/lib/brief.test.ts +215 -148
- package/src/lib/brief.ts +116 -53
- package/src/lib/drafts.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
6
|
-
* Starred mail is not a section — Flagged is a virtual
|
|
7
|
-
* brief defaults to the cross-account aggregate, and
|
|
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
|
|
25
|
-
*
|
|
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,
|
|
30
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
-
/*
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
query:
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
606
|
-
|
|
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
|
-
|
|
709
|
-
|
|
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,
|
|
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
|
|
1031
|
+
searchLoading: isLoading,
|
|
888
1032
|
relatedResults,
|
|
889
1033
|
relatedLoading,
|
|
890
1034
|
onSelectSearchResult: openResult,
|
|
891
|
-
// The body already narrows to the committed query
|
|
892
|
-
//
|
|
893
|
-
//
|
|
894
|
-
// it is on the mailbox route (#212) — the two-engine
|
|
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
|
}}
|