@remit/web-client 0.0.131 → 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/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,
|
|
@@ -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
|
+
};
|