@remit/web-client 0.0.94 → 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 +17 -27
- package/src/components/mail/FlaggedList.tsx +24 -19
- package/src/components/mail/FlaggedPane.tsx +9 -23
- 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 +187 -39
- package/src/components/mail/ThreadListInteraction.test.ts +74 -4
- package/src/components/mail/ThreadListInteraction.tsx +58 -50
- 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/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/organize/organize-model.ts +8 -2
- package/src/lib/wizard-history.ts +42 -2
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 &&
|
|
@@ -426,7 +415,7 @@ function BriefSelectionChrome({
|
|
|
426
415
|
selectionBar={selectionBar}
|
|
427
416
|
paneOverlay={
|
|
428
417
|
<SelectionWizardHost
|
|
429
|
-
verb={
|
|
418
|
+
verb={wizard.verb}
|
|
430
419
|
accountId={scope.accountId}
|
|
431
420
|
mailboxId={scope.mailboxId}
|
|
432
421
|
selection={wizardSelection}
|
|
@@ -459,7 +448,6 @@ interface DailyBriefProps {
|
|
|
459
448
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
460
449
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
461
450
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
462
|
-
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
463
451
|
}
|
|
464
452
|
|
|
465
453
|
export function DailyBrief({
|
|
@@ -470,11 +458,11 @@ export function DailyBrief({
|
|
|
470
458
|
commandsRef,
|
|
471
459
|
onTriageContextChange,
|
|
472
460
|
onDeleteMessages,
|
|
473
|
-
onMarkMessagesRead,
|
|
474
461
|
}: DailyBriefProps) {
|
|
475
462
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
476
463
|
const tokenContext = useSearchTokenContext();
|
|
477
464
|
const isDesktop = useIsDesktop();
|
|
465
|
+
const wizard = useSelectionWizard();
|
|
478
466
|
|
|
479
467
|
const nonMuted = useMemo(
|
|
480
468
|
() => sortAccountsByCreatedAt(accounts.filter((a) => !a.muted?.value)),
|
|
@@ -780,10 +768,13 @@ export function DailyBrief({
|
|
|
780
768
|
selectedMessageId={selectedMessageId}
|
|
781
769
|
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
782
770
|
onDeleteMessages={onDeleteMessages}
|
|
771
|
+
onSelectionVerb={wizard.start}
|
|
772
|
+
wizardOpen={wizard.isOpen}
|
|
783
773
|
commandsRef={commandsRef}
|
|
784
774
|
onTriageContextChange={onTriageContextChange}
|
|
785
775
|
>
|
|
786
776
|
<BriefSelectionChrome
|
|
777
|
+
wizard={wizard}
|
|
787
778
|
header={{
|
|
788
779
|
title: "Daily brief",
|
|
789
780
|
unreadCount: totalUnseen,
|
|
@@ -796,7 +787,6 @@ export function DailyBrief({
|
|
|
796
787
|
onSelectSearchResult,
|
|
797
788
|
}}
|
|
798
789
|
rows={filteredRows}
|
|
799
|
-
onMarkMessagesRead={onMarkMessagesRead}
|
|
800
790
|
>
|
|
801
791
|
<div className="flex h-full flex-col">
|
|
802
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,6 +36,7 @@ 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";
|
|
@@ -55,17 +57,24 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
|
-
* The wizard
|
|
60
|
+
* The wizard this view's verbs walk, and the one its search entry lands on.
|
|
59
61
|
*
|
|
60
|
-
* The
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
67
70
|
*/
|
|
68
|
-
function StarredWizardHost({
|
|
71
|
+
function StarredWizardHost({
|
|
72
|
+
rows,
|
|
73
|
+
verb,
|
|
74
|
+
}: {
|
|
75
|
+
rows: readonly ThreadRowData[];
|
|
76
|
+
verb: Verb;
|
|
77
|
+
}) {
|
|
69
78
|
const { selectedIds, exitSelection } = useThreadListSelection();
|
|
70
79
|
const selection = useMemo<WizardSelectionMessage[]>(
|
|
71
80
|
() =>
|
|
@@ -82,7 +91,7 @@ function StarredWizardHost({ rows }: { rows: readonly ThreadRowData[] }) {
|
|
|
82
91
|
);
|
|
83
92
|
return (
|
|
84
93
|
<SelectionWizardHost
|
|
85
|
-
verb=
|
|
94
|
+
verb={verb}
|
|
86
95
|
selection={selection}
|
|
87
96
|
crossAccount
|
|
88
97
|
onFinished={exitSelection}
|
|
@@ -98,7 +107,6 @@ interface FlaggedListProps {
|
|
|
98
107
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
99
108
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
100
109
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
101
|
-
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
export function FlaggedList({
|
|
@@ -107,11 +115,11 @@ export function FlaggedList({
|
|
|
107
115
|
commandsRef,
|
|
108
116
|
onTriageContextChange,
|
|
109
117
|
onDeleteMessages,
|
|
110
|
-
onMarkMessagesRead,
|
|
111
118
|
}: FlaggedListProps) {
|
|
112
119
|
const { searchQuery, resultFolderIndex } = useMailContext();
|
|
113
120
|
const tokenContext = useSearchTokenContext();
|
|
114
121
|
const isDesktop = useIsDesktop();
|
|
122
|
+
const wizard = useSelectionWizard();
|
|
115
123
|
|
|
116
124
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
|
117
125
|
const [activeFilters, setActiveFilters] = useState<ReadonlySet<string>>(
|
|
@@ -234,6 +242,8 @@ export function FlaggedList({
|
|
|
234
242
|
selectedMessageId={selectedMessageId}
|
|
235
243
|
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
236
244
|
onDeleteMessages={onDeleteMessages}
|
|
245
|
+
onSelectionVerb={wizard.start}
|
|
246
|
+
wizardOpen={wizard.isOpen}
|
|
237
247
|
commandsRef={commandsRef}
|
|
238
248
|
onTriageContextChange={onTriageContextChange}
|
|
239
249
|
>
|
|
@@ -251,18 +261,13 @@ export function FlaggedList({
|
|
|
251
261
|
onSelectThread={onSelectMessage}
|
|
252
262
|
onSelectBriefCategory={() => undefined}
|
|
253
263
|
isDesktop={isDesktop}
|
|
254
|
-
selectionBar={
|
|
255
|
-
<ThreadListSelectionBar
|
|
256
|
-
title="Starred"
|
|
257
|
-
onMarkAsRead={onMarkMessagesRead}
|
|
258
|
-
/>
|
|
259
|
-
}
|
|
264
|
+
selectionBar={<ThreadListSelectionBar title="Starred" />}
|
|
260
265
|
listBody={
|
|
261
266
|
chrome.searchResults ??
|
|
262
267
|
(listState === "ready" ? listBody : undefined)
|
|
263
268
|
}
|
|
264
269
|
/>
|
|
265
|
-
<StarredWizardHost rows={rows} />
|
|
270
|
+
<StarredWizardHost rows={rows} verb={wizard.verb} />
|
|
266
271
|
</ThreadListInteraction>
|
|
267
272
|
</MailViewChrome>
|
|
268
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
|
}
|
|
@@ -695,21 +695,24 @@ function MailboxPaneProvider({
|
|
|
695
695
|
if (selectedThread) setToolbarComposeRequest("forward");
|
|
696
696
|
}, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
|
|
697
697
|
|
|
698
|
-
const triageTargetMessageIds = useCallback(
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
698
|
+
const triageTargetMessageIds = useCallback(
|
|
699
|
+
(): string[] => messageIdsForFocusedThread(focusedThread),
|
|
700
|
+
[messageIdsForFocusedThread, focusedThread],
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// Every verb the list can take, it takes: over a selection it opens the
|
|
704
|
+
// wizard, which is where a bulk action is reviewed before it reaches the mail
|
|
705
|
+
// server (#477 1.4, #508). What falls through is aimed at the bare cursor, or
|
|
706
|
+
// at the reading pane when no list is mounted — one message, not a bulk
|
|
707
|
+
// action, and the pane acts on it directly.
|
|
706
708
|
const triageDeleteAction = useCallback(() => {
|
|
707
|
-
if (listCommandsRef.current?.
|
|
709
|
+
if (listCommandsRef.current?.requestVerb("delete")) return;
|
|
708
710
|
const ids = triageTargetMessageIds();
|
|
709
711
|
if (ids.length > 0) triageDelete(ids);
|
|
710
712
|
}, [listCommandsRef, triageTargetMessageIds, triageDelete]);
|
|
711
713
|
|
|
712
714
|
const triageMarkJunk = useCallback(() => {
|
|
715
|
+
if (listCommandsRef.current?.requestVerb("junk")) return;
|
|
713
716
|
if (!junkMailboxId) return;
|
|
714
717
|
const ids = triageTargetMessageIds();
|
|
715
718
|
if (ids.length === 0) return;
|
|
@@ -720,6 +723,7 @@ function MailboxPaneProvider({
|
|
|
720
723
|
});
|
|
721
724
|
triageMove(ids, junkMailboxId);
|
|
722
725
|
}, [
|
|
726
|
+
listCommandsRef,
|
|
723
727
|
junkMailboxId,
|
|
724
728
|
triageTargetMessageIds,
|
|
725
729
|
triageMove,
|
|
@@ -727,6 +731,10 @@ function MailboxPaneProvider({
|
|
|
727
731
|
focusedThread,
|
|
728
732
|
]);
|
|
729
733
|
|
|
734
|
+
// Star is the one verb that acts on a selection from here. It is not on the
|
|
735
|
+
// selection bar and not one of the five the wizard walks: it sets a flag on
|
|
736
|
+
// mail that is already in front of the user and unsets it the same way, so
|
|
737
|
+
// there is nothing for a review screen to name and nothing to undo.
|
|
730
738
|
const triageStar = useCallback(() => {
|
|
731
739
|
if (triageSelectedIds.length > 0) {
|
|
732
740
|
const nextStarred = !(focusedThread?.hasStars ?? false);
|
|
@@ -743,11 +751,17 @@ function MailboxPaneProvider({
|
|
|
743
751
|
}, [triageSelectedIds, threads, focusedThread, focusedToggleStar]);
|
|
744
752
|
|
|
745
753
|
const triageToggleRead = useCallback(() => {
|
|
754
|
+
if (listCommandsRef.current?.requestVerb("markRead")) return;
|
|
746
755
|
const ids = triageTargetMessageIds();
|
|
747
756
|
if (ids.length === 0) return;
|
|
748
757
|
const nextRead = !(focusedThread?.isRead ?? false);
|
|
749
758
|
triageToggleReadFor(ids, nextRead);
|
|
750
|
-
}, [
|
|
759
|
+
}, [
|
|
760
|
+
listCommandsRef,
|
|
761
|
+
triageTargetMessageIds,
|
|
762
|
+
focusedThread,
|
|
763
|
+
triageToggleReadFor,
|
|
764
|
+
]);
|
|
751
765
|
|
|
752
766
|
const triageMute = useCallback(() => {
|
|
753
767
|
if (!focusedAddressId) return;
|
|
@@ -1013,7 +1027,6 @@ function MailboxList() {
|
|
|
1013
1027
|
searchQuery={searchQuery}
|
|
1014
1028
|
searchPredicate={searchPredicate}
|
|
1015
1029
|
onDeleteMessages={onDeleteMessages}
|
|
1016
|
-
onMoveMessages={onMoveMessages}
|
|
1017
1030
|
isDeleting={isDeleting}
|
|
1018
1031
|
isMoving={isMoving}
|
|
1019
1032
|
onLoadMore={onLoadMore}
|
|
@@ -59,45 +59,43 @@ describe("MessageList selection mode", () => {
|
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* An escalated selection is a predicate,
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* An escalated selection is a predicate, and it walks the same wizard every
|
|
63
|
+
* other selection walks (#508). The list hands the wizard the predicate — the
|
|
64
|
+
* words it names it with, the count it was escalated to, and the chunked runner
|
|
65
|
+
* that re-resolves it — and keeps no second confirmation of its own.
|
|
66
66
|
*/
|
|
67
67
|
describe("MessageList escalated actions", () => {
|
|
68
|
-
it("
|
|
68
|
+
it("hands the escalated predicate to the wizard rather than running it here", () => {
|
|
69
69
|
assert.match(
|
|
70
70
|
source,
|
|
71
|
-
/escalation\.phase\.kind === "escalated"
|
|
71
|
+
/const escalatedSelection: EscalatedSelection \| undefined =\s*escalation\.phase\.kind === "escalated"/,
|
|
72
72
|
);
|
|
73
73
|
assert.match(
|
|
74
74
|
source,
|
|
75
|
-
/
|
|
75
|
+
/scope: describeSearchScope\(searchPredicate \?\? \{\}\)/,
|
|
76
76
|
);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
it("keeps mark-read and the move slot on an escalated selection", () => {
|
|
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 — it falls
|
|
82
|
-
// back to the predicate run, which the wizard cannot take.
|
|
83
|
-
assert.match(source, /: handleMarkAsRead\s*\}/);
|
|
84
|
-
// The move slot is offered for a bounded selection or an escalated one —
|
|
85
|
-
// #114's rule that an escalated selection is never delete-only.
|
|
77
|
+
assert.match(source, /total: escalation\.phase\.total/);
|
|
86
78
|
assert.match(
|
|
87
79
|
source,
|
|
88
|
-
/
|
|
80
|
+
/run: \(action: EscalatedAction\) => escalation\.runAction\(action\)/,
|
|
89
81
|
);
|
|
82
|
+
const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
|
|
83
|
+
assert.match(host, /escalated=\{escalatedSelection\}/);
|
|
90
84
|
});
|
|
91
85
|
|
|
92
|
-
it("
|
|
86
|
+
it("keeps no predicate confirmation of its own", () => {
|
|
87
|
+
// The review screen names what the predicate covers; a second dialog
|
|
88
|
+
// asking the same question is the drift the wizard exists to end.
|
|
89
|
+
assert.doesNotMatch(source, /source: "predicate"/);
|
|
90
|
+
assert.doesNotMatch(source, /requestEscalatedDelete/);
|
|
91
|
+
assert.doesNotMatch(source, /pendingDeleteIsEstimate/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("words the bar's progress per action instead of per delete", () => {
|
|
93
95
|
assert.match(
|
|
94
96
|
source,
|
|
95
97
|
/bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
|
|
96
98
|
);
|
|
97
|
-
assert.match(
|
|
98
|
-
source,
|
|
99
|
-
/bulkActionCompletionText\(action\.kind, outcome\.done\)/,
|
|
100
|
-
);
|
|
101
99
|
});
|
|
102
100
|
});
|
|
103
101
|
|
|
@@ -143,29 +141,45 @@ describe("MessageList escalation reaches desktop (#212)", () => {
|
|
|
143
141
|
assert.match(source, /navSlot=\{listHeaderChrome\.navSlot\}/);
|
|
144
142
|
});
|
|
145
143
|
|
|
146
|
-
it("
|
|
144
|
+
it("carries every verb the bar can offer (#477 1.4)", () => {
|
|
145
|
+
// That each of them opens the wizard is `selection-verbs.test.ts`, over
|
|
146
|
+
// every surface at once. What this pins is that none of them was dropped
|
|
147
|
+
// from the one bar the mailbox raises.
|
|
147
148
|
const bar = source.match(/<SelectionTopBar[\s\S]*?\n\t\t\/>/)?.[0] ?? "";
|
|
148
|
-
for (const
|
|
149
|
-
|
|
149
|
+
for (const prop of [
|
|
150
|
+
"onDelete",
|
|
151
|
+
"onMove",
|
|
152
|
+
"onOrganize",
|
|
153
|
+
"onJunk",
|
|
154
|
+
"onMarkRead",
|
|
155
|
+
]) {
|
|
156
|
+
assert.match(bar, new RegExp(`${prop}=`));
|
|
150
157
|
}
|
|
151
158
|
// The one selection surface has no second organize entry beside them.
|
|
152
159
|
assert.doesNotMatch(source, /onSomethingElse/);
|
|
153
160
|
});
|
|
154
161
|
|
|
162
|
+
it("keeps Organize reachable over a predicate rather than dropping it", () => {
|
|
163
|
+
// A verb that vanishes when a selection escalates leaves the user no route
|
|
164
|
+
// at all: the make-filter affordance it defers to is hidden while rows are
|
|
165
|
+
// ticked (#477 1.7).
|
|
166
|
+
assert.match(source, /if \(escalatedSelection\) \{\s*startFromSearch\(\);/);
|
|
167
|
+
});
|
|
168
|
+
|
|
155
169
|
it("offers the label picker only when the account has labels", () => {
|
|
156
170
|
assert.match(source, /labels\.length > 0 \? \(/);
|
|
157
171
|
});
|
|
158
172
|
});
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
|
-
* A
|
|
175
|
+
* A confirmed delete used to open the surviving neighbour by writing
|
|
162
176
|
* `selectedMessageId` into the URL. On desktop that fills the reading pane
|
|
163
177
|
* beside the list; on a single-pane mobile layout the same navigation replaced
|
|
164
|
-
* the list with a full-screen message, so
|
|
165
|
-
* random message" rather than "the
|
|
166
|
-
*
|
|
178
|
+
* the list with a full-screen message, so the delete read as "jumped into a
|
|
179
|
+
* random message" rather than "the row is gone" (#202). Mobile now stays on the
|
|
180
|
+
* list and raises a completion banner instead.
|
|
167
181
|
*/
|
|
168
|
-
describe("MessageList
|
|
182
|
+
describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
|
|
169
183
|
it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
|
|
170
184
|
assert.match(
|
|
171
185
|
source,
|