@remit/web-client 0.0.109 → 0.0.111
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 +142 -193
- package/src/components/mail/MailListHeader.tsx +1 -2
- package/src/components/mail/MailViewChrome.tsx +4 -4
- package/src/components/mail/MessageList.tsx +5 -9
- package/src/components/mail/MessageListItem.tsx +6 -2
- package/src/components/mail/MessageRow.tsx +3 -2
- package/src/components/mail/SwipeableMessageRow.tsx +1 -1
- package/src/components/mail/useModifierSelect.render.test.ts +2 -26
- package/src/components/mail/useModifierSelect.ts +1 -10
- package/src/hooks/useListCursor.ts +7 -5
- package/src/lib/selection-mode.test.ts +1 -25
- package/src/lib/selection-mode.ts +0 -12
- package/src/hooks/useSelection.test.ts +0 -244
- package/src/hooks/useSelection.ts +0 -292
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.111",
|
|
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
|
|
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
|
}
|
|
@@ -440,7 +440,7 @@ export function MailListHeader({
|
|
|
440
440
|
<span className="shrink-0 text-2xs text-fg-subtle">
|
|
441
441
|
{unreadCount.toLocaleString()} unread
|
|
442
442
|
</span>
|
|
443
|
-
|
|
443
|
+
<FilterToggle />
|
|
444
444
|
</>
|
|
445
445
|
),
|
|
446
446
|
searchSlot: ownsSearch && !searchExpanded && (
|
|
@@ -492,7 +492,6 @@ export function MailListHeader({
|
|
|
492
492
|
chromeResults,
|
|
493
493
|
makeFilterAction,
|
|
494
494
|
searchConversion,
|
|
495
|
-
hasQuery,
|
|
496
495
|
],
|
|
497
496
|
);
|
|
498
497
|
|
|
@@ -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
|
|
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
21
|
FilterPanelProvider,
|
|
@@ -102,7 +102,7 @@ export function MailViewChrome({
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
return (
|
|
105
|
-
<FilterPanelProvider>
|
|
105
|
+
<FilterPanelProvider hasSheet={!searching}>
|
|
106
106
|
<MailListHeader
|
|
107
107
|
title={title}
|
|
108
108
|
unreadCount={unreadCount}
|
|
@@ -2,10 +2,14 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
2
2
|
import {
|
|
3
3
|
Banner,
|
|
4
4
|
type Density,
|
|
5
|
+
deriveIsMultiSelectMode,
|
|
5
6
|
type MessageListFilter,
|
|
6
7
|
MessageListLoadingMore,
|
|
7
8
|
MessageListPane,
|
|
9
|
+
nextFocusId,
|
|
10
|
+
type SelectionModifiers,
|
|
8
11
|
SelectionTopBar,
|
|
12
|
+
useSelection,
|
|
9
13
|
type Verb,
|
|
10
14
|
} from "@remit/ui";
|
|
11
15
|
import { useBlocker, useNavigate } from "@tanstack/react-router";
|
|
@@ -26,11 +30,6 @@ import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
|
26
30
|
import { useLabelList } from "@/hooks/useLabels";
|
|
27
31
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
28
32
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
29
|
-
import {
|
|
30
|
-
nextFocusId,
|
|
31
|
-
type SelectionModifiers,
|
|
32
|
-
useSelection,
|
|
33
|
-
} from "@/hooks/useSelection";
|
|
34
33
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
35
34
|
import {
|
|
36
35
|
type BulkActionKind,
|
|
@@ -53,10 +52,7 @@ import {
|
|
|
53
52
|
} from "@/lib/format";
|
|
54
53
|
import { tabStopId } from "@/lib/list-focus";
|
|
55
54
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
56
|
-
import {
|
|
57
|
-
deriveIsMultiSelectMode,
|
|
58
|
-
shouldExitSelectionOnNavigate,
|
|
59
|
-
} from "@/lib/selection-mode";
|
|
55
|
+
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
60
56
|
import { cn } from "@/lib/utils";
|
|
61
57
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
62
58
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* interactive lives in `MessageRow`, which the brief and Flagged render too.
|
|
7
7
|
*/
|
|
8
8
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
Density,
|
|
11
|
+
SelectionModifiers,
|
|
12
|
+
SenderTrustLevel,
|
|
13
|
+
ThreadRowData,
|
|
14
|
+
} from "@remit/ui";
|
|
10
15
|
import { memo } from "react";
|
|
11
|
-
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
12
16
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
13
17
|
import { formatEmailDate } from "@/lib/format";
|
|
14
18
|
import { MessageRow } from "./MessageRow";
|
|
@@ -18,17 +18,18 @@ import {
|
|
|
18
18
|
compactRowClass,
|
|
19
19
|
type Density,
|
|
20
20
|
mergeProps,
|
|
21
|
+
modifiersOf,
|
|
21
22
|
type RowToggleEvent,
|
|
23
|
+
type SelectionModifiers,
|
|
22
24
|
type ThreadRowData,
|
|
23
25
|
useLongPress,
|
|
24
26
|
} from "@remit/ui";
|
|
25
27
|
import { useQueryClient } from "@tanstack/react-query";
|
|
26
28
|
import { Link } from "@tanstack/react-router";
|
|
27
29
|
import { type MouseEvent, memo, type ReactNode, useCallback } from "react";
|
|
28
|
-
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
29
30
|
import { cn } from "@/lib/utils";
|
|
30
31
|
import { useThreadRowInteraction } from "./ThreadListInteraction";
|
|
31
|
-
import {
|
|
32
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
32
33
|
|
|
33
34
|
interface MailboxLinkSearch {
|
|
34
35
|
selectedMessageId?: string;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
2
|
import {
|
|
3
3
|
type Density,
|
|
4
|
+
type SelectionModifiers,
|
|
4
5
|
SwipeableRow,
|
|
5
6
|
type SwipePeek,
|
|
6
7
|
type ThreadRowData,
|
|
7
8
|
} from "@remit/ui";
|
|
8
9
|
import { Link } from "@tanstack/react-router";
|
|
9
10
|
import { useCallback, useState } from "react";
|
|
10
|
-
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import assert from "node:assert/strict";
|
|
16
16
|
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import type { SelectionModifiers } from "@remit/ui";
|
|
17
18
|
import { createElement, type MouseEvent } from "react";
|
|
18
|
-
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
19
19
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
20
|
-
import {
|
|
20
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
21
21
|
|
|
22
22
|
let harness: DomHarness | undefined;
|
|
23
23
|
|
|
@@ -221,27 +221,3 @@ describe("useModifierSelect", () => {
|
|
|
221
221
|
assert.equal(press(dom, "contextmenu", { ctrlKey: true }), false);
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
|
-
|
|
225
|
-
describe("isModified", () => {
|
|
226
|
-
it("is true for any of the three keys a keyboard can add", () => {
|
|
227
|
-
assert.equal(
|
|
228
|
-
isModified({ shiftKey: true, metaKey: false, ctrlKey: false }),
|
|
229
|
-
true,
|
|
230
|
-
);
|
|
231
|
-
assert.equal(
|
|
232
|
-
isModified({ shiftKey: false, metaKey: true, ctrlKey: false }),
|
|
233
|
-
true,
|
|
234
|
-
);
|
|
235
|
-
assert.equal(
|
|
236
|
-
isModified({ shiftKey: false, metaKey: false, ctrlKey: true }),
|
|
237
|
-
true,
|
|
238
|
-
);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("is false for the bare press a tap delivers", () => {
|
|
242
|
-
assert.equal(
|
|
243
|
-
isModified({ shiftKey: false, metaKey: false, ctrlKey: false }),
|
|
244
|
-
false,
|
|
245
|
-
);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
@@ -16,17 +16,8 @@
|
|
|
16
16
|
* `preventDefault` is already too late there. `claimClick` still handles the
|
|
17
17
|
* click for engines that deliver it that way.
|
|
18
18
|
*/
|
|
19
|
+
import { isModified, modifiersOf, type SelectionModifiers } from "@remit/ui";
|
|
19
20
|
import { type MouseEvent, useCallback, useRef } from "react";
|
|
20
|
-
import type { SelectionModifiers } from "@/hooks/useSelection";
|
|
21
|
-
|
|
22
|
-
export const modifiersOf = (e: MouseEvent): SelectionModifiers => ({
|
|
23
|
-
shiftKey: e.shiftKey,
|
|
24
|
-
metaKey: e.metaKey,
|
|
25
|
-
ctrlKey: e.ctrlKey,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
export const isModified = (m: SelectionModifiers): boolean =>
|
|
29
|
-
m.shiftKey || m.metaKey || m.ctrlKey;
|
|
30
21
|
|
|
31
22
|
export interface ModifierSelect {
|
|
32
23
|
/** Takes a modified press for selection before the browser acts on it. */
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
* `cursorMovedByPointerRef` records whether the last move came from a click, so
|
|
14
14
|
* a list that scrolls its cursor into view can skip doing so for pointer moves.
|
|
15
15
|
*/
|
|
16
|
-
import { useCallback, useMemo, useRef, useState } from "react";
|
|
17
16
|
import {
|
|
17
|
+
deriveIsMultiSelectMode,
|
|
18
18
|
nextFocusId,
|
|
19
|
+
rowSelectIntent,
|
|
19
20
|
type SelectionModifiers,
|
|
20
21
|
useSelection,
|
|
21
|
-
} from "
|
|
22
|
-
import {
|
|
22
|
+
} from "@remit/ui";
|
|
23
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
23
24
|
|
|
24
25
|
interface UseListCursorOptions {
|
|
25
26
|
/** Message ids in display order. */
|
|
@@ -178,14 +179,15 @@ export const useListCursor = ({
|
|
|
178
179
|
|
|
179
180
|
const handleRowSelect = useCallback(
|
|
180
181
|
(messageId: string, modifiers: SelectionModifiers): boolean => {
|
|
181
|
-
|
|
182
|
+
const intent = rowSelectIntent(modifiers);
|
|
183
|
+
if (intent === "range") {
|
|
182
184
|
// The open/focused row is the fallback origin when the stored anchor
|
|
183
185
|
// has been filtered or searched out of the visible list, so the first
|
|
184
186
|
// shift-click still ranges from where the user is (#142, #144).
|
|
185
187
|
selectRange(orderedIds, messageId, focusedMessageId);
|
|
186
188
|
return true;
|
|
187
189
|
}
|
|
188
|
-
if (
|
|
190
|
+
if (intent === "toggle") {
|
|
189
191
|
toggleCheck(messageId);
|
|
190
192
|
return true;
|
|
191
193
|
}
|
|
@@ -1,30 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
deriveIsMultiSelectMode,
|
|
5
|
-
shouldExitSelectionOnNavigate,
|
|
6
|
-
} from "./selection-mode.js";
|
|
7
|
-
|
|
8
|
-
describe("deriveIsMultiSelectMode", () => {
|
|
9
|
-
test("no selection is not multi-select mode", () => {
|
|
10
|
-
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("any selection on touch is multi-select mode", () => {
|
|
14
|
-
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
15
|
-
assert.equal(deriveIsMultiSelectMode(42, false), true);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("desktop selection drives the desktop toolbar, not multi-select mode", () => {
|
|
19
|
-
assert.equal(deriveIsMultiSelectMode(1, true), false);
|
|
20
|
-
assert.equal(deriveIsMultiSelectMode(0, true), false);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("dropping the last selected id leaves the mode in the same call", () => {
|
|
24
|
-
assert.equal(deriveIsMultiSelectMode(1, false), true);
|
|
25
|
-
assert.equal(deriveIsMultiSelectMode(0, false), false);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
3
|
+
import { shouldExitSelectionOnNavigate } from "./selection-mode.js";
|
|
28
4
|
|
|
29
5
|
describe("shouldExitSelectionOnNavigate", () => {
|
|
30
6
|
test("back while selecting exits selection instead of navigating", () => {
|
|
@@ -8,18 +8,6 @@ import type { StepId } from "@remit/ui";
|
|
|
8
8
|
/** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
|
|
9
9
|
export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Whether the list is in multi-select mode: a function of the selection count,
|
|
13
|
-
* never a stored flag, so the two can never disagree. Multi-select is the
|
|
14
|
-
* touch affordance (long press, always-visible checkboxes, the selection top
|
|
15
|
-
* bar); on desktop a selection drives the desktop toolbar instead and rows
|
|
16
|
-
* keep their ordinary hover behaviour.
|
|
17
|
-
*/
|
|
18
|
-
export const deriveIsMultiSelectMode = (
|
|
19
|
-
selectedCount: number,
|
|
20
|
-
isDesktop: boolean,
|
|
21
|
-
): boolean => !isDesktop && selectedCount > 0;
|
|
22
|
-
|
|
23
11
|
/**
|
|
24
12
|
* Whether a history navigation should exit selection mode instead of leaving
|
|
25
13
|
* the route. Only the back gesture is intercepted, so a navigation the app
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
computeRange,
|
|
5
|
-
intersectSelectedIds,
|
|
6
|
-
nextFocusId,
|
|
7
|
-
resolveRangeAnchor,
|
|
8
|
-
} from "./useSelection.js";
|
|
9
|
-
|
|
10
|
-
const ids = ["a", "b", "c", "d", "e"];
|
|
11
|
-
|
|
12
|
-
// Mirrors the hook's `selectRange` state transition (anchor + selection) using
|
|
13
|
-
// the same pure helpers it runs, so the mouse/keyboard sequences below exercise
|
|
14
|
-
// the real logic without an interactive DOM. Returns the next {selected, anchor}.
|
|
15
|
-
const applyRange = (
|
|
16
|
-
state: { selected: Set<string>; anchor: string | undefined },
|
|
17
|
-
orderedIds: string[],
|
|
18
|
-
targetId: string,
|
|
19
|
-
fallbackAnchor?: string,
|
|
20
|
-
): { selected: Set<string>; anchor: string | undefined } => {
|
|
21
|
-
const anchor = resolveRangeAnchor(
|
|
22
|
-
orderedIds,
|
|
23
|
-
state.anchor,
|
|
24
|
-
fallbackAnchor,
|
|
25
|
-
targetId,
|
|
26
|
-
);
|
|
27
|
-
const range = computeRange(orderedIds, anchor, targetId);
|
|
28
|
-
const selected = new Set(state.selected);
|
|
29
|
-
for (const id of range) selected.add(id);
|
|
30
|
-
return { selected, anchor };
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
// Mirrors a cmd/ctrl-click: toggle membership and re-anchor on the clicked row.
|
|
34
|
-
const applyToggle = (
|
|
35
|
-
state: { selected: Set<string>; anchor: string | undefined },
|
|
36
|
-
targetId: string,
|
|
37
|
-
): { selected: Set<string>; anchor: string | undefined } => {
|
|
38
|
-
const selected = new Set(state.selected);
|
|
39
|
-
if (selected.has(targetId)) selected.delete(targetId);
|
|
40
|
-
else selected.add(targetId);
|
|
41
|
-
return { selected, anchor: targetId };
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
describe("computeRange", () => {
|
|
45
|
-
test("forward range: anchor above target selects the inclusive slice", () => {
|
|
46
|
-
assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("backward range: target above anchor selects the same inclusive slice", () => {
|
|
50
|
-
assert.deepStrictEqual(computeRange(ids, "d", "b"), ["b", "c", "d"]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("anchor equals target selects just that one id", () => {
|
|
54
|
-
assert.deepStrictEqual(computeRange(ids, "c", "c"), ["c"]);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("no anchor selects just the target", () => {
|
|
58
|
-
assert.deepStrictEqual(computeRange(ids, undefined, "c"), ["c"]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("anchor not present in the list selects just the target", () => {
|
|
62
|
-
assert.deepStrictEqual(computeRange(ids, "zzz", "c"), ["c"]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("full span from first to last includes every id in order", () => {
|
|
66
|
-
assert.deepStrictEqual(computeRange(ids, "a", "e"), [
|
|
67
|
-
"a",
|
|
68
|
-
"b",
|
|
69
|
-
"c",
|
|
70
|
-
"d",
|
|
71
|
-
"e",
|
|
72
|
-
]);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("target not present in the list selects nothing", () => {
|
|
76
|
-
assert.deepStrictEqual(computeRange(ids, "b", "zzz"), []);
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("resolveRangeAnchor", () => {
|
|
81
|
-
test("keeps a still-visible stored anchor so consecutive shift-clicks extend from it", () => {
|
|
82
|
-
assert.strictEqual(resolveRangeAnchor(ids, "b", undefined, "d"), "b");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("a stored anchor no longer visible falls back to the open/focused row", () => {
|
|
86
|
-
// The stored anchor "z" was filtered/searched out of the visible list; the
|
|
87
|
-
// open row "b" is still visible, so the range anchors there.
|
|
88
|
-
assert.strictEqual(resolveRangeAnchor(ids, "z", "b", "d"), "b");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("with neither a visible stored anchor nor a visible fallback, the target anchors itself", () => {
|
|
92
|
-
assert.strictEqual(resolveRangeAnchor(ids, "z", "y", "d"), "d");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("no stored anchor and no fallback selects just the target", () => {
|
|
96
|
-
assert.strictEqual(resolveRangeAnchor(ids, undefined, undefined, "c"), "c");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("a stored anchor beats the fallback while it stays visible", () => {
|
|
100
|
-
assert.strictEqual(resolveRangeAnchor(ids, "a", "c", "e"), "a");
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("shift-click range over a filtered/search-narrowed list (#142, #144)", () => {
|
|
105
|
-
test("shift-click range with an active filter builds the range within the visible rows", () => {
|
|
106
|
-
// Full inbox is a..h; the "Automated" filter leaves only these rows.
|
|
107
|
-
const filtered = ["b", "d", "f", "g"];
|
|
108
|
-
// The open row "d" is visible; nothing selected yet, no stored anchor.
|
|
109
|
-
let state = {
|
|
110
|
-
selected: new Set<string>(),
|
|
111
|
-
anchor: undefined as string | undefined,
|
|
112
|
-
};
|
|
113
|
-
// Shift-click "g": ranges from the open/focused row "d" to "g".
|
|
114
|
-
state = applyRange(state, filtered, "g", "d");
|
|
115
|
-
assert.deepStrictEqual([...state.selected], ["d", "f", "g"]);
|
|
116
|
-
assert.strictEqual(state.anchor, "d");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("range where the anchor left the visible set re-anchors instead of no-oping", () => {
|
|
120
|
-
const filtered = ["b", "d", "f", "g"];
|
|
121
|
-
// Stored anchor "a" was selected before the filter narrowed the list and is
|
|
122
|
-
// no longer visible; there is no open/focused fallback row.
|
|
123
|
-
let state = {
|
|
124
|
-
selected: new Set<string>(),
|
|
125
|
-
anchor: "a" as string | undefined,
|
|
126
|
-
};
|
|
127
|
-
// First shift-click adopts the clicked row as the new visible anchor.
|
|
128
|
-
state = applyRange(state, filtered, "f");
|
|
129
|
-
assert.deepStrictEqual([...state.selected], ["f"]);
|
|
130
|
-
assert.strictEqual(state.anchor, "f");
|
|
131
|
-
// Second shift-click now builds a real range from that adopted anchor.
|
|
132
|
-
state = applyRange(state, filtered, "b");
|
|
133
|
-
assert.deepStrictEqual([...state.selected].sort(), ["b", "d", "f"]);
|
|
134
|
-
assert.strictEqual(state.anchor, "f");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("multi-select across a search-results list: cmd-click then shift-click", () => {
|
|
138
|
-
// Search "npm" yields these results; the inbox anchor is gone from this set.
|
|
139
|
-
const results = ["m1", "m2", "m3", "m4", "m5"];
|
|
140
|
-
let state = {
|
|
141
|
-
selected: new Set<string>(),
|
|
142
|
-
anchor: "inbox-row" as string | undefined,
|
|
143
|
-
};
|
|
144
|
-
// Cmd-click "m2": toggles it in and re-anchors on it (a visible row).
|
|
145
|
-
state = applyToggle(state, "m2");
|
|
146
|
-
assert.deepStrictEqual([...state.selected], ["m2"]);
|
|
147
|
-
assert.strictEqual(state.anchor, "m2");
|
|
148
|
-
// Shift-click "m4": ranges from the cmd-clicked anchor across the results.
|
|
149
|
-
state = applyRange(state, results, "m4");
|
|
150
|
-
assert.deepStrictEqual([...state.selected].sort(), ["m2", "m3", "m4"]);
|
|
151
|
-
assert.strictEqual(state.anchor, "m2");
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe("nextFocusId", () => {
|
|
156
|
-
test("moves down one row", () => {
|
|
157
|
-
assert.strictEqual(nextFocusId(ids, "b", 1), "c");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("moves up one row", () => {
|
|
161
|
-
assert.strictEqual(nextFocusId(ids, "c", -1), "b");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("clamps at the bottom (no wrap)", () => {
|
|
165
|
-
assert.strictEqual(nextFocusId(ids, "e", 1), "e");
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("clamps at the top (no wrap)", () => {
|
|
169
|
-
assert.strictEqual(nextFocusId(ids, "a", -1), "a");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("no focus + down starts at the first row", () => {
|
|
173
|
-
assert.strictEqual(nextFocusId(ids, undefined, 1), "a");
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("no focus + up starts at the last row", () => {
|
|
177
|
-
assert.strictEqual(nextFocusId(ids, undefined, -1), "e");
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("focus not in the list + down starts at the first row", () => {
|
|
181
|
-
assert.strictEqual(nextFocusId(ids, "zzz", 1), "a");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("empty list returns undefined", () => {
|
|
185
|
-
assert.strictEqual(nextFocusId([], "a", 1), undefined);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
describe("intersectSelectedIds", () => {
|
|
190
|
-
test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
|
|
191
|
-
// Regression for #111: the effect used to clear the WHOLE selection the
|
|
192
|
-
// moment any single selected id left the list. Here "b" leaves (deleted
|
|
193
|
-
// elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
|
|
194
|
-
const selected = new Set(["a", "b", "c"]);
|
|
195
|
-
const refreshedThreadIds = ["a", "c", "d", "f"];
|
|
196
|
-
assert.deepStrictEqual(
|
|
197
|
-
intersectSelectedIds(selected, refreshedThreadIds),
|
|
198
|
-
new Set(["a", "c"]),
|
|
199
|
-
);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("never adds an id that wasn't already selected", () => {
|
|
203
|
-
const selected = new Set(["a"]);
|
|
204
|
-
assert.deepStrictEqual(
|
|
205
|
-
intersectSelectedIds(selected, ["a", "b", "c"]),
|
|
206
|
-
new Set(["a"]),
|
|
207
|
-
);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("every selected id surviving is a no-op (same members, not just same size)", () => {
|
|
211
|
-
const selected = new Set(["a", "b"]);
|
|
212
|
-
assert.deepStrictEqual(
|
|
213
|
-
intersectSelectedIds(selected, ["a", "b", "z"]),
|
|
214
|
-
new Set(["a", "b"]),
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
|
|
219
|
-
// Mirrors processRunOutcome materializing the failed ids as the new
|
|
220
|
-
// selection, then the cache-invalidation refetch running this same
|
|
221
|
-
// intersection against the freshly reloaded `threads`. One retry id
|
|
222
|
-
// ("fail-2") is momentarily missing from the refreshed page; the other
|
|
223
|
-
// two must stay selected so the Retry notice (gated on
|
|
224
|
-
// `selectedCount > 0`) doesn't disappear with it.
|
|
225
|
-
const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
|
|
226
|
-
const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
|
|
227
|
-
assert.deepStrictEqual(
|
|
228
|
-
intersectSelectedIds(retrySelection, refetchedThreadIds),
|
|
229
|
-
new Set(["fail-1", "fail-3"]),
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
|
|
234
|
-
const selected = new Set(["a", "b"]);
|
|
235
|
-
assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("an empty selection stays empty", () => {
|
|
239
|
-
assert.deepStrictEqual(
|
|
240
|
-
intersectSelectedIds(new Set(), ["a", "b"]),
|
|
241
|
-
new Set(),
|
|
242
|
-
);
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Mouse/keyboard modifier flags read off a row click, used to drive desktop
|
|
5
|
-
* multi-select semantics (shift = range, cmd/ctrl = toggle, plain = open).
|
|
6
|
-
*/
|
|
7
|
-
export interface SelectionModifiers {
|
|
8
|
-
shiftKey: boolean;
|
|
9
|
-
metaKey: boolean;
|
|
10
|
-
ctrlKey: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface UseSelectionOptions<T> {
|
|
14
|
-
/** Function to extract ID from an item */
|
|
15
|
-
getId: (item: T) => string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface UseSelectionReturn {
|
|
19
|
-
/** Set of currently selected item IDs */
|
|
20
|
-
selectedIds: Set<string>;
|
|
21
|
-
/** Number of selected items */
|
|
22
|
-
selectedCount: number;
|
|
23
|
-
/** Whether any items are selected */
|
|
24
|
-
hasSelection: boolean;
|
|
25
|
-
/** Check if a specific item is selected */
|
|
26
|
-
isSelected: (id: string) => boolean;
|
|
27
|
-
/** Toggle selection for a single item (updates the range anchor) */
|
|
28
|
-
toggle: (id: string) => void;
|
|
29
|
-
/** Select a single item (adds to selection, updates the range anchor) */
|
|
30
|
-
select: (id: string) => void;
|
|
31
|
-
/** Deselect a single item */
|
|
32
|
-
deselect: (id: string) => void;
|
|
33
|
-
/** Select all items */
|
|
34
|
-
selectAll: (ids: string[]) => void;
|
|
35
|
-
/** Clear all selections (also clears the range anchor) */
|
|
36
|
-
clearSelection: () => void;
|
|
37
|
-
/** Toggle selection for all items */
|
|
38
|
-
toggleAll: (ids: string[]) => void;
|
|
39
|
-
/**
|
|
40
|
-
* Add the contiguous range of ids from the anchor to `targetId` (inclusive)
|
|
41
|
-
* to the selection, using `orderedIds` for display order. The anchor is the
|
|
42
|
-
* stored one when it is still visible in `orderedIds`; otherwise
|
|
43
|
-
* `fallbackAnchor` when that is visible (the open/focused row); otherwise
|
|
44
|
-
* `targetId`. Whatever anchors the range becomes the new stored anchor, so a
|
|
45
|
-
* filtered or search-narrowed list can still build a range within what's
|
|
46
|
-
* visible (#142, #144).
|
|
47
|
-
*/
|
|
48
|
-
selectRange: (
|
|
49
|
-
orderedIds: string[],
|
|
50
|
-
targetId: string,
|
|
51
|
-
fallbackAnchor?: string,
|
|
52
|
-
) => void;
|
|
53
|
-
/**
|
|
54
|
-
* Set the range anchor without changing the selection set. Used by a plain
|
|
55
|
-
* click that navigates but should seed the anchor for a later shift-click.
|
|
56
|
-
*/
|
|
57
|
-
setAnchor: (id: string) => void;
|
|
58
|
-
/**
|
|
59
|
-
* The id of the row that anchors shift-range selection. `undefined` when
|
|
60
|
-
* nothing has been selected yet.
|
|
61
|
-
*/
|
|
62
|
-
anchorId: string | undefined;
|
|
63
|
-
/**
|
|
64
|
-
* Narrows the selection to whatever in it is still present in `currentIds`
|
|
65
|
-
* — drops ids that left, keeps every survivor. Never adds anything, and
|
|
66
|
-
* never clears the selection just because one id is gone (#111).
|
|
67
|
-
*/
|
|
68
|
-
intersectWith: (currentIds: readonly string[]) => void;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Compute the inclusive slice of ids spanning from `anchorId` to `targetId`
|
|
73
|
-
* in `orderedIds`. Pure so it can be unit-tested without a DOM.
|
|
74
|
-
*
|
|
75
|
-
* - Direction-agnostic: works whether the target sits above or below the anchor.
|
|
76
|
-
* - Missing anchor (or anchor not in the list): returns just `[targetId]`.
|
|
77
|
-
* - Target not in the list: returns `[]` (nothing to select).
|
|
78
|
-
*/
|
|
79
|
-
export const computeRange = (
|
|
80
|
-
orderedIds: string[],
|
|
81
|
-
anchorId: string | undefined,
|
|
82
|
-
targetId: string,
|
|
83
|
-
): string[] => {
|
|
84
|
-
const targetIndex = orderedIds.indexOf(targetId);
|
|
85
|
-
if (targetIndex === -1) return [];
|
|
86
|
-
|
|
87
|
-
const anchorIndex =
|
|
88
|
-
anchorId === undefined ? -1 : orderedIds.indexOf(anchorId);
|
|
89
|
-
if (anchorIndex === -1) return [targetId];
|
|
90
|
-
|
|
91
|
-
const start = Math.min(anchorIndex, targetIndex);
|
|
92
|
-
const end = Math.max(anchorIndex, targetIndex);
|
|
93
|
-
return orderedIds.slice(start, end + 1);
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Resolve which id a shift-range selection anchors from, given the stored
|
|
98
|
-
* anchor and the ids currently visible (`orderedIds`). Pure so the
|
|
99
|
-
* filtered/search anchor behavior can be unit-tested without a DOM.
|
|
100
|
-
*
|
|
101
|
-
* - The stored anchor wins while it is still visible — consecutive shift-clicks
|
|
102
|
-
* keep extending from the same origin (Apple Mail / Gmail).
|
|
103
|
-
* - Once the stored anchor leaves the visible set (filtered out, or a search
|
|
104
|
-
* changed the list), it can't anchor a range in that set, so fall back to
|
|
105
|
-
* `fallbackAnchor` (the open/focused row) when it is visible.
|
|
106
|
-
* - With neither available, the target anchors itself: the clicked row is
|
|
107
|
-
* selected alone and becomes the origin for the next shift-click.
|
|
108
|
-
*/
|
|
109
|
-
export const resolveRangeAnchor = (
|
|
110
|
-
orderedIds: string[],
|
|
111
|
-
storedAnchor: string | undefined,
|
|
112
|
-
fallbackAnchor: string | undefined,
|
|
113
|
-
targetId: string,
|
|
114
|
-
): string => {
|
|
115
|
-
if (storedAnchor !== undefined && orderedIds.includes(storedAnchor)) {
|
|
116
|
-
return storedAnchor;
|
|
117
|
-
}
|
|
118
|
-
if (fallbackAnchor !== undefined && orderedIds.includes(fallbackAnchor)) {
|
|
119
|
-
return fallbackAnchor;
|
|
120
|
-
}
|
|
121
|
-
return targetId;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The ids from `selectedIds` that are still present in `currentIds` — the
|
|
126
|
-
* survivor set after a list refresh. Only ever narrows: an id absent from
|
|
127
|
-
* `selectedIds` is never added just because it's in `currentIds`. Pure so the
|
|
128
|
-
* "drop what left, keep the rest" behavior (K-9's `selected.intersect
|
|
129
|
-
* (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
|
|
130
|
-
*/
|
|
131
|
-
export const intersectSelectedIds = (
|
|
132
|
-
selectedIds: ReadonlySet<string>,
|
|
133
|
-
currentIds: readonly string[],
|
|
134
|
-
): Set<string> => {
|
|
135
|
-
const present = new Set(currentIds);
|
|
136
|
-
const next = new Set<string>();
|
|
137
|
-
for (const id of selectedIds) {
|
|
138
|
-
if (present.has(id)) next.add(id);
|
|
139
|
-
}
|
|
140
|
-
return next;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
|
|
145
|
-
* Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
|
|
146
|
-
*
|
|
147
|
-
* - `direction` is -1 for up (previous) or +1 for down (next).
|
|
148
|
-
* - Missing focus (or focus not in the list): returns the first id for down,
|
|
149
|
-
* the last id for up, or `undefined` when the list is empty.
|
|
150
|
-
* - At a boundary: returns the same `focusId` (no wrap).
|
|
151
|
-
*/
|
|
152
|
-
export const nextFocusId = (
|
|
153
|
-
orderedIds: string[],
|
|
154
|
-
focusId: string | undefined,
|
|
155
|
-
direction: -1 | 1,
|
|
156
|
-
): string | undefined => {
|
|
157
|
-
if (orderedIds.length === 0) return undefined;
|
|
158
|
-
|
|
159
|
-
const currentIndex = focusId === undefined ? -1 : orderedIds.indexOf(focusId);
|
|
160
|
-
if (currentIndex === -1) {
|
|
161
|
-
return direction > 0 ? orderedIds[0] : orderedIds[orderedIds.length - 1];
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const nextIndex = Math.min(
|
|
165
|
-
Math.max(currentIndex + direction, 0),
|
|
166
|
-
orderedIds.length - 1,
|
|
167
|
-
);
|
|
168
|
-
return orderedIds[nextIndex];
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Hook for managing selection state in lists.
|
|
173
|
-
* Supports single and multi-select operations.
|
|
174
|
-
*/
|
|
175
|
-
export const useSelection = <T>(
|
|
176
|
-
_options?: UseSelectionOptions<T>,
|
|
177
|
-
): UseSelectionReturn => {
|
|
178
|
-
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
179
|
-
const [anchorId, setAnchorId] = useState<string | undefined>(undefined);
|
|
180
|
-
|
|
181
|
-
const isSelected = useCallback(
|
|
182
|
-
(id: string) => selectedIds.has(id),
|
|
183
|
-
[selectedIds],
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const toggle = useCallback((id: string) => {
|
|
187
|
-
setAnchorId(id);
|
|
188
|
-
setSelectedIds((prev) => {
|
|
189
|
-
const next = new Set(prev);
|
|
190
|
-
if (next.has(id)) {
|
|
191
|
-
next.delete(id);
|
|
192
|
-
} else {
|
|
193
|
-
next.add(id);
|
|
194
|
-
}
|
|
195
|
-
return next;
|
|
196
|
-
});
|
|
197
|
-
}, []);
|
|
198
|
-
|
|
199
|
-
const select = useCallback((id: string) => {
|
|
200
|
-
setAnchorId(id);
|
|
201
|
-
setSelectedIds((prev) => {
|
|
202
|
-
if (prev.has(id)) return prev;
|
|
203
|
-
const next = new Set(prev);
|
|
204
|
-
next.add(id);
|
|
205
|
-
return next;
|
|
206
|
-
});
|
|
207
|
-
}, []);
|
|
208
|
-
|
|
209
|
-
const deselect = useCallback((id: string) => {
|
|
210
|
-
setSelectedIds((prev) => {
|
|
211
|
-
if (!prev.has(id)) return prev;
|
|
212
|
-
const next = new Set(prev);
|
|
213
|
-
next.delete(id);
|
|
214
|
-
return next;
|
|
215
|
-
});
|
|
216
|
-
}, []);
|
|
217
|
-
|
|
218
|
-
const selectAll = useCallback((ids: string[]) => {
|
|
219
|
-
setSelectedIds(new Set(ids));
|
|
220
|
-
}, []);
|
|
221
|
-
|
|
222
|
-
const clearSelection = useCallback(() => {
|
|
223
|
-
setAnchorId(undefined);
|
|
224
|
-
setSelectedIds(new Set());
|
|
225
|
-
}, []);
|
|
226
|
-
|
|
227
|
-
const toggleAll = useCallback((ids: string[]) => {
|
|
228
|
-
setSelectedIds((prev) => {
|
|
229
|
-
const allSelected = ids.every((id) => prev.has(id));
|
|
230
|
-
return allSelected ? new Set() : new Set(ids);
|
|
231
|
-
});
|
|
232
|
-
}, []);
|
|
233
|
-
|
|
234
|
-
const setAnchor = useCallback((id: string) => {
|
|
235
|
-
setAnchorId(id);
|
|
236
|
-
}, []);
|
|
237
|
-
|
|
238
|
-
// Bails out to the same `prev` reference when nothing was dropped, so a
|
|
239
|
-
// caller can run this on every list refresh (e.g. an effect keyed on
|
|
240
|
-
// `threads`) without forcing a render each time.
|
|
241
|
-
const intersectWith = useCallback((currentIds: readonly string[]) => {
|
|
242
|
-
setSelectedIds((prev) => {
|
|
243
|
-
if (prev.size === 0) return prev;
|
|
244
|
-
const next = intersectSelectedIds(prev, currentIds);
|
|
245
|
-
return next.size === prev.size ? prev : next;
|
|
246
|
-
});
|
|
247
|
-
}, []);
|
|
248
|
-
|
|
249
|
-
const selectRange = useCallback(
|
|
250
|
-
(orderedIds: string[], targetId: string, fallbackAnchor?: string) => {
|
|
251
|
-
const effectiveAnchor = resolveRangeAnchor(
|
|
252
|
-
orderedIds,
|
|
253
|
-
anchorId,
|
|
254
|
-
fallbackAnchor,
|
|
255
|
-
targetId,
|
|
256
|
-
);
|
|
257
|
-
setSelectedIds((prev) => {
|
|
258
|
-
const range = computeRange(orderedIds, effectiveAnchor, targetId);
|
|
259
|
-
if (range.length === 0) return prev;
|
|
260
|
-
const next = new Set(prev);
|
|
261
|
-
for (const id of range) {
|
|
262
|
-
next.add(id);
|
|
263
|
-
}
|
|
264
|
-
return next;
|
|
265
|
-
});
|
|
266
|
-
// Whatever anchored the range becomes the stored anchor. A still-visible
|
|
267
|
-
// stored anchor resolves to itself (unchanged), so consecutive
|
|
268
|
-
// shift-clicks keep extending from the same origin; a stored anchor that
|
|
269
|
-
// left the visible set is replaced by the row the range actually used, so
|
|
270
|
-
// a filtered/search-narrowed list can build a range within what's visible.
|
|
271
|
-
setAnchorId(effectiveAnchor);
|
|
272
|
-
},
|
|
273
|
-
[anchorId],
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
selectedIds,
|
|
278
|
-
selectedCount: selectedIds.size,
|
|
279
|
-
hasSelection: selectedIds.size > 0,
|
|
280
|
-
isSelected,
|
|
281
|
-
toggle,
|
|
282
|
-
select,
|
|
283
|
-
deselect,
|
|
284
|
-
selectAll,
|
|
285
|
-
clearSelection,
|
|
286
|
-
toggleAll,
|
|
287
|
-
selectRange,
|
|
288
|
-
setAnchor,
|
|
289
|
-
anchorId,
|
|
290
|
-
intersectWith,
|
|
291
|
-
};
|
|
292
|
-
};
|