@remit/web-client 0.0.93 → 0.0.95
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/BriefPane.selection.test.ts +0 -1
- package/src/components/mail/BriefPane.tsx +7 -17
- package/src/components/mail/DailyBrief.selection.test.ts +21 -3
- package/src/components/mail/DailyBrief.tsx +37 -39
- package/src/components/mail/FlaggedList.tsx +55 -8
- package/src/components/mail/FlaggedPane.tsx +9 -23
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MailboxPane.tsx +24 -11
- package/src/components/mail/MessageList.selection.test.ts +44 -30
- package/src/components/mail/MessageList.tsx +117 -339
- package/src/components/mail/SelectionWizardHost.tsx +265 -48
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/mail/selection-verbs.test.ts +278 -0
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -15
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/hooks/useMatchSample.ts +44 -3
- package/src/hooks/useRulePreview.ts +1 -2
- package/src/lib/bulk-action-copy.test.ts +0 -19
- package/src/lib/bulk-action-copy.ts +0 -8
- package/src/lib/bulk-actions.test.ts +0 -60
- package/src/lib/bulk-actions.ts +0 -26
- package/src/lib/format.test.ts +0 -27
- package/src/lib/format.ts +4 -14
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/organize/organize-model.ts +8 -2
- package/src/lib/organize/rule-model.ts +0 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +97 -9
- package/src/routes/mail.tsx +3 -1
- 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 -140
- 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/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.95",
|
|
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,6 +47,5 @@ describe("BriefPane phone selection surface (#203)", () => {
|
|
|
47
47
|
const next = source.indexOf("\nfunction ", start + 1);
|
|
48
48
|
const body = source.slice(start, next === -1 ? undefined : next);
|
|
49
49
|
assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
|
|
50
|
-
assert.match(body, /onMarkMessagesRead=\{onMarkMessagesRead\}/);
|
|
51
50
|
});
|
|
52
51
|
});
|
|
@@ -68,7 +68,6 @@ interface BriefPaneContextValue {
|
|
|
68
68
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
69
69
|
triage: TriageContext;
|
|
70
70
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
71
|
-
onMarkMessagesRead: (messageIds: string[]) => void;
|
|
72
71
|
nextMessageId: string | undefined;
|
|
73
72
|
previousMessageId: string | undefined;
|
|
74
73
|
}
|
|
@@ -206,10 +205,6 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
206
205
|
mailboxId: selectedThread?.mailboxId ?? "",
|
|
207
206
|
messages: briefThreads,
|
|
208
207
|
});
|
|
209
|
-
const handleMarkMessagesRead = useCallback(
|
|
210
|
-
(messageIds: string[]) => toggleReadFor(messageIds, true),
|
|
211
|
-
[toggleReadFor],
|
|
212
|
-
);
|
|
213
208
|
|
|
214
209
|
const focusedThreadId = triage.focusedMessageId;
|
|
215
210
|
const focusedThread = useMemo(
|
|
@@ -228,20 +223,18 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
228
223
|
reply: () => actions.requestCompose("reply"),
|
|
229
224
|
replyAll: () => actions.requestCompose("reply_all"),
|
|
230
225
|
forward: () => actions.requestCompose("forward"),
|
|
226
|
+
// The list takes any verb aimed at its selection and opens the wizard
|
|
227
|
+
// on it, so a shortcut can never reach a bulk action the bar would have
|
|
228
|
+
// reviewed. What comes back here is a verb aimed at the bare cursor.
|
|
231
229
|
delete: () => {
|
|
232
|
-
if (triage.listCommandsRef.current?.
|
|
230
|
+
if (triage.listCommandsRef.current?.requestVerb("delete")) return;
|
|
233
231
|
triageActions.deleteThread();
|
|
234
232
|
},
|
|
235
233
|
toggleStar: triageActions.toggleStar,
|
|
236
234
|
toggleRead: () => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
: triageTarget
|
|
241
|
-
? [triageTarget.messageId]
|
|
242
|
-
: [];
|
|
243
|
-
if (ids.length === 0) return;
|
|
244
|
-
toggleReadFor(ids, !(triageTarget?.isRead ?? false));
|
|
235
|
+
if (triage.listCommandsRef.current?.requestVerb("markRead")) return;
|
|
236
|
+
if (!triageTarget) return;
|
|
237
|
+
toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
|
|
245
238
|
},
|
|
246
239
|
goFlagged: () => navigate({ to: "/mail/flagged" }),
|
|
247
240
|
goSettings: () => navigate({ to: "/settings" }),
|
|
@@ -258,7 +251,6 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
258
251
|
actions,
|
|
259
252
|
triage,
|
|
260
253
|
onDeleteMessages: deleteMessages,
|
|
261
|
-
onMarkMessagesRead: handleMarkMessagesRead,
|
|
262
254
|
nextMessageId,
|
|
263
255
|
previousMessageId,
|
|
264
256
|
};
|
|
@@ -280,7 +272,6 @@ function BriefList() {
|
|
|
280
272
|
onSelectSearchResult,
|
|
281
273
|
triage,
|
|
282
274
|
onDeleteMessages,
|
|
283
|
-
onMarkMessagesRead,
|
|
284
275
|
} = useBriefPane();
|
|
285
276
|
const { accounts } = useMailContext();
|
|
286
277
|
|
|
@@ -293,7 +284,6 @@ function BriefList() {
|
|
|
293
284
|
commandsRef={triage.listCommandsRef}
|
|
294
285
|
onTriageContextChange={triage.onTriageContextChange}
|
|
295
286
|
onDeleteMessages={onDeleteMessages}
|
|
296
|
-
onMarkMessagesRead={onMarkMessagesRead}
|
|
297
287
|
/>
|
|
298
288
|
);
|
|
299
289
|
}
|
|
@@ -47,11 +47,29 @@ describe("the brief's select-all", () => {
|
|
|
47
47
|
);
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it("
|
|
50
|
+
it("carries every verb the bar can offer (#477 1.4)", () => {
|
|
51
|
+
// That each of them opens the wizard is `selection-verbs.test.ts`, over
|
|
52
|
+
// every surface at once. What this pins is that none of them was dropped
|
|
53
|
+
// from the bar the brief raises.
|
|
51
54
|
const body = chromeBody();
|
|
52
|
-
for (const
|
|
53
|
-
|
|
55
|
+
for (const prop of [
|
|
56
|
+
"onDelete",
|
|
57
|
+
"onMove",
|
|
58
|
+
"onOrganize",
|
|
59
|
+
"onJunk",
|
|
60
|
+
"onMarkRead",
|
|
61
|
+
]) {
|
|
62
|
+
assert.match(body, new RegExp(`${prop}=`));
|
|
54
63
|
}
|
|
55
64
|
assert.doesNotMatch(body, /onSomethingElse/);
|
|
56
65
|
});
|
|
66
|
+
|
|
67
|
+
it("drives the wizard and the keyboard layer from one control", () => {
|
|
68
|
+
// Two copies of "which verb is the wizard on" is how the brief came to
|
|
69
|
+
// offer a verb the mailbox list did not, and how its keyboard came to act
|
|
70
|
+
// behind a wizard the bar had opened.
|
|
71
|
+
assert.match(source, /const wizard = useSelectionWizard\(\);/);
|
|
72
|
+
assert.match(source, /onSelectionVerb={wizard.start}/);
|
|
73
|
+
assert.match(source, /wizardOpen={wizard.isOpen}/);
|
|
74
|
+
});
|
|
57
75
|
});
|
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
SelectionTopBar,
|
|
47
47
|
type ThreadRowData,
|
|
48
48
|
type ThreadSection,
|
|
49
|
-
type Verb,
|
|
50
49
|
} from "@remit/ui";
|
|
51
50
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
52
51
|
import { useNavigate } from "@tanstack/react-router";
|
|
@@ -82,7 +81,10 @@ import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
82
81
|
import { useMailContext } from "@/lib/mail-context";
|
|
83
82
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
84
83
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
85
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
type SelectionWizardControl,
|
|
86
|
+
useSelectionWizard,
|
|
87
|
+
} from "@/lib/wizard-history";
|
|
86
88
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
87
89
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
88
90
|
import type { MessageListCommands } from "./MessageList";
|
|
@@ -294,7 +296,8 @@ interface BriefSelectionChromeProps {
|
|
|
294
296
|
>;
|
|
295
297
|
/** The rows the list is showing, for resolving the selection's scope. */
|
|
296
298
|
rows: readonly ThreadRowData[];
|
|
297
|
-
|
|
299
|
+
/** The wizard every verb on this bar opens, shared with the keyboard layer. */
|
|
300
|
+
wizard: SelectionWizardControl;
|
|
298
301
|
children: ReactNode;
|
|
299
302
|
}
|
|
300
303
|
|
|
@@ -307,7 +310,7 @@ interface BriefSelectionChromeProps {
|
|
|
307
310
|
function BriefSelectionChrome({
|
|
308
311
|
header,
|
|
309
312
|
rows,
|
|
310
|
-
|
|
313
|
+
wizard,
|
|
311
314
|
children,
|
|
312
315
|
}: BriefSelectionChromeProps) {
|
|
313
316
|
const {
|
|
@@ -326,18 +329,6 @@ function BriefSelectionChrome({
|
|
|
326
329
|
const { junkMailboxId } = useJunkMailbox(scope.accountId);
|
|
327
330
|
const { labels } = useLabelList(scope.accountId);
|
|
328
331
|
|
|
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
|
-
);
|
|
340
|
-
|
|
341
332
|
const selectedMessageIds = useMemo(
|
|
342
333
|
() => Array.from(selectedIds),
|
|
343
334
|
[selectedIds],
|
|
@@ -391,13 +382,11 @@ function BriefSelectionChrome({
|
|
|
391
382
|
idleSlot={chrome.makeFilterSlot}
|
|
392
383
|
count={selectedCount}
|
|
393
384
|
onCancel={exitSelection}
|
|
394
|
-
onDelete={() =>
|
|
395
|
-
onMove={() =>
|
|
396
|
-
onOrganize={() =>
|
|
397
|
-
onJunk={canJunk ? () =>
|
|
398
|
-
onMarkRead={
|
|
399
|
-
onMarkMessagesRead ? () => startWizard("markRead") : undefined
|
|
400
|
-
}
|
|
385
|
+
onDelete={() => wizard.start("delete")}
|
|
386
|
+
onMove={() => wizard.start("move")}
|
|
387
|
+
onOrganize={() => wizard.start("organize")}
|
|
388
|
+
onJunk={canJunk ? () => wizard.start("junk") : undefined}
|
|
389
|
+
onMarkRead={() => wizard.start("markRead")}
|
|
401
390
|
overflowSlot={
|
|
402
391
|
scope.accountId &&
|
|
403
392
|
scope.mailboxId &&
|
|
@@ -415,20 +404,28 @@ function BriefSelectionChrome({
|
|
|
415
404
|
/>
|
|
416
405
|
);
|
|
417
406
|
|
|
407
|
+
// Mounted as the pane's overlay rather than beside the header: the brief hands
|
|
408
|
+
// its body over to the search results panel while a query is being typed, and
|
|
409
|
+
// the wizard is not part of the body it hands over. The overlay slot also puts
|
|
410
|
+
// it inside the list header's chrome, which is where the converted query it
|
|
411
|
+
// opens on comes from.
|
|
418
412
|
return (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
413
|
+
<MailListHeader
|
|
414
|
+
{...header}
|
|
415
|
+
selectionBar={selectionBar}
|
|
416
|
+
paneOverlay={
|
|
417
|
+
<SelectionWizardHost
|
|
418
|
+
verb={wizard.verb}
|
|
419
|
+
accountId={scope.accountId}
|
|
420
|
+
mailboxId={scope.mailboxId}
|
|
421
|
+
selection={wizardSelection}
|
|
422
|
+
crossAccount={scope.moveDisabledHint !== undefined}
|
|
423
|
+
onFinished={exitSelection}
|
|
424
|
+
/>
|
|
425
|
+
}
|
|
426
|
+
>
|
|
427
|
+
{children}
|
|
428
|
+
</MailListHeader>
|
|
432
429
|
);
|
|
433
430
|
}
|
|
434
431
|
|
|
@@ -451,7 +448,6 @@ interface DailyBriefProps {
|
|
|
451
448
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
452
449
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
453
450
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
454
|
-
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
455
451
|
}
|
|
456
452
|
|
|
457
453
|
export function DailyBrief({
|
|
@@ -462,11 +458,11 @@ export function DailyBrief({
|
|
|
462
458
|
commandsRef,
|
|
463
459
|
onTriageContextChange,
|
|
464
460
|
onDeleteMessages,
|
|
465
|
-
onMarkMessagesRead,
|
|
466
461
|
}: DailyBriefProps) {
|
|
467
462
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
468
463
|
const tokenContext = useSearchTokenContext();
|
|
469
464
|
const isDesktop = useIsDesktop();
|
|
465
|
+
const wizard = useSelectionWizard();
|
|
470
466
|
|
|
471
467
|
const nonMuted = useMemo(
|
|
472
468
|
() => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
|
|
@@ -772,10 +768,13 @@ export function DailyBrief({
|
|
|
772
768
|
selectedMessageId={selectedMessageId}
|
|
773
769
|
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
774
770
|
onDeleteMessages={onDeleteMessages}
|
|
771
|
+
onSelectionVerb={wizard.start}
|
|
772
|
+
wizardOpen={wizard.isOpen}
|
|
775
773
|
commandsRef={commandsRef}
|
|
776
774
|
onTriageContextChange={onTriageContextChange}
|
|
777
775
|
>
|
|
778
776
|
<BriefSelectionChrome
|
|
777
|
+
wizard={wizard}
|
|
779
778
|
header={{
|
|
780
779
|
title: "Daily brief",
|
|
781
780
|
unreadCount: totalUnseen,
|
|
@@ -788,7 +787,6 @@ export function DailyBrief({
|
|
|
788
787
|
onSelectSearchResult,
|
|
789
788
|
}}
|
|
790
789
|
rows={filteredRows}
|
|
791
|
-
onMarkMessagesRead={onMarkMessagesRead}
|
|
792
790
|
>
|
|
793
791
|
<div className="flex h-full flex-col">
|
|
794
792
|
{failedAccounts.map((account) => (
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
flaggedFilterConfig,
|
|
18
18
|
MessageListPane,
|
|
19
19
|
type ThreadRowData,
|
|
20
|
+
type Verb,
|
|
20
21
|
} from "@remit/ui";
|
|
21
22
|
import { type RefObject, useCallback, useMemo, useState } from "react";
|
|
22
23
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
@@ -35,13 +36,19 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
35
36
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
36
37
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
37
38
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
39
|
+
import { useSelectionWizard } from "@/lib/wizard-history";
|
|
38
40
|
import { MailViewChrome } from "./MailViewChrome";
|
|
39
41
|
import type { MessageListCommands } from "./MessageList";
|
|
40
42
|
import { MessageRow } from "./MessageRow";
|
|
43
|
+
import {
|
|
44
|
+
SelectionWizardHost,
|
|
45
|
+
type WizardSelectionMessage,
|
|
46
|
+
} from "./SelectionWizardHost";
|
|
41
47
|
import {
|
|
42
48
|
type OpenMessageOptions,
|
|
43
49
|
ThreadListInteraction,
|
|
44
50
|
ThreadListSelectionBar,
|
|
51
|
+
useThreadListSelection,
|
|
45
52
|
} from "./ThreadListInteraction";
|
|
46
53
|
|
|
47
54
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
@@ -49,6 +56,49 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
49
56
|
attachment: (t) => t.hasAttachment === true,
|
|
50
57
|
};
|
|
51
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The wizard this view's verbs walk, and the one its search entry lands on.
|
|
61
|
+
*
|
|
62
|
+
* The bar's Delete and Mark read open it on the ticked rows, so a bulk action
|
|
63
|
+
* here is reviewed exactly as it is on the mailbox list. The list header also
|
|
64
|
+
* offers "make this a filter" wherever a search is active, and the step that
|
|
65
|
+
* affordance pushes has to be answered on the view that offered it — an
|
|
66
|
+
* affordance whose press lands on nothing is the dead button clause 1.7 exists
|
|
67
|
+
* to prevent. Starred spans accounts and mailboxes, so a rule made from the
|
|
68
|
+
* ticked rows has no single account to belong to; a rule made from the query
|
|
69
|
+
* belongs to the account the query names, which the host reads for itself.
|
|
70
|
+
*/
|
|
71
|
+
function StarredWizardHost({
|
|
72
|
+
rows,
|
|
73
|
+
verb,
|
|
74
|
+
}: {
|
|
75
|
+
rows: readonly ThreadRowData[];
|
|
76
|
+
verb: Verb;
|
|
77
|
+
}) {
|
|
78
|
+
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
79
|
+
const selection = useMemo<WizardSelectionMessage[]>(
|
|
80
|
+
() =>
|
|
81
|
+
rows
|
|
82
|
+
.filter((row) => selectedIds.has(row.id))
|
|
83
|
+
.map((row) => ({
|
|
84
|
+
id: row.id,
|
|
85
|
+
sender: row.fromName,
|
|
86
|
+
email: row.fromEmail,
|
|
87
|
+
subject: row.subject,
|
|
88
|
+
date: row.timeLabel,
|
|
89
|
+
})),
|
|
90
|
+
[rows, selectedIds],
|
|
91
|
+
);
|
|
92
|
+
return (
|
|
93
|
+
<SelectionWizardHost
|
|
94
|
+
verb={verb}
|
|
95
|
+
selection={selection}
|
|
96
|
+
crossAccount
|
|
97
|
+
onFinished={exitSelection}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
52
102
|
interface FlaggedListProps {
|
|
53
103
|
selectedMessageId?: string;
|
|
54
104
|
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
@@ -57,7 +107,6 @@ interface FlaggedListProps {
|
|
|
57
107
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
58
108
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
59
109
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
60
|
-
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
export function FlaggedList({
|
|
@@ -66,11 +115,11 @@ export function FlaggedList({
|
|
|
66
115
|
commandsRef,
|
|
67
116
|
onTriageContextChange,
|
|
68
117
|
onDeleteMessages,
|
|
69
|
-
onMarkMessagesRead,
|
|
70
118
|
}: FlaggedListProps) {
|
|
71
119
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
72
120
|
const tokenContext = useSearchTokenContext();
|
|
73
121
|
const isDesktop = useIsDesktop();
|
|
122
|
+
const wizard = useSelectionWizard();
|
|
74
123
|
|
|
75
124
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
76
125
|
const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
|
|
@@ -193,6 +242,8 @@ export function FlaggedList({
|
|
|
193
242
|
selectedMessageId={selectedMessageId}
|
|
194
243
|
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
195
244
|
onDeleteMessages={onDeleteMessages}
|
|
245
|
+
onSelectionVerb={wizard.start}
|
|
246
|
+
wizardOpen={wizard.isOpen}
|
|
196
247
|
commandsRef={commandsRef}
|
|
197
248
|
onTriageContextChange={onTriageContextChange}
|
|
198
249
|
>
|
|
@@ -210,17 +261,13 @@ export function FlaggedList({
|
|
|
210
261
|
onSelectThread={onSelectMessage}
|
|
211
262
|
onSelectBriefCategory={() => undefined}
|
|
212
263
|
isDesktop={isDesktop}
|
|
213
|
-
selectionBar={
|
|
214
|
-
<ThreadListSelectionBar
|
|
215
|
-
title="Starred"
|
|
216
|
-
onMarkAsRead={onMarkMessagesRead}
|
|
217
|
-
/>
|
|
218
|
-
}
|
|
264
|
+
selectionBar={<ThreadListSelectionBar title="Starred" />}
|
|
219
265
|
listBody={
|
|
220
266
|
chrome.searchResults ??
|
|
221
267
|
(listState === "ready" ? listBody : undefined)
|
|
222
268
|
}
|
|
223
269
|
/>
|
|
270
|
+
<StarredWizardHost rows={rows} verb={wizard.verb} />
|
|
224
271
|
</ThreadListInteraction>
|
|
225
272
|
</MailViewChrome>
|
|
226
273
|
);
|
|
@@ -64,7 +64,6 @@ interface FlaggedPaneContextValue {
|
|
|
64
64
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
65
65
|
triage: TriageContext;
|
|
66
66
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
67
|
-
onMarkMessagesRead: (messageIds: string[]) => void;
|
|
68
67
|
nextMessageId: string | undefined;
|
|
69
68
|
previousMessageId: string | undefined;
|
|
70
69
|
}
|
|
@@ -145,10 +144,6 @@ function FlaggedPaneProvider({
|
|
|
145
144
|
mailboxId: selectedThread?.mailboxId ?? "",
|
|
146
145
|
messages: threads,
|
|
147
146
|
});
|
|
148
|
-
const handleMarkMessagesRead = useCallback(
|
|
149
|
-
(messageIds: string[]) => toggleReadFor(messageIds, true),
|
|
150
|
-
[toggleReadFor],
|
|
151
|
-
);
|
|
152
147
|
|
|
153
148
|
const focusedThreadId = triage.focusedMessageId;
|
|
154
149
|
const focusedThread = useMemo(
|
|
@@ -167,20 +162,18 @@ function FlaggedPaneProvider({
|
|
|
167
162
|
reply: () => actions.requestCompose("reply"),
|
|
168
163
|
replyAll: () => actions.requestCompose("reply_all"),
|
|
169
164
|
forward: () => actions.requestCompose("forward"),
|
|
165
|
+
// The list takes any verb aimed at its selection and opens the wizard
|
|
166
|
+
// on it, so a shortcut can never reach a bulk action the bar would have
|
|
167
|
+
// reviewed. What comes back here is a verb aimed at the bare cursor.
|
|
170
168
|
delete: () => {
|
|
171
|
-
if (triage.listCommandsRef.current?.
|
|
169
|
+
if (triage.listCommandsRef.current?.requestVerb("delete")) return;
|
|
172
170
|
triageActions.deleteThread();
|
|
173
171
|
},
|
|
174
172
|
toggleStar: triageActions.toggleStar,
|
|
175
173
|
toggleRead: () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: triageTarget
|
|
180
|
-
? [triageTarget.messageId]
|
|
181
|
-
: [];
|
|
182
|
-
if (ids.length === 0) return;
|
|
183
|
-
toggleReadFor(ids, !(triageTarget?.isRead ?? false));
|
|
174
|
+
if (triage.listCommandsRef.current?.requestVerb("markRead")) return;
|
|
175
|
+
if (!triageTarget) return;
|
|
176
|
+
toggleReadFor([triageTarget.messageId], !triageTarget.isRead);
|
|
184
177
|
},
|
|
185
178
|
goBrief: () => navigate({ to: "/mail" }),
|
|
186
179
|
goSettings: () => navigate({ to: "/settings" }),
|
|
@@ -195,7 +188,6 @@ function FlaggedPaneProvider({
|
|
|
195
188
|
actions,
|
|
196
189
|
triage,
|
|
197
190
|
onDeleteMessages: deleteMessages,
|
|
198
|
-
onMarkMessagesRead: handleMarkMessagesRead,
|
|
199
191
|
nextMessageId,
|
|
200
192
|
previousMessageId,
|
|
201
193
|
};
|
|
@@ -211,13 +203,8 @@ function FlaggedPaneProvider({
|
|
|
211
203
|
|
|
212
204
|
/** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
|
|
213
205
|
function FlaggedListSlot() {
|
|
214
|
-
const {
|
|
215
|
-
|
|
216
|
-
onSelectMessage,
|
|
217
|
-
triage,
|
|
218
|
-
onDeleteMessages,
|
|
219
|
-
onMarkMessagesRead,
|
|
220
|
-
} = useFlaggedPane();
|
|
206
|
+
const { selectedMessageId, onSelectMessage, triage, onDeleteMessages } =
|
|
207
|
+
useFlaggedPane();
|
|
221
208
|
return (
|
|
222
209
|
<FlaggedList
|
|
223
210
|
selectedMessageId={selectedMessageId}
|
|
@@ -225,7 +212,6 @@ function FlaggedListSlot() {
|
|
|
225
212
|
commandsRef={triage.listCommandsRef}
|
|
226
213
|
onTriageContextChange={triage.onTriageContextChange}
|
|
227
214
|
onDeleteMessages={onDeleteMessages}
|
|
228
|
-
onMarkMessagesRead={onMarkMessagesRead}
|
|
229
215
|
/>
|
|
230
216
|
);
|
|
231
217
|
}
|
|
@@ -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>
|