@remit/web-client 0.0.130 → 0.0.132
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 +3 -2
- package/src/components/mail/MailboxPane.tsx +5 -3
- package/src/components/mail/MessageList.tsx +34 -24
- package/src/components/mail/SelectionWizardHost.run-exit.test.ts +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +28 -19
- package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +237 -0
- package/src/components/mail/SwipeableMessageRow.tsx +28 -12
- package/src/components/mail/selection-verbs.test.ts +8 -1
- package/src/lib/junk-destination.test.ts +40 -0
- package/src/lib/junk-destination.ts +37 -0
- package/src/lib/list-verb-request.test.ts +124 -0
- package/src/lib/list-verb-request.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.132",
|
|
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": {
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
toThreadRowData,
|
|
85
85
|
} from "@/lib/brief";
|
|
86
86
|
import { isServerError } from "@/lib/error-classifier";
|
|
87
|
+
import { junkDestination } from "@/lib/junk-destination";
|
|
87
88
|
import type { ListHeaderChrome } from "@/lib/list-header-chrome";
|
|
88
89
|
import { useMailContext } from "@/lib/mail-context";
|
|
89
90
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
@@ -280,7 +281,7 @@ function BriefSelectionChrome({
|
|
|
280
281
|
[rows, selectedIds],
|
|
281
282
|
);
|
|
282
283
|
|
|
283
|
-
const
|
|
284
|
+
const junkDestinationId = junkDestination(junkMailboxId, scope.mailboxId);
|
|
284
285
|
|
|
285
286
|
// One select-all for both surfaces: the desktop toolbar and the touch sheet
|
|
286
287
|
// offer the same control over the same rendered rows, so the verb a phone
|
|
@@ -316,7 +317,7 @@ function BriefSelectionChrome({
|
|
|
316
317
|
onDelete={() => wizard.start("delete")}
|
|
317
318
|
onMove={() => wizard.start("move")}
|
|
318
319
|
onOrganize={() => wizard.start("organize")}
|
|
319
|
-
onJunk={
|
|
320
|
+
onJunk={junkDestinationId ? () => wizard.start("junk") : undefined}
|
|
320
321
|
onMarkRead={() => wizard.start("markRead")}
|
|
321
322
|
overflowSlot={
|
|
322
323
|
scope.accountId &&
|
|
@@ -111,6 +111,7 @@ import {
|
|
|
111
111
|
sameInboxFilter,
|
|
112
112
|
} from "@/lib/inbox-filters";
|
|
113
113
|
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
114
|
+
import { junkDestination } from "@/lib/junk-destination";
|
|
114
115
|
import { useMailContext } from "@/lib/mail-context";
|
|
115
116
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
116
117
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
@@ -658,6 +659,7 @@ function MailboxPaneProvider({
|
|
|
658
659
|
|
|
659
660
|
const { junkMailboxId } = useJunkMailbox(mailboxAccountId);
|
|
660
661
|
const isSpamFolder = junkMailboxId != null && junkMailboxId === mailboxId;
|
|
662
|
+
const junkDestinationId = junkDestination(junkMailboxId, mailboxId);
|
|
661
663
|
const { candidates: rescueCandidates } = useRescueCandidates(
|
|
662
664
|
isSpamFolder ? junkMailboxId : undefined,
|
|
663
665
|
);
|
|
@@ -724,7 +726,7 @@ function MailboxPaneProvider({
|
|
|
724
726
|
|
|
725
727
|
const triageMarkJunk = useCallback(() => {
|
|
726
728
|
if (listCommandsRef.current?.requestVerb("junk")) return;
|
|
727
|
-
if (!
|
|
729
|
+
if (!junkDestinationId) return;
|
|
728
730
|
const ids = triageTargetMessageIds();
|
|
729
731
|
if (ids.length === 0) return;
|
|
730
732
|
recordRescueSentToJunk(telemetry, {
|
|
@@ -732,10 +734,10 @@ function MailboxPaneProvider({
|
|
|
732
734
|
senderTrust: focusedThread?.senderTrust ?? "unknown",
|
|
733
735
|
wasRescuable: focusedThread ? isRescueCandidate(focusedThread) : false,
|
|
734
736
|
});
|
|
735
|
-
triageMove(ids,
|
|
737
|
+
triageMove(ids, junkDestinationId);
|
|
736
738
|
}, [
|
|
737
739
|
listCommandsRef,
|
|
738
|
-
|
|
740
|
+
junkDestinationId,
|
|
739
741
|
triageTargetMessageIds,
|
|
740
742
|
triageMove,
|
|
741
743
|
telemetry,
|
|
@@ -46,8 +46,10 @@ import {
|
|
|
46
46
|
escalationActionLabel,
|
|
47
47
|
} from "@/lib/escalation-label";
|
|
48
48
|
import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
|
|
49
|
+
import { junkDestination } from "@/lib/junk-destination";
|
|
49
50
|
import { tabStopId } from "@/lib/list-focus";
|
|
50
51
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
|
+
import { listVerbRequest } from "@/lib/list-verb-request";
|
|
51
53
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
52
54
|
import { cn } from "@/lib/utils";
|
|
53
55
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
@@ -290,6 +292,7 @@ export const MessageList = ({
|
|
|
290
292
|
// The Junk quick action moves the selection to the account's appointed Junk
|
|
291
293
|
// mailbox — the message-flags API has no `$Junk` field, so "junk" is a move.
|
|
292
294
|
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
295
|
+
const junkDestinationId = junkDestination(junkMailboxId, mailboxId);
|
|
293
296
|
|
|
294
297
|
// Selection state
|
|
295
298
|
const {
|
|
@@ -571,39 +574,50 @@ export const MessageList = ({
|
|
|
571
574
|
}
|
|
572
575
|
}, [orderedIds, selectAll]);
|
|
573
576
|
|
|
574
|
-
// A keyboard verb, routed the same
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
577
|
+
// A keyboard verb, routed by `listVerbRequest` — the same routing the bar
|
|
578
|
+
// renders from, so a verb the bar withholds is not still reachable by
|
|
579
|
+
// shortcut. Over a selection every verb opens the wizard (#477 1.4), whatever
|
|
580
|
+
// the selection is: the ticked rows, a selection spanning accounts — the
|
|
581
|
+
// wizard is where that restriction is stated, on the step that needs one
|
|
582
|
+
// account (#477 5.5) — or the predicate the list escalated to, which the
|
|
583
|
+
// wizard names on its match step and counts on its review screen before
|
|
584
|
+
// anything is sent (#508).
|
|
578
585
|
const requestVerb = useCallback(
|
|
579
586
|
(verb: Verb): boolean => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// which the wizard names on its match step and counts on its review
|
|
590
|
-
// screen before anything is sent (#508).
|
|
587
|
+
const request = listVerbRequest({
|
|
588
|
+
verb,
|
|
589
|
+
confirmingDelete: pendingDelete !== null,
|
|
590
|
+
hasSelection,
|
|
591
|
+
junkMailboxId,
|
|
592
|
+
currentMailboxId: mailboxId,
|
|
593
|
+
deletableMessageId: onDeleteMessages ? focusedMessageId : undefined,
|
|
594
|
+
});
|
|
595
|
+
if (request.kind === "openWizard") {
|
|
591
596
|
startWizard(verb);
|
|
592
597
|
return true;
|
|
593
598
|
}
|
|
594
|
-
if (
|
|
595
|
-
|
|
599
|
+
if (request.kind === "confirmDelete") {
|
|
600
|
+
requestDelete([request.messageId]);
|
|
601
|
+
return true;
|
|
596
602
|
}
|
|
597
|
-
|
|
598
|
-
|
|
603
|
+
// A shortcut that does nothing at all is indistinguishable from one that
|
|
604
|
+
// is broken, so a verb this mailbox cannot take says why on the press.
|
|
605
|
+
if (request.kind === "unavailable") {
|
|
606
|
+
pushError({ severity: "warning", title: request.reason });
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
return request.kind === "withheld";
|
|
599
610
|
},
|
|
600
611
|
[
|
|
601
612
|
pendingDelete,
|
|
602
613
|
onDeleteMessages,
|
|
603
614
|
hasSelection,
|
|
615
|
+
junkMailboxId,
|
|
616
|
+
mailboxId,
|
|
604
617
|
startWizard,
|
|
605
618
|
focusedMessageId,
|
|
606
619
|
requestDelete,
|
|
620
|
+
pushError,
|
|
607
621
|
],
|
|
608
622
|
);
|
|
609
623
|
|
|
@@ -1178,11 +1192,7 @@ export const MessageList = ({
|
|
|
1178
1192
|
onDelete={() => startWizard("delete")}
|
|
1179
1193
|
onMove={() => startWizard("move")}
|
|
1180
1194
|
onOrganize={organizeSelection}
|
|
1181
|
-
onJunk={
|
|
1182
|
-
junkMailboxId && junkMailboxId !== mailboxId
|
|
1183
|
-
? () => startWizard("junk")
|
|
1184
|
-
: undefined
|
|
1185
|
-
}
|
|
1195
|
+
onJunk={junkDestinationId ? () => startWizard("junk") : undefined}
|
|
1186
1196
|
onMarkRead={() => startWizard("markRead")}
|
|
1187
1197
|
overflowSlot={
|
|
1188
1198
|
accountId && mailboxId && selectedCount > 0 && labels.length > 0 ? (
|
|
@@ -45,7 +45,7 @@ describe("ending the run", () => {
|
|
|
45
45
|
assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
|
|
46
46
|
assert.match(
|
|
47
47
|
source,
|
|
48
|
-
/const runInFlight =\s*bulkRun !== undefined
|
|
48
|
+
/const runInFlight =\s*bulkRun !== undefined &&\s*bulkRun\.outcome === undefined &&\s*bulkRun\.failureReason === undefined;/,
|
|
49
49
|
);
|
|
50
50
|
});
|
|
51
51
|
|
|
@@ -48,6 +48,7 @@ import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
|
|
|
48
48
|
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
49
49
|
import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
|
|
50
50
|
import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
|
|
51
|
+
import { NO_JUNK_FOLDER_REASON } from "@/lib/junk-destination";
|
|
51
52
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
52
53
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
54
|
import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
|
|
@@ -174,6 +175,12 @@ interface RunSnapshot {
|
|
|
174
175
|
applied: number;
|
|
175
176
|
failed: number;
|
|
176
177
|
failures: readonly WizardMessage[];
|
|
178
|
+
/**
|
|
179
|
+
* Why a commit never started, when the reason is one the same commit cannot
|
|
180
|
+
* get past. Carried to the run screen, which states it and offers no retry
|
|
181
|
+
* (#522).
|
|
182
|
+
*/
|
|
183
|
+
failureReason?: string;
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
const NOT_STARTED: RunSnapshot = {
|
|
@@ -222,21 +229,12 @@ const widenedRunsAsJob = (verb: Verb): boolean =>
|
|
|
222
229
|
* The commit pressed with nowhere for the mail to go. A verb that files mail
|
|
223
230
|
* needs a destination, and reaching the run screen without one is a failure the
|
|
224
231
|
* screen states along with the way out of it — never a control that does
|
|
225
|
-
* nothing.
|
|
232
|
+
* nothing, and never a retry that re-sends the same commit to the same absence.
|
|
226
233
|
*/
|
|
227
|
-
const
|
|
228
|
-
verb
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
done: 0,
|
|
232
|
-
failedIds: [...ids],
|
|
233
|
-
cancelled: false,
|
|
234
|
-
error: new Error(
|
|
235
|
-
verb === "junk"
|
|
236
|
-
? "This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders."
|
|
237
|
-
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.",
|
|
238
|
-
),
|
|
239
|
-
});
|
|
234
|
+
const noDestinationReason = (verb: Verb): string =>
|
|
235
|
+
verb === "junk"
|
|
236
|
+
? NO_JUNK_FOLDER_REASON
|
|
237
|
+
: "No destination was chosen, so there is nowhere to file these. Go back and pick a folder.";
|
|
240
238
|
|
|
241
239
|
/**
|
|
242
240
|
* Where the wizard meets the app (#483). The steps and their bodies belong to
|
|
@@ -305,7 +303,8 @@ function SelectionWizardSession({
|
|
|
305
303
|
OrganizeScope | undefined
|
|
306
304
|
>(undefined);
|
|
307
305
|
const [bulkRun, setBulkRun] = useState<
|
|
308
|
-
{ matched: number; outcome?: BulkRunOutcome
|
|
306
|
+
| { matched: number; outcome?: BulkRunOutcome; failureReason?: string }
|
|
307
|
+
| undefined
|
|
309
308
|
>(undefined);
|
|
310
309
|
// The predicate the create chains its pass to. Undefined when the rule cannot
|
|
311
310
|
// be back-applied — a `HasWords` clause the vector-free pass cannot evaluate —
|
|
@@ -589,7 +588,7 @@ function SelectionWizardSession({
|
|
|
589
588
|
if (!action) {
|
|
590
589
|
setBulkRun({
|
|
591
590
|
matched: ids.length,
|
|
592
|
-
|
|
591
|
+
failureReason: noDestinationReason(verb),
|
|
593
592
|
});
|
|
594
593
|
return;
|
|
595
594
|
}
|
|
@@ -610,7 +609,7 @@ function SelectionWizardSession({
|
|
|
610
609
|
if (!action) {
|
|
611
610
|
setBulkRun({
|
|
612
611
|
matched: escalated.total,
|
|
613
|
-
|
|
612
|
+
failureReason: noDestinationReason(verb),
|
|
614
613
|
});
|
|
615
614
|
return;
|
|
616
615
|
}
|
|
@@ -747,7 +746,12 @@ function SelectionWizardSession({
|
|
|
747
746
|
|
|
748
747
|
const bulkSnapshot = useCallback((): RunSnapshot => {
|
|
749
748
|
if (!bulkRun) return NOT_STARTED;
|
|
750
|
-
const { matched, outcome } = bulkRun;
|
|
749
|
+
const { matched, outcome, failureReason } = bulkRun;
|
|
750
|
+
// Nothing was sent, and nothing about sending it again resolves what was
|
|
751
|
+
// missing — so the screen carries why rather than the generic ending (#522).
|
|
752
|
+
if (failureReason !== undefined) {
|
|
753
|
+
return { ...NOT_STARTED, state: "commitFailed", failureReason };
|
|
754
|
+
}
|
|
751
755
|
if (!outcome) {
|
|
752
756
|
const applied = runProgress?.done ?? 0;
|
|
753
757
|
// A predicate matches more by the time the run re-pages it than the count
|
|
@@ -867,7 +871,11 @@ function SelectionWizardSession({
|
|
|
867
871
|
// list's runner and a bounded selection by this wizard's own, so the stop
|
|
868
872
|
// follows whichever one is paging.
|
|
869
873
|
const { stop: stopBulk } = bulk;
|
|
870
|
-
|
|
874
|
+
// A commit that never started has nothing paging and nothing to end.
|
|
875
|
+
const runInFlight =
|
|
876
|
+
bulkRun !== undefined &&
|
|
877
|
+
bulkRun.outcome === undefined &&
|
|
878
|
+
bulkRun.failureReason === undefined;
|
|
871
879
|
const stopRun = useCallback(() => {
|
|
872
880
|
if (escalated) {
|
|
873
881
|
escalated.stop();
|
|
@@ -1001,6 +1009,7 @@ function SelectionWizardSession({
|
|
|
1001
1009
|
applied: run.applied,
|
|
1002
1010
|
failedCount: run.failed,
|
|
1003
1011
|
failures: run.failures,
|
|
1012
|
+
failureReason: run.failureReason,
|
|
1004
1013
|
onRetry: retry,
|
|
1005
1014
|
onDismiss: dismiss,
|
|
1006
1015
|
onCancelRun: runInFlight ? stopRun : undefined,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A modifier can only come from a real keyboard, so the touch row honours it at
|
|
3
|
+
* every width (#586).
|
|
4
|
+
*
|
|
5
|
+
* Below 1024px the list renders the swipe row, which reads a press as a pointer
|
|
6
|
+
* gesture and opens the message from the release. Shift and cmd have to reach
|
|
7
|
+
* selection before that gesture starts, or a half-screen window and a tablet
|
|
8
|
+
* with a keyboard can only ever open messages one at a time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import type { SelectionModifiers } from "@remit/ui";
|
|
15
|
+
import {
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRoute,
|
|
20
|
+
createRouter,
|
|
21
|
+
RouterContextProvider,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
23
|
+
import { createElement } from "react";
|
|
24
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
25
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
26
|
+
|
|
27
|
+
const HALF_SCREEN_WIDTH = 900;
|
|
28
|
+
|
|
29
|
+
let harness: DomHarness | undefined;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness?.close();
|
|
33
|
+
harness = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const thread = {
|
|
37
|
+
threadMessageId: "tm-1",
|
|
38
|
+
threadId: "th-1",
|
|
39
|
+
messageId: "msg-1",
|
|
40
|
+
accountId: "acc-1",
|
|
41
|
+
accountConfigId: "acc-1",
|
|
42
|
+
mailboxId: "mbx-1",
|
|
43
|
+
subject: "Q3 planning notes",
|
|
44
|
+
fromName: "Alex Rivera",
|
|
45
|
+
fromEmail: "alex@example.com",
|
|
46
|
+
snippet: "Notes from the planning session.",
|
|
47
|
+
sentDate: 0,
|
|
48
|
+
isRead: false,
|
|
49
|
+
hasAttachment: false,
|
|
50
|
+
hasStars: false,
|
|
51
|
+
star: "None",
|
|
52
|
+
isDeleted: false,
|
|
53
|
+
senderTrust: "unknown",
|
|
54
|
+
createdAt: 0,
|
|
55
|
+
updatedAt: 0,
|
|
56
|
+
} as unknown as RemitImapThreadMessageResponse;
|
|
57
|
+
|
|
58
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
59
|
+
// `window`.
|
|
60
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
61
|
+
|
|
62
|
+
const rootRoute = createRootRoute();
|
|
63
|
+
const mailRoute = createRoute({
|
|
64
|
+
getParentRoute: () => rootRoute,
|
|
65
|
+
path: "/mail/$mailboxId",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
interface Mounted {
|
|
69
|
+
row: HTMLElement;
|
|
70
|
+
router: AnyRouter;
|
|
71
|
+
selects: SelectionModifiers[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mountRow = (selectionTakesIt = true): Mounted => {
|
|
75
|
+
const selects: SelectionModifiers[] = [];
|
|
76
|
+
const router = createRouter({
|
|
77
|
+
routeTree: rootRoute.addChildren([mailRoute]),
|
|
78
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
79
|
+
}) as unknown as AnyRouter;
|
|
80
|
+
const created = createDomHarness({ viewportWidth: HALF_SCREEN_WIDTH });
|
|
81
|
+
harness = created;
|
|
82
|
+
created.render(
|
|
83
|
+
createElement(RouterContextProvider, {
|
|
84
|
+
router,
|
|
85
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
86
|
+
children: createElement(SwipeableMessageRow, {
|
|
87
|
+
thread,
|
|
88
|
+
mailboxId: "mbx-1",
|
|
89
|
+
isSelected: false,
|
|
90
|
+
isChecked: false,
|
|
91
|
+
onToggleCheck: () => undefined,
|
|
92
|
+
onRowSelect: (_id: string, modifiers: SelectionModifiers) => {
|
|
93
|
+
selects.push(modifiers);
|
|
94
|
+
return selectionTakesIt;
|
|
95
|
+
},
|
|
96
|
+
isMultiSelectMode: false,
|
|
97
|
+
onLongPress: () => undefined,
|
|
98
|
+
isDesktop: false,
|
|
99
|
+
onDelete: () => undefined,
|
|
100
|
+
onToggleRead: () => undefined,
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const row = created.query("button[data-message-row]");
|
|
105
|
+
assert.ok(row, "the swipe row did not mount");
|
|
106
|
+
return { row, router, selects };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const press = (
|
|
110
|
+
row: Element,
|
|
111
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
112
|
+
): PointerEvent => {
|
|
113
|
+
const event = new PointerEvent("pointerdown", {
|
|
114
|
+
bubbles: true,
|
|
115
|
+
cancelable: true,
|
|
116
|
+
pointerId: 1,
|
|
117
|
+
clientX: 10,
|
|
118
|
+
clientY: 10,
|
|
119
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
120
|
+
metaKey: modifiers.metaKey ?? false,
|
|
121
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
122
|
+
});
|
|
123
|
+
harness?.dispatch(row, event);
|
|
124
|
+
return event;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const release = (row: Element): void => {
|
|
128
|
+
harness?.dispatch(
|
|
129
|
+
row,
|
|
130
|
+
new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// A press whose default was taken still delivers a click, so the click has to
|
|
135
|
+
// be consumed too rather than reaching selection a second time.
|
|
136
|
+
const click = (
|
|
137
|
+
row: Element,
|
|
138
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
139
|
+
): void =>
|
|
140
|
+
harness?.dispatch(
|
|
141
|
+
row,
|
|
142
|
+
new MouseEvent("click", {
|
|
143
|
+
bubbles: true,
|
|
144
|
+
cancelable: true,
|
|
145
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
146
|
+
metaKey: modifiers.metaKey ?? false,
|
|
147
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const openedMessageId = (router: AnyRouter): string | undefined =>
|
|
152
|
+
(router.state.location.search as { selectedMessageId?: string })
|
|
153
|
+
.selectedMessageId;
|
|
154
|
+
|
|
155
|
+
describe("SwipeableMessageRow — modifier selection below the desktop width", () => {
|
|
156
|
+
it("takes a shift-press for selection instead of opening the message", async () => {
|
|
157
|
+
const { row, router, selects } = mountRow();
|
|
158
|
+
|
|
159
|
+
const event = press(row, { shiftKey: true });
|
|
160
|
+
release(row);
|
|
161
|
+
await harness?.flush();
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(selects, [
|
|
164
|
+
{ shiftKey: true, metaKey: false, ctrlKey: false },
|
|
165
|
+
]);
|
|
166
|
+
assert.equal(event.defaultPrevented, true);
|
|
167
|
+
assert.equal(openedMessageId(router), undefined);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("takes a cmd-press for selection instead of opening the message", async () => {
|
|
171
|
+
const { row, router, selects } = mountRow();
|
|
172
|
+
|
|
173
|
+
press(row, { metaKey: true });
|
|
174
|
+
release(row);
|
|
175
|
+
click(row, { metaKey: true });
|
|
176
|
+
await harness?.flush();
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(selects, [
|
|
179
|
+
{ shiftKey: false, metaKey: true, ctrlKey: false },
|
|
180
|
+
]);
|
|
181
|
+
assert.equal(openedMessageId(router), undefined);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("drops the native text selection a shift-press would drag across rows", () => {
|
|
185
|
+
const { row } = mountRow();
|
|
186
|
+
const selection = harness?.window.getSelection();
|
|
187
|
+
assert.ok(selection, "jsdom exposes no selection");
|
|
188
|
+
const range = harness?.document.createRange();
|
|
189
|
+
assert.ok(range, "jsdom exposes no range");
|
|
190
|
+
range.selectNodeContents(row);
|
|
191
|
+
selection.removeAllRanges();
|
|
192
|
+
selection.addRange(range);
|
|
193
|
+
|
|
194
|
+
press(row, { shiftKey: true });
|
|
195
|
+
|
|
196
|
+
assert.equal(selection.rangeCount, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("suppresses the context menu a ctrl-press already spent on selection", () => {
|
|
200
|
+
const { row, selects } = mountRow();
|
|
201
|
+
|
|
202
|
+
press(row, { ctrlKey: true });
|
|
203
|
+
const menu = new MouseEvent("contextmenu", {
|
|
204
|
+
bubbles: true,
|
|
205
|
+
cancelable: true,
|
|
206
|
+
ctrlKey: true,
|
|
207
|
+
});
|
|
208
|
+
harness?.dispatch(row, menu);
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(selects, [
|
|
211
|
+
{ shiftKey: false, metaKey: false, ctrlKey: true },
|
|
212
|
+
]);
|
|
213
|
+
assert.equal(menu.defaultPrevented, true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("opens on an unmodified tap", async () => {
|
|
217
|
+
const { row, router, selects } = mountRow();
|
|
218
|
+
|
|
219
|
+
press(row);
|
|
220
|
+
release(row);
|
|
221
|
+
click(row);
|
|
222
|
+
await harness?.flush();
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(selects, []);
|
|
225
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("opens when selection declines the modified press", async () => {
|
|
229
|
+
const { row, router } = mountRow(false);
|
|
230
|
+
|
|
231
|
+
press(row, { metaKey: true });
|
|
232
|
+
release(row);
|
|
233
|
+
await harness?.flush();
|
|
234
|
+
|
|
235
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -11,6 +11,7 @@ import { useCallback, useState } from "react";
|
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
14
15
|
|
|
15
16
|
interface MailboxLinkSearch {
|
|
16
17
|
selectedMessageId?: string;
|
|
@@ -111,6 +112,8 @@ export const SwipeableMessageRow = ({
|
|
|
111
112
|
});
|
|
112
113
|
}, [navigate, mailboxId, thread.messageId]);
|
|
113
114
|
|
|
115
|
+
const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
|
|
116
|
+
|
|
114
117
|
if (isDesktop || isMultiSelectMode) {
|
|
115
118
|
return (
|
|
116
119
|
<MessageListItem
|
|
@@ -131,18 +134,31 @@ export const SwipeableMessageRow = ({
|
|
|
131
134
|
);
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
// The swipe row reads the press as a pointer gesture, and it opens the message
|
|
138
|
+
// from the release — a `mousedown` handler on the row would already be behind
|
|
139
|
+
// it. Taking the modified press in the capture phase keeps it away from the
|
|
140
|
+
// gesture entirely, so a shift- or cmd-click selects instead of starting a
|
|
141
|
+
// swipe, a long press or an open.
|
|
134
142
|
return (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the wrapper only intercepts mouse modifiers ahead of the row's own gesture; the row beneath keeps the button semantics and the whole keyboard path
|
|
144
|
+
<div
|
|
145
|
+
role="presentation"
|
|
146
|
+
onPointerDownCapture={modifierSelect.onMouseDown}
|
|
147
|
+
onClickCapture={modifierSelect.claimClick}
|
|
148
|
+
onContextMenu={modifierSelect.onContextMenu}
|
|
149
|
+
>
|
|
150
|
+
<SwipeableRow
|
|
151
|
+
thread={toThreadRowData(thread)}
|
|
152
|
+
selectionMode={false}
|
|
153
|
+
checked={false}
|
|
154
|
+
active={isSelected}
|
|
155
|
+
peek={peek}
|
|
156
|
+
onPeek={setPeek}
|
|
157
|
+
onToggleCheck={handleToggleCheck}
|
|
158
|
+
onLongPress={handleLongPress}
|
|
159
|
+
onOpen={handleOpen}
|
|
160
|
+
onAct={handleAct}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
147
163
|
);
|
|
148
164
|
};
|
|
@@ -191,13 +191,20 @@ describe("every keyboard verb over a selection goes through the list", () => {
|
|
|
191
191
|
markJunk: "junk",
|
|
192
192
|
};
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* How a list may answer a verb aimed at the bare cursor: with the shared
|
|
196
|
+
* decision, whose rules are in `../../lib/list-verb-request.test.ts`, or
|
|
197
|
+
* inline. Either way only Delete is the list's.
|
|
198
|
+
*/
|
|
199
|
+
const CURSOR_DELETE_ONLY = /listVerbRequest\(\{|if \(verb !== "delete"/;
|
|
200
|
+
|
|
194
201
|
for (const file of LISTS) {
|
|
195
202
|
it(`${file} publishes the one seam and claims a selection`, () => {
|
|
196
203
|
const source = read(file);
|
|
197
204
|
assert.match(source, /requestVerb,/);
|
|
198
205
|
assert.match(
|
|
199
206
|
source,
|
|
200
|
-
|
|
207
|
+
CURSOR_DELETE_ONLY,
|
|
201
208
|
"only delete is the list's over a bare cursor",
|
|
202
209
|
);
|
|
203
210
|
assert.doesNotMatch(
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
ALREADY_IN_JUNK_REASON,
|
|
5
|
+
junkDestination,
|
|
6
|
+
junkWithheldReason,
|
|
7
|
+
NO_JUNK_FOLDER_REASON,
|
|
8
|
+
} from "./junk-destination";
|
|
9
|
+
|
|
10
|
+
const JUNK = "mbx-junk";
|
|
11
|
+
const INBOX = "mbx-inbox";
|
|
12
|
+
|
|
13
|
+
describe("junkDestination", () => {
|
|
14
|
+
it("is the appointed folder when the mail is somewhere else", () => {
|
|
15
|
+
assert.equal(junkDestination(JUNK, INBOX), JUNK);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("is nothing when the account appointed no Junk folder", () => {
|
|
19
|
+
assert.equal(junkDestination(undefined, INBOX), undefined);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is nothing when the mail is already in the Junk folder", () => {
|
|
23
|
+
assert.equal(junkDestination(JUNK, JUNK), undefined);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("junkWithheldReason", () => {
|
|
28
|
+
it("is nothing to say when the verb can act", () => {
|
|
29
|
+
assert.equal(junkWithheldReason(JUNK, INBOX), undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("names the setting that appoints a folder", () => {
|
|
33
|
+
assert.equal(junkWithheldReason(undefined, INBOX), NO_JUNK_FOLDER_REASON);
|
|
34
|
+
assert.match(NO_JUNK_FOLDER_REASON, /Settings › Folders/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("says the mail is already where the verb would put it", () => {
|
|
38
|
+
assert.equal(junkWithheldReason(JUNK, JUNK), ALREADY_IN_JUNK_REASON);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Junk verb can file mail, and why it cannot when it cannot (#522).
|
|
3
|
+
* One reading, for every surface that offers the verb: the selection bar, the
|
|
4
|
+
* keyboard, the brief's own bar and the wizard's commit. A second derivation is
|
|
5
|
+
* what let the keyboard open a wizard on a verb the bar was withholding.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const NO_JUNK_FOLDER_REASON =
|
|
9
|
+
"This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.";
|
|
10
|
+
|
|
11
|
+
export const ALREADY_IN_JUNK_REASON =
|
|
12
|
+
"These are already in Junk, so there is nowhere to file them.";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The account's appointed Junk folder, and nothing at all when the account has
|
|
16
|
+
* appointed none or when that folder is the one the mail is already in.
|
|
17
|
+
*/
|
|
18
|
+
export const junkDestination = (
|
|
19
|
+
junkMailboxId: string | undefined,
|
|
20
|
+
currentMailboxId: string | undefined,
|
|
21
|
+
): string | undefined =>
|
|
22
|
+
junkMailboxId !== undefined && junkMailboxId !== currentMailboxId
|
|
23
|
+
? junkMailboxId
|
|
24
|
+
: undefined;
|
|
25
|
+
|
|
26
|
+
/** Why the verb cannot be offered here, and nothing when it can. */
|
|
27
|
+
export const junkWithheldReason = (
|
|
28
|
+
junkMailboxId: string | undefined,
|
|
29
|
+
currentMailboxId: string | undefined,
|
|
30
|
+
): string | undefined => {
|
|
31
|
+
if (junkDestination(junkMailboxId, currentMailboxId) !== undefined) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return junkMailboxId === undefined
|
|
35
|
+
? NO_JUNK_FOLDER_REASON
|
|
36
|
+
: ALREADY_IN_JUNK_REASON;
|
|
37
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { Verb } from "@remit/ui";
|
|
4
|
+
import {
|
|
5
|
+
ALREADY_IN_JUNK_REASON,
|
|
6
|
+
NO_JUNK_FOLDER_REASON,
|
|
7
|
+
} from "./junk-destination";
|
|
8
|
+
import { type ListVerbReading, listVerbRequest } from "./list-verb-request";
|
|
9
|
+
|
|
10
|
+
const JUNK = "mbx-junk";
|
|
11
|
+
const INBOX = "mbx-inbox";
|
|
12
|
+
|
|
13
|
+
const reading = (over: Partial<ListVerbReading> = {}): ListVerbReading => ({
|
|
14
|
+
verb: "junk",
|
|
15
|
+
confirmingDelete: false,
|
|
16
|
+
hasSelection: true,
|
|
17
|
+
junkMailboxId: JUNK,
|
|
18
|
+
currentMailboxId: INBOX,
|
|
19
|
+
deletableMessageId: undefined,
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
|
|
24
|
+
|
|
25
|
+
describe("listVerbRequest", () => {
|
|
26
|
+
it("opens the wizard for every verb over a selection", () => {
|
|
27
|
+
for (const verb of VERBS) {
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
listVerbRequest(reading({ verb })),
|
|
30
|
+
{ kind: "openWizard" },
|
|
31
|
+
verb,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("leaves a verb aimed at the bare cursor to the pane, except delete", () => {
|
|
37
|
+
for (const verb of VERBS.filter((each) => each !== "delete")) {
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
listVerbRequest(
|
|
40
|
+
reading({ verb, hasSelection: false, deletableMessageId: "m1" }),
|
|
41
|
+
),
|
|
42
|
+
{ kind: "declined" },
|
|
43
|
+
verb,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
listVerbRequest(
|
|
48
|
+
reading({
|
|
49
|
+
verb: "delete",
|
|
50
|
+
hasSelection: false,
|
|
51
|
+
deletableMessageId: "m1",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
{ kind: "confirmDelete", messageId: "m1" },
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("declines a delete the surface cannot make", () => {
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
listVerbRequest(
|
|
61
|
+
reading({
|
|
62
|
+
verb: "delete",
|
|
63
|
+
hasSelection: false,
|
|
64
|
+
deletableMessageId: undefined,
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
{ kind: "declined" },
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("claims a second delete rather than letting it past the confirmation", () => {
|
|
72
|
+
assert.deepEqual(
|
|
73
|
+
listVerbRequest(reading({ verb: "delete", confirmingDelete: true })),
|
|
74
|
+
{ kind: "withheld" },
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* #522. The bar withholds Junk on exactly these two readings; the keyboard
|
|
80
|
+
* used to claim the press regardless and open the wizard on a verb whose
|
|
81
|
+
* commit then resolved no destination — ending on a Try again that re-sent the
|
|
82
|
+
* identical commit forever.
|
|
83
|
+
*/
|
|
84
|
+
describe("Junk with nowhere to file into", () => {
|
|
85
|
+
it("is withheld over a selection when no Junk folder is appointed", () => {
|
|
86
|
+
assert.deepEqual(listVerbRequest(reading({ junkMailboxId: undefined })), {
|
|
87
|
+
kind: "unavailable",
|
|
88
|
+
reason: NO_JUNK_FOLDER_REASON,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is withheld over a selection inside the Junk folder itself", () => {
|
|
93
|
+
assert.deepEqual(listVerbRequest(reading({ currentMailboxId: JUNK })), {
|
|
94
|
+
kind: "unavailable",
|
|
95
|
+
reason: ALREADY_IN_JUNK_REASON,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("is withheld over a bare cursor too, rather than falling to the pane", () => {
|
|
100
|
+
// Declining here hands the press to the pane, which moves the focused row
|
|
101
|
+
// into the folder it is already in and reports it as done.
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
listVerbRequest(
|
|
104
|
+
reading({
|
|
105
|
+
hasSelection: false,
|
|
106
|
+
currentMailboxId: JUNK,
|
|
107
|
+
deletableMessageId: "m1",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
{ kind: "unavailable", reason: ALREADY_IN_JUNK_REASON },
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("withholds nothing else on the same reading", () => {
|
|
115
|
+
for (const verb of VERBS.filter((each) => each !== "junk")) {
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
listVerbRequest(reading({ verb, junkMailboxId: undefined })),
|
|
118
|
+
{ kind: "openWizard" },
|
|
119
|
+
verb,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a message list does with a verb the keyboard aimed at it (#477 1.4,
|
|
3
|
+
* #508, #522). Pure, so the routing is testable without the DOM, the router and
|
|
4
|
+
* the data hooks a list wires together.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Verb } from "@remit/ui";
|
|
8
|
+
import { junkWithheldReason } from "./junk-destination";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* - `openWizard` — the selection walks the wizard, which is where a bulk action
|
|
12
|
+
* is reviewed before it reaches the mail server.
|
|
13
|
+
* - `confirmDelete` — the one verb a bare cursor keeps, with its confirmation.
|
|
14
|
+
* - `withheld` — the press belongs to what is already on screen: the delete
|
|
15
|
+
* confirmation is asking, and answering it is the Confirm button's job.
|
|
16
|
+
* - `unavailable` — the verb cannot act on this mail from here, and this is why.
|
|
17
|
+
* The list takes the press and says so, rather than leaving a shortcut that
|
|
18
|
+
* silently does nothing.
|
|
19
|
+
* - `declined` — not the list's press, so the pane acts on the focused row.
|
|
20
|
+
*/
|
|
21
|
+
export type ListVerbRequest =
|
|
22
|
+
| { kind: "openWizard" }
|
|
23
|
+
| { kind: "confirmDelete"; messageId: string }
|
|
24
|
+
| { kind: "withheld" }
|
|
25
|
+
| { kind: "unavailable"; reason: string }
|
|
26
|
+
| { kind: "declined" };
|
|
27
|
+
|
|
28
|
+
export interface ListVerbReading {
|
|
29
|
+
verb: Verb;
|
|
30
|
+
/** The delete confirmation is on screen. */
|
|
31
|
+
confirmingDelete: boolean;
|
|
32
|
+
hasSelection: boolean;
|
|
33
|
+
/** The account's appointed Junk folder, when it has appointed one. */
|
|
34
|
+
junkMailboxId: string | undefined;
|
|
35
|
+
/** The mailbox the rows are in, which the Junk destination cannot also be. */
|
|
36
|
+
currentMailboxId: string | undefined;
|
|
37
|
+
/** The row under the cursor, when this surface can delete it. */
|
|
38
|
+
deletableMessageId: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const listVerbRequest = ({
|
|
42
|
+
verb,
|
|
43
|
+
confirmingDelete,
|
|
44
|
+
hasSelection,
|
|
45
|
+
junkMailboxId,
|
|
46
|
+
currentMailboxId,
|
|
47
|
+
deletableMessageId,
|
|
48
|
+
}: ListVerbReading): ListVerbRequest => {
|
|
49
|
+
if (confirmingDelete) return { kind: "withheld" };
|
|
50
|
+
// Junk with nowhere to file into is kept off the keyboard exactly as the bar
|
|
51
|
+
// keeps it off the screen. Opening the wizard on it instead ends on a commit
|
|
52
|
+
// that resolves no destination, whatever is ticked (#522).
|
|
53
|
+
if (verb === "junk") {
|
|
54
|
+
const reason = junkWithheldReason(junkMailboxId, currentMailboxId);
|
|
55
|
+
if (reason !== undefined) return { kind: "unavailable", reason };
|
|
56
|
+
}
|
|
57
|
+
if (hasSelection) return { kind: "openWizard" };
|
|
58
|
+
if (verb !== "delete" || deletableMessageId === undefined) {
|
|
59
|
+
return { kind: "declined" };
|
|
60
|
+
}
|
|
61
|
+
return { kind: "confirmDelete", messageId: deletableMessageId };
|
|
62
|
+
};
|