@remit/web-client 0.0.92 → 0.0.94
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.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +57 -112
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MessageList.selection.test.ts +10 -5
- package/src/components/mail/MessageList.tsx +78 -82
- package/src/components/mail/SelectionWizardHost.tsx +721 -64
- package/src/components/mail/ThreadListInteraction.tsx +0 -9
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +24 -5
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/mail-context.ts +0 -9
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +65 -2
- package/src/routes/mail.tsx +3 -8
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.94",
|
|
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": {
|
|
@@ -47,7 +47,11 @@ describe("the brief's select-all", () => {
|
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
50
|
+
it("sends every verb on the bar into the wizard (#477 1.4)", () => {
|
|
51
|
+
const body = chromeBody();
|
|
52
|
+
for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
|
|
53
|
+
assert.match(body, new RegExp(`startWizard\\("${verb}"\\)`));
|
|
54
|
+
}
|
|
55
|
+
assert.doesNotMatch(body, /onSomethingElse/);
|
|
52
56
|
});
|
|
53
57
|
});
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
SelectionTopBar,
|
|
47
47
|
type ThreadRowData,
|
|
48
48
|
type ThreadSection,
|
|
49
|
+
type Verb,
|
|
49
50
|
} from "@remit/ui";
|
|
50
51
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
51
52
|
import { useNavigate } from "@tanstack/react-router";
|
|
@@ -54,7 +55,6 @@ import {
|
|
|
54
55
|
type ReactNode,
|
|
55
56
|
type RefObject,
|
|
56
57
|
useCallback,
|
|
57
|
-
useEffect,
|
|
58
58
|
useMemo,
|
|
59
59
|
useState,
|
|
60
60
|
} from "react";
|
|
@@ -65,7 +65,6 @@ import {
|
|
|
65
65
|
} from "@/hooks/useInitialSyncProgress";
|
|
66
66
|
import { useLabelList } from "@/hooks/useLabels";
|
|
67
67
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
68
|
-
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
69
68
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
70
69
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
71
70
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -83,13 +82,15 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
83
82
|
import { useMailContext } from "@/lib/mail-context";
|
|
84
83
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
85
84
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
85
|
+
import { useOpenWizard } from "@/lib/wizard-history";
|
|
86
86
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
87
87
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
88
88
|
import type { MessageListCommands } from "./MessageList";
|
|
89
89
|
import { MessageRow } from "./MessageRow";
|
|
90
|
-
import {
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
import {
|
|
91
|
+
SelectionWizardHost,
|
|
92
|
+
type WizardSelectionMessage,
|
|
93
|
+
} from "./SelectionWizardHost";
|
|
93
94
|
import {
|
|
94
95
|
type OpenMessageOptions,
|
|
95
96
|
ThreadListInteraction,
|
|
@@ -293,7 +294,6 @@ interface BriefSelectionChromeProps {
|
|
|
293
294
|
>;
|
|
294
295
|
/** The rows the list is showing, for resolving the selection's scope. */
|
|
295
296
|
rows: readonly ThreadRowData[];
|
|
296
|
-
isDesktop: boolean;
|
|
297
297
|
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
298
298
|
children: ReactNode;
|
|
299
299
|
}
|
|
@@ -307,7 +307,6 @@ interface BriefSelectionChromeProps {
|
|
|
307
307
|
function BriefSelectionChrome({
|
|
308
308
|
header,
|
|
309
309
|
rows,
|
|
310
|
-
isDesktop,
|
|
311
310
|
onMarkMessagesRead,
|
|
312
311
|
children,
|
|
313
312
|
}: BriefSelectionChromeProps) {
|
|
@@ -315,7 +314,6 @@ function BriefSelectionChrome({
|
|
|
315
314
|
selectedIds,
|
|
316
315
|
selectedCount,
|
|
317
316
|
exitSelection,
|
|
318
|
-
requestDeleteSelection,
|
|
319
317
|
orderedIds,
|
|
320
318
|
allSelected,
|
|
321
319
|
toggleAllLoaded,
|
|
@@ -327,57 +325,40 @@ function BriefSelectionChrome({
|
|
|
327
325
|
);
|
|
328
326
|
const { junkMailboxId } = useJunkMailbox(scope.accountId);
|
|
329
327
|
const { labels } = useLabelList(scope.accountId);
|
|
330
|
-
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
331
|
-
mailboxId: scope.mailboxId ?? "",
|
|
332
|
-
accountId: scope.accountId,
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
336
|
-
const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
|
|
337
|
-
"select-similar" | "something-else" | null
|
|
338
|
-
>(null);
|
|
339
328
|
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
329
|
+
const openWizard = useOpenWizard();
|
|
330
|
+
// The verb the bar was pressed for. The wizard is open for as long as the URL
|
|
331
|
+
// holds a step, so a back that pops the last one closes it.
|
|
332
|
+
const [wizardVerb, setWizardVerb] = useState<Verb>("organize");
|
|
333
|
+
const startWizard = useCallback(
|
|
334
|
+
(verb: Verb) => {
|
|
335
|
+
setWizardVerb(verb);
|
|
336
|
+
openWizard("match");
|
|
337
|
+
},
|
|
338
|
+
[openWizard],
|
|
339
|
+
);
|
|
347
340
|
|
|
348
341
|
const selectedMessageIds = useMemo(
|
|
349
342
|
() => Array.from(selectedIds),
|
|
350
343
|
[selectedIds],
|
|
351
344
|
);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
(destinationMailboxId: string) => {
|
|
367
|
-
if (selectedMessageIds.length === 0) return;
|
|
368
|
-
moveMessages(selectedMessageIds, destinationMailboxId);
|
|
369
|
-
exitSelection();
|
|
370
|
-
},
|
|
371
|
-
[moveMessages, selectedMessageIds, exitSelection],
|
|
345
|
+
// The ticked rows as the wizard reads them — the sample under every screen
|
|
346
|
+
// that names a match, and the senders its widen falls back to.
|
|
347
|
+
const wizardSelection = useMemo<WizardSelectionMessage[]>(
|
|
348
|
+
() =>
|
|
349
|
+
rows
|
|
350
|
+
.filter((row) => selectedIds.has(row.id))
|
|
351
|
+
.map((row) => ({
|
|
352
|
+
id: row.id,
|
|
353
|
+
sender: row.fromName,
|
|
354
|
+
email: row.fromEmail,
|
|
355
|
+
subject: row.subject,
|
|
356
|
+
date: row.timeLabel,
|
|
357
|
+
})),
|
|
358
|
+
[rows, selectedIds],
|
|
372
359
|
);
|
|
373
360
|
|
|
374
|
-
const
|
|
375
|
-
if (junkMailboxId) handleMove(junkMailboxId);
|
|
376
|
-
}, [junkMailboxId, handleMove]);
|
|
377
|
-
|
|
378
|
-
const scoped = !!scope.accountId && !!scope.mailboxId;
|
|
379
|
-
const canJunk =
|
|
380
|
-
scoped && !!junkMailboxId && junkMailboxId !== scope.mailboxId;
|
|
361
|
+
const canJunk = !!junkMailboxId && junkMailboxId !== scope.mailboxId;
|
|
381
362
|
|
|
382
363
|
// One select-all for both surfaces: the desktop toolbar and the touch sheet
|
|
383
364
|
// offer the same control over the same rendered rows, so the verb a phone
|
|
@@ -410,31 +391,12 @@ function BriefSelectionChrome({
|
|
|
410
391
|
idleSlot={chrome.makeFilterSlot}
|
|
411
392
|
count={selectedCount}
|
|
412
393
|
onCancel={exitSelection}
|
|
413
|
-
onDelete={
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
: setMobileOrganizeEntry("select-similar")
|
|
420
|
-
: undefined
|
|
421
|
-
}
|
|
422
|
-
onJunk={canJunk ? handleJunk : undefined}
|
|
423
|
-
onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
|
|
424
|
-
onSomethingElse={
|
|
425
|
-
scoped && !isDesktop
|
|
426
|
-
? () => setMobileOrganizeEntry("something-else")
|
|
427
|
-
: undefined
|
|
428
|
-
}
|
|
429
|
-
moveSlot={
|
|
430
|
-
scoped && scope.accountId && scope.mailboxId ? (
|
|
431
|
-
<MoveToTrigger
|
|
432
|
-
accountId={scope.accountId}
|
|
433
|
-
currentMailboxId={scope.mailboxId}
|
|
434
|
-
onMove={isMoving ? () => {} : handleMove}
|
|
435
|
-
label="Move selected messages"
|
|
436
|
-
/>
|
|
437
|
-
) : undefined
|
|
394
|
+
onDelete={() => startWizard("delete")}
|
|
395
|
+
onMove={() => startWizard("move")}
|
|
396
|
+
onOrganize={() => startWizard("organize")}
|
|
397
|
+
onJunk={canJunk ? () => startWizard("junk") : undefined}
|
|
398
|
+
onMarkRead={
|
|
399
|
+
onMarkMessagesRead ? () => startWizard("markRead") : undefined
|
|
438
400
|
}
|
|
439
401
|
overflowSlot={
|
|
440
402
|
scope.accountId &&
|
|
@@ -448,49 +410,33 @@ function BriefSelectionChrome({
|
|
|
448
410
|
/>
|
|
449
411
|
) : undefined
|
|
450
412
|
}
|
|
451
|
-
isBusy={isMoving}
|
|
452
413
|
selectAll={selectAll}
|
|
453
414
|
notice={notice}
|
|
454
415
|
/>
|
|
455
416
|
);
|
|
456
417
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
<MobileOrganizeFlow
|
|
463
|
-
entry={mobileOrganizeEntry}
|
|
464
|
-
accountId={scope.accountId}
|
|
465
|
-
selectedMessageIds={selectedMessageIds}
|
|
466
|
-
selectedSenders={selectedSenders}
|
|
467
|
-
junkMailboxId={junkMailboxId}
|
|
468
|
-
onClose={() => {
|
|
469
|
-
setMobileOrganizeEntry(null);
|
|
470
|
-
exitSelection();
|
|
471
|
-
}}
|
|
472
|
-
/>
|
|
473
|
-
) : undefined;
|
|
474
|
-
|
|
418
|
+
// Mounted as the pane's overlay rather than beside the header: the brief hands
|
|
419
|
+
// its body over to the search results panel while a query is being typed, and
|
|
420
|
+
// the wizard is not part of the body it hands over. The overlay slot also puts
|
|
421
|
+
// it inside the list header's chrome, which is where the converted query it
|
|
422
|
+
// opens on comes from.
|
|
475
423
|
return (
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
{children}
|
|
483
|
-
</MailListHeader>
|
|
484
|
-
{organizeOpen && scope.accountId && (
|
|
485
|
-
<OrganizeDialog
|
|
486
|
-
open={organizeOpen}
|
|
424
|
+
<MailListHeader
|
|
425
|
+
{...header}
|
|
426
|
+
selectionBar={selectionBar}
|
|
427
|
+
paneOverlay={
|
|
428
|
+
<SelectionWizardHost
|
|
429
|
+
verb={wizardVerb}
|
|
487
430
|
accountId={scope.accountId}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
431
|
+
mailboxId={scope.mailboxId}
|
|
432
|
+
selection={wizardSelection}
|
|
433
|
+
crossAccount={scope.moveDisabledHint !== undefined}
|
|
434
|
+
onFinished={exitSelection}
|
|
491
435
|
/>
|
|
492
|
-
|
|
493
|
-
|
|
436
|
+
}
|
|
437
|
+
>
|
|
438
|
+
{children}
|
|
439
|
+
</MailListHeader>
|
|
494
440
|
);
|
|
495
441
|
}
|
|
496
442
|
|
|
@@ -850,7 +796,6 @@ export function DailyBrief({
|
|
|
850
796
|
onSelectSearchResult,
|
|
851
797
|
}}
|
|
852
798
|
rows={filteredRows}
|
|
853
|
-
isDesktop={isDesktop}
|
|
854
799
|
onMarkMessagesRead={onMarkMessagesRead}
|
|
855
800
|
>
|
|
856
801
|
<div className="flex h-full flex-col">
|
|
@@ -38,10 +38,15 @@ import { dedupeByThread } from "@/lib/starred-rows";
|
|
|
38
38
|
import { MailViewChrome } from "./MailViewChrome";
|
|
39
39
|
import type { MessageListCommands } from "./MessageList";
|
|
40
40
|
import { MessageRow } from "./MessageRow";
|
|
41
|
+
import {
|
|
42
|
+
SelectionWizardHost,
|
|
43
|
+
type WizardSelectionMessage,
|
|
44
|
+
} from "./SelectionWizardHost";
|
|
41
45
|
import {
|
|
42
46
|
type OpenMessageOptions,
|
|
43
47
|
ThreadListInteraction,
|
|
44
48
|
ThreadListSelectionBar,
|
|
49
|
+
useThreadListSelection,
|
|
45
50
|
} from "./ThreadListInteraction";
|
|
46
51
|
|
|
47
52
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
@@ -49,6 +54,42 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
49
54
|
attachment: (t) => t.hasAttachment === true,
|
|
50
55
|
};
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The wizard, on the one surface whose bar borrows it rather than raising it.
|
|
59
|
+
*
|
|
60
|
+
* The list header offers "make this a filter" wherever a search is active, and
|
|
61
|
+
* the step that affordance pushes has to be answered on the view that offered
|
|
62
|
+
* it — an affordance whose press lands on nothing is the dead button clause 1.7
|
|
63
|
+
* exists to prevent. Starred spans accounts and mailboxes, so a rule made from
|
|
64
|
+
* the ticked rows has no single account to belong to; a rule made from the
|
|
65
|
+
* query belongs to the account the query names, which the host reads for
|
|
66
|
+
* itself.
|
|
67
|
+
*/
|
|
68
|
+
function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
|
|
69
|
+
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
70
|
+
const selection = useMemo<WizardSelectionMessage[]>(
|
|
71
|
+
() =>
|
|
72
|
+
rows
|
|
73
|
+
.filter((row) => selectedIds.has(row.id))
|
|
74
|
+
.map((row) => ({
|
|
75
|
+
id: row.id,
|
|
76
|
+
sender: row.fromName,
|
|
77
|
+
email: row.fromEmail,
|
|
78
|
+
subject: row.subject,
|
|
79
|
+
date: row.timeLabel,
|
|
80
|
+
})),
|
|
81
|
+
[rows, selectedIds],
|
|
82
|
+
);
|
|
83
|
+
return (
|
|
84
|
+
<SelectionWizardHost
|
|
85
|
+
verb="organize"
|
|
86
|
+
selection={selection}
|
|
87
|
+
crossAccount
|
|
88
|
+
onFinished={exitSelection}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
interface FlaggedListProps {
|
|
53
94
|
selectedMessageId?: string;
|
|
54
95
|
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
@@ -221,6 +262,7 @@ export function FlaggedList({
|
|
|
221
262
|
(listState === "ready" ? listBody : undefined)
|
|
222
263
|
}
|
|
223
264
|
/>
|
|
265
|
+
<StarredWizardHost rows={rows} />
|
|
224
266
|
</ThreadListInteraction>
|
|
225
267
|
</MailViewChrome>
|
|
226
268
|
);
|
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
searchTokenLabel,
|
|
87
87
|
} from "@/lib/search-tokens";
|
|
88
88
|
import { spamOfferForResults } from "@/lib/spam-offer";
|
|
89
|
-
import {
|
|
89
|
+
import { useOpenWizard } from "@/lib/wizard-history";
|
|
90
90
|
|
|
91
91
|
export interface MailListHeaderProps {
|
|
92
92
|
title: string;
|
|
@@ -102,8 +102,11 @@ export interface MailListHeaderProps {
|
|
|
102
102
|
*/
|
|
103
103
|
selectionBar?: (chrome: ListHeaderChrome) => ReactNode;
|
|
104
104
|
/**
|
|
105
|
-
* An overlay covering the pane, above the list (the
|
|
106
|
-
*
|
|
105
|
+
* An overlay covering the pane, above the list (the selection wizard). Kept
|
|
106
|
+
* out of the body and rendered on both the ordinary layout and the phone
|
|
107
|
+
* search takeover, so a surface that covers the screen is not taken down by
|
|
108
|
+
* whatever the body swapped to underneath it. Inside the chrome provider, so
|
|
109
|
+
* an overlay a view mounts from above still reads the search state.
|
|
107
110
|
*/
|
|
108
111
|
paneOverlay?: ReactNode;
|
|
109
112
|
/** Pinned below the scrollable list (e.g. the keyboard hint bar). */
|
|
@@ -169,7 +172,7 @@ export function MailListHeader({
|
|
|
169
172
|
const tier = useLayoutTier();
|
|
170
173
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
171
174
|
const [recentSearches, setRecentSearches] = useState(loadRecentSearches);
|
|
172
|
-
const
|
|
175
|
+
const openWizard = useOpenWizard();
|
|
173
176
|
|
|
174
177
|
// Leaving the view ends the search: the shell drops the query, and the chrome
|
|
175
178
|
// it opened — the phone takeover, the expanded tablet field — closes with it
|
|
@@ -320,14 +323,15 @@ export function MailListHeader({
|
|
|
320
323
|
}
|
|
321
324
|
: routeScope;
|
|
322
325
|
|
|
323
|
-
// Make-this-a-filter (
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
326
|
+
// Make-this-a-filter (#477 clause 1.8): the search is the wizard's second
|
|
327
|
+
// entry. It opens on the properties step with the clauses `convertSearchToRule`
|
|
328
|
+
// derives from the query, nothing ticked, and the notice for what the query
|
|
329
|
+
// could not be turned into. The conversion is computed once, here, and handed
|
|
330
|
+
// to the wizard through the chrome — the reason the affordance gives and the
|
|
331
|
+
// clauses the wizard seeds from are the same answer. The literal filter cannot
|
|
332
|
+
// reproduce the search's semantic reach, so the conversion states it whenever
|
|
333
|
+
// the search surfaced a "Related" section — a direct signal, read here from
|
|
334
|
+
// the semantic results, never a capability probe.
|
|
331
335
|
//
|
|
332
336
|
// It belongs to the search, not to any one way of showing it. The affordance
|
|
333
337
|
// therefore sits in the pane, above whichever body is up — the read-only
|
|
@@ -338,35 +342,32 @@ export function MailListHeader({
|
|
|
338
342
|
// the URL, taking the affordance down with it a few hundred milliseconds after
|
|
339
343
|
// it appeared, and leaving the brief (which keeps the panel for any query) as
|
|
340
344
|
// the only place it survived.
|
|
341
|
-
const accountToken = parsed.tokens.find((token) => token.type === "account");
|
|
342
|
-
const targetAccountId = accountToken?.accountId ?? accounts[0]?.accountId;
|
|
343
345
|
const searchHadSemanticReach = related.length > 0;
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
346
|
+
const conversion = useMemo(
|
|
347
|
+
() => convertSearchToRule(parsed, { searchHadSemanticReach }),
|
|
348
|
+
[parsed, searchHadSemanticReach],
|
|
349
|
+
);
|
|
350
|
+
const searchConversion = hasQuery ? conversion : undefined;
|
|
351
|
+
const makeFilter = hasQuery
|
|
352
|
+
? {
|
|
353
|
+
// The wizard is a full-screen surface, so the phone takeover it was
|
|
354
|
+
// pressed from stands down rather than sitting under it — and the list
|
|
355
|
+
// underneath, which hosts the wizard, is mounted again by the time the
|
|
356
|
+
// step lands.
|
|
357
|
+
onClick: () => {
|
|
358
|
+
setSearchOpen(false);
|
|
359
|
+
openWizard("properties", "search");
|
|
360
|
+
},
|
|
361
|
+
blockedReason: isConvertible(conversion)
|
|
362
|
+
? undefined
|
|
363
|
+
: "Add a sender or words to filter on",
|
|
364
|
+
}
|
|
365
|
+
: undefined;
|
|
355
366
|
// Handed to the bar rather than rendered here: the bar knows whether rows
|
|
356
367
|
// are ticked, and a selection's own verbs own the surface while they are up.
|
|
357
368
|
const makeFilterAction = makeFilter ? (
|
|
358
369
|
<MakeFilterAction {...makeFilter} />
|
|
359
370
|
) : null;
|
|
360
|
-
const filterDialog =
|
|
361
|
-
filterOpen && targetAccountId ? (
|
|
362
|
-
<SearchFilterDialog
|
|
363
|
-
open={filterOpen}
|
|
364
|
-
accountId={targetAccountId}
|
|
365
|
-
parsed={parsed}
|
|
366
|
-
searchHadSemanticReach={searchHadSemanticReach}
|
|
367
|
-
onClose={() => setFilterOpen(false)}
|
|
368
|
-
/>
|
|
369
|
-
) : null;
|
|
370
371
|
|
|
371
372
|
// Tablet + desktop keep the inline toolbar search; while a query is being
|
|
372
373
|
// typed the list-pane body swaps to the same sectioned results the phone
|
|
@@ -421,6 +422,7 @@ export function MailListHeader({
|
|
|
421
422
|
title,
|
|
422
423
|
searchResults: chromeResults,
|
|
423
424
|
makeFilterSlot: makeFilterAction,
|
|
425
|
+
searchConversion,
|
|
424
426
|
navSlot: layout && !layout.showNavPane && (
|
|
425
427
|
<Button
|
|
426
428
|
variant="ghost"
|
|
@@ -484,6 +486,7 @@ export function MailListHeader({
|
|
|
484
486
|
searchSuggest,
|
|
485
487
|
chromeResults,
|
|
486
488
|
makeFilterAction,
|
|
489
|
+
searchConversion,
|
|
487
490
|
],
|
|
488
491
|
);
|
|
489
492
|
|
|
@@ -494,7 +497,7 @@ export function MailListHeader({
|
|
|
494
497
|
onSelectSearchResult?.(result);
|
|
495
498
|
};
|
|
496
499
|
return (
|
|
497
|
-
|
|
500
|
+
<ListHeaderChromeContext.Provider value={chrome}>
|
|
498
501
|
<MobileSearchView
|
|
499
502
|
value={searchInput}
|
|
500
503
|
onChange={onSearchChange}
|
|
@@ -515,8 +518,8 @@ export function MailListHeader({
|
|
|
515
518
|
suggest={searchSuggest}
|
|
516
519
|
suggestList={suggestList}
|
|
517
520
|
/>
|
|
518
|
-
{
|
|
519
|
-
|
|
521
|
+
{paneOverlay}
|
|
522
|
+
</ListHeaderChromeContext.Provider>
|
|
520
523
|
);
|
|
521
524
|
}
|
|
522
525
|
|
|
@@ -527,7 +530,6 @@ export function MailListHeader({
|
|
|
527
530
|
{suggestList}
|
|
528
531
|
<div className="min-h-0 flex-1">{body}</div>
|
|
529
532
|
{footer}
|
|
530
|
-
{filterDialog}
|
|
531
533
|
{paneOverlay}
|
|
532
534
|
</section>
|
|
533
535
|
</ListHeaderChromeContext.Provider>
|
|
@@ -78,8 +78,9 @@ describe("MessageList escalated actions", () => {
|
|
|
78
78
|
|
|
79
79
|
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
80
80
|
// The bar always carries the mark-read verb (it drops it itself while
|
|
81
|
-
// counting or busy); an escalated selection must never lose it
|
|
82
|
-
|
|
81
|
+
// counting or busy); an escalated selection must never lose it — it falls
|
|
82
|
+
// back to the predicate run, which the wizard cannot take.
|
|
83
|
+
assert.match(source, /: handleMarkAsRead\s*\}/);
|
|
83
84
|
// The move slot is offered for a bounded selection or an escalated one —
|
|
84
85
|
// #114's rule that an escalated selection is never delete-only.
|
|
85
86
|
assert.match(
|
|
@@ -142,9 +143,13 @@ describe("MessageList escalation reaches desktop (#212)", () => {
|
|
|
142
143
|
assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
|
|
143
144
|
});
|
|
144
145
|
|
|
145
|
-
it("
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
it("sends every verb on the bar into the wizard (#477 1.4)", () => {
|
|
147
|
+
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
148
|
+
for (const verb of ["delete", "move", "junk", "markRead", "organize"]) {
|
|
149
|
+
assert.match(bar, new RegExp(`startWizard\\("${verb}"\\)`));
|
|
150
|
+
}
|
|
151
|
+
// The one selection surface has no second organize entry beside them.
|
|
152
|
+
assert.doesNotMatch(source, /onSomethingElse/);
|
|
148
153
|
});
|
|
149
154
|
|
|
150
155
|
it("offers the label picker only when the account has labels", () => {
|