@remit/web-client 0.0.108 → 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.
|
|
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": {
|
package/src/auth/AccountMenu.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Avatar } from "@remit/ui";
|
|
2
|
-
import {
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* not the list's: the list header drops its own field wherever this bar
|
|
7
|
-
* mounted, so exactly one search input exists on the page and the "/"
|
|
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,
|
|
12
|
-
* move and the rest stay on the reading pane's own
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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.
|
|
478
|
-
//
|
|
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
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
|
|
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
|
|
653
|
-
const searchResults = useMemo<SearchResult[]>(
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
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
|
|
702
|
-
expanded:
|
|
703
|
-
onExpandedChange:
|
|
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:
|
|
639
|
+
onToggleFilter: (id: string) => toggleFilter(id as BriefFilterId),
|
|
708
640
|
onClear: () => {
|
|
709
641
|
setSelectedCategory("all");
|
|
710
642
|
setSelectedAccountId("all");
|
|
711
|
-
|
|
643
|
+
clearFilters();
|
|
712
644
|
},
|
|
713
645
|
};
|
|
714
646
|
}, [
|
|
715
647
|
accountSources,
|
|
716
648
|
mutedCount,
|
|
717
649
|
selectedCategory,
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
<
|
|
850
|
-
{
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
}
|
|
@@ -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,
|
|
@@ -435,9 +436,12 @@ export function MailListHeader({
|
|
|
435
436
|
/>
|
|
436
437
|
),
|
|
437
438
|
titleMeta: (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
439
|
+
<>
|
|
440
|
+
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
441
|
+
{unreadCount.toLocaleString()} unread
|
|
442
|
+
</span>
|
|
443
|
+
{!hasQuery && <FilterToggle />}
|
|
444
|
+
</>
|
|
441
445
|
),
|
|
442
446
|
searchSlot: ownsSearch && !searchExpanded && (
|
|
443
447
|
<Button
|
|
@@ -488,6 +492,7 @@ export function MailListHeader({
|
|
|
488
492
|
chromeResults,
|
|
489
493
|
makeFilterAction,
|
|
490
494
|
searchConversion,
|
|
495
|
+
hasQuery,
|
|
491
496
|
],
|
|
492
497
|
);
|
|
493
498
|
|
|
@@ -13,17 +13,18 @@
|
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
+
FilterPanelProvider,
|
|
21
22
|
type FilterPreset,
|
|
22
23
|
FilterSheet,
|
|
23
24
|
type FilterSheetProps,
|
|
24
25
|
type SearchResult,
|
|
25
26
|
} from "@remit/ui";
|
|
26
|
-
import {
|
|
27
|
+
import type { ReactNode } from "react";
|
|
27
28
|
import { useMailContext } from "@/lib/mail-context";
|
|
28
29
|
import { MailListHeader } from "./MailListHeader";
|
|
29
30
|
|
|
@@ -81,7 +82,6 @@ export function MailViewChrome({
|
|
|
81
82
|
relatedResultsLabel,
|
|
82
83
|
searchResultsInBody,
|
|
83
84
|
}: MailViewChromeProps) {
|
|
84
|
-
const [expanded, setExpanded] = useState(false);
|
|
85
85
|
// A query owns the pane: the filter chrome and the search's own affordance
|
|
86
86
|
// narrow the same list from the same place, so the filter sheet stands down
|
|
87
87
|
// for as long as something is being searched. Its state survives — clearing
|
|
@@ -95,8 +95,6 @@ export function MailViewChrome({
|
|
|
95
95
|
sources: preset.sources,
|
|
96
96
|
selectedCategory,
|
|
97
97
|
activeFilters,
|
|
98
|
-
expanded,
|
|
99
|
-
onExpandedChange: setExpanded,
|
|
100
98
|
onSelectCategory,
|
|
101
99
|
onSelectSource,
|
|
102
100
|
onToggleFilter,
|
|
@@ -104,26 +102,28 @@ export function MailViewChrome({
|
|
|
104
102
|
};
|
|
105
103
|
|
|
106
104
|
return (
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
|
|
127
|
-
|
|
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>
|
|
128
128
|
);
|
|
129
129
|
}
|