@remit/ui 0.0.123 → 0.0.125
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/selection-wizard.render.test.ts +22 -0
- package/src/components/selection-wizard.tsx +8 -6
- package/src/index.ts +5 -0
- package/src/lib/keymap.test.ts +7 -0
- package/src/lib/keymap.ts +45 -0
- package/src/lib/shortcut-tree.test.ts +438 -0
- package/src/lib/shortcut-tree.ts +373 -0
- package/src/lib/wizard-steps.test.ts +50 -0
- package/src/lib/wizard-steps.ts +68 -0
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
matchDoorsFor,
|
|
8
8
|
stepBlockedReason,
|
|
9
9
|
stepsFor,
|
|
10
|
+
wizardScopeFor,
|
|
10
11
|
} from "../lib/wizard-steps.js";
|
|
11
12
|
import {
|
|
12
13
|
type RuleClause,
|
|
@@ -413,6 +414,27 @@ describe("FolderStepBody", () => {
|
|
|
413
414
|
);
|
|
414
415
|
assert.match(text(html), /Tap a folder to open it/);
|
|
415
416
|
});
|
|
417
|
+
|
|
418
|
+
it("states the restriction the selection actually carries (#525)", () => {
|
|
419
|
+
// Told to pick a single account, a selection already inside one account has
|
|
420
|
+
// nothing to act on and the flow dead-ends here.
|
|
421
|
+
const spansFolders = renderToString(
|
|
422
|
+
createElement(FolderStepBody, {
|
|
423
|
+
...folderProps,
|
|
424
|
+
restriction: wizardScopeFor("acc-personal", "spansFolders").destination,
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
assert.match(text(spansFolders), /within one folder/);
|
|
428
|
+
assert.doesNotMatch(text(spansFolders), /single account/);
|
|
429
|
+
|
|
430
|
+
const spansAccounts = renderToString(
|
|
431
|
+
createElement(FolderStepBody, {
|
|
432
|
+
...folderProps,
|
|
433
|
+
restriction: wizardScopeFor(undefined, "spansAccounts").destination,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
assert.match(text(spansAccounts), /single account/);
|
|
437
|
+
});
|
|
416
438
|
});
|
|
417
439
|
|
|
418
440
|
describe("RuleStepBody", () => {
|
|
@@ -636,9 +636,10 @@ export interface FolderStepProps {
|
|
|
636
636
|
/** The account's hierarchy separator, which the tree nests on. */
|
|
637
637
|
delimiter?: string;
|
|
638
638
|
/**
|
|
639
|
-
* Why
|
|
640
|
-
*
|
|
641
|
-
*
|
|
639
|
+
* Why no destination can be chosen — the selection spans more accounts, or
|
|
640
|
+
* more folders, than a move can take (#477 5.5, #525). In the words of the
|
|
641
|
+
* scope it actually spans, so the instruction is one the user can follow. Said
|
|
642
|
+
* here rather than left as an empty picker.
|
|
642
643
|
*/
|
|
643
644
|
restriction?: string;
|
|
644
645
|
}
|
|
@@ -692,9 +693,10 @@ export interface RuleStepProps {
|
|
|
692
693
|
onScopeChange: (scope: RuleScope) => void;
|
|
693
694
|
onUntilChange: (until: string) => void;
|
|
694
695
|
/**
|
|
695
|
-
* Why the two persisting scopes cannot be reached from here —
|
|
696
|
-
*
|
|
697
|
-
*
|
|
696
|
+
* Why the two persisting scopes cannot be reached from here — the selection
|
|
697
|
+
* spans more accounts, or more folders, than a rule can take (#477 5.5, #525),
|
|
698
|
+
* in the words of the scope it actually spans. They stay pressable and say
|
|
699
|
+
* this; the one-off scope is unaffected.
|
|
698
700
|
*/
|
|
699
701
|
restriction?: string;
|
|
700
702
|
}
|
package/src/index.ts
CHANGED
|
@@ -989,6 +989,8 @@ export {
|
|
|
989
989
|
clauseWords,
|
|
990
990
|
crossAccountDestinationReason,
|
|
991
991
|
crossAccountRuleReason,
|
|
992
|
+
crossFolderDestinationReason,
|
|
993
|
+
crossFolderRuleReason,
|
|
992
994
|
ESCALATED_MATCH_HINT,
|
|
993
995
|
ESCALATED_REVIEW_WARNING,
|
|
994
996
|
ESCALATED_SCOPE_FALLBACK,
|
|
@@ -1006,6 +1008,7 @@ export {
|
|
|
1006
1008
|
type RunState,
|
|
1007
1009
|
runCopy,
|
|
1008
1010
|
type SampleEmptyReason,
|
|
1011
|
+
type SelectionRestriction,
|
|
1009
1012
|
type StepId,
|
|
1010
1013
|
sampleEmptyCopy,
|
|
1011
1014
|
stepBlockedReason,
|
|
@@ -1018,4 +1021,6 @@ export {
|
|
|
1018
1021
|
verbCopy,
|
|
1019
1022
|
type WizardAnswers,
|
|
1020
1023
|
type WizardDraft,
|
|
1024
|
+
type WizardScope,
|
|
1025
|
+
wizardScopeFor,
|
|
1021
1026
|
} from "./lib/wizard-steps.js";
|
package/src/lib/keymap.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
ALL_TRIAGE_ACTIONS,
|
|
4
5
|
KEY_HINT_GROUPS,
|
|
5
6
|
keysForAction,
|
|
6
7
|
shortcutHintForAction,
|
|
@@ -70,6 +71,12 @@ describe("keymap module", () => {
|
|
|
70
71
|
assert.strictEqual(describes("↑"), "Focus the message above");
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
test("every action the app can produce has a displayed binding", () => {
|
|
75
|
+
for (const action of ALL_TRIAGE_ACTIONS) {
|
|
76
|
+
assert.ok(keysForAction(action), `${action} has keys`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
73
80
|
test("every hint's action is a non-empty key list", () => {
|
|
74
81
|
for (const group of KEY_HINT_GROUPS) {
|
|
75
82
|
for (const hint of group.hints) {
|
package/src/lib/keymap.ts
CHANGED
|
@@ -48,6 +48,51 @@ export type TriageAction =
|
|
|
48
48
|
| "goFlagged"
|
|
49
49
|
| "goSettings";
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Presence table for {@link TriageAction}. `Record<TriageAction, true>` is what
|
|
53
|
+
* makes it exhaustive: adding a member to the union without adding it here is a
|
|
54
|
+
* type error, so anything that must reason about *every* action — the shortcut
|
|
55
|
+
* tree's completeness tests, the help overlay's audit — has a runtime list that
|
|
56
|
+
* cannot silently fall behind the type.
|
|
57
|
+
*/
|
|
58
|
+
const TRIAGE_ACTION_PRESENCE: Record<TriageAction, true> = {
|
|
59
|
+
focusNext: true,
|
|
60
|
+
focusPrevious: true,
|
|
61
|
+
focusFirst: true,
|
|
62
|
+
focusLast: true,
|
|
63
|
+
openFocused: true,
|
|
64
|
+
back: true,
|
|
65
|
+
toggleSelect: true,
|
|
66
|
+
extendSelectDown: true,
|
|
67
|
+
extendSelectUp: true,
|
|
68
|
+
selectAll: true,
|
|
69
|
+
reply: true,
|
|
70
|
+
replyAll: true,
|
|
71
|
+
forward: true,
|
|
72
|
+
delete: true,
|
|
73
|
+
toggleStar: true,
|
|
74
|
+
toggleRead: true,
|
|
75
|
+
muteSender: true,
|
|
76
|
+
blockSender: true,
|
|
77
|
+
vipSender: true,
|
|
78
|
+
markJunk: true,
|
|
79
|
+
toggleIntelligence: true,
|
|
80
|
+
toggleDensity: true,
|
|
81
|
+
focusSearch: true,
|
|
82
|
+
compose: true,
|
|
83
|
+
help: true,
|
|
84
|
+
goBrief: true,
|
|
85
|
+
goInbox: true,
|
|
86
|
+
goSent: true,
|
|
87
|
+
goFlagged: true,
|
|
88
|
+
goSettings: true,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Every action the app can produce, in declaration order. */
|
|
92
|
+
export const ALL_TRIAGE_ACTIONS: readonly TriageAction[] = Object.keys(
|
|
93
|
+
TRIAGE_ACTION_PRESENCE,
|
|
94
|
+
) as TriageAction[];
|
|
95
|
+
|
|
51
96
|
/** Map of action → handler. Omitted actions are inert (no-op). */
|
|
52
97
|
export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
|
|
53
98
|
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { ALL_TRIAGE_ACTIONS, type TriageAction } from "./keymap.js";
|
|
4
|
+
import {
|
|
5
|
+
type BlockedReason,
|
|
6
|
+
type DetailSurface,
|
|
7
|
+
type EditingField,
|
|
8
|
+
type ListNode,
|
|
9
|
+
type MailList,
|
|
10
|
+
NODE_ACTIONS,
|
|
11
|
+
type OverlayFrame,
|
|
12
|
+
type RegisteredActions,
|
|
13
|
+
type Resolution,
|
|
14
|
+
resolveShortcut,
|
|
15
|
+
type ShortcutTree,
|
|
16
|
+
} from "./shortcut-tree.js";
|
|
17
|
+
|
|
18
|
+
const brief: MailList = { kind: "brief" };
|
|
19
|
+
const inbox: MailList = { kind: "mailbox", mailboxId: "a", role: "inbox" };
|
|
20
|
+
const sent: MailList = { kind: "mailbox", mailboxId: "b", role: "sent" };
|
|
21
|
+
const archive: MailList = { kind: "mailbox", mailboxId: "c", role: "other" };
|
|
22
|
+
|
|
23
|
+
const thread: DetailSurface = {
|
|
24
|
+
kind: "thread",
|
|
25
|
+
threadId: "t1",
|
|
26
|
+
messageId: null,
|
|
27
|
+
};
|
|
28
|
+
const draft: DetailSurface = { kind: "compose", draftId: null };
|
|
29
|
+
|
|
30
|
+
const aim = { threadId: "t1", messageId: null };
|
|
31
|
+
|
|
32
|
+
const listOnly = (partial: Partial<ListNode> = {}): ListNode => ({
|
|
33
|
+
list: brief,
|
|
34
|
+
pane: { layout: "list-only" },
|
|
35
|
+
aim: null,
|
|
36
|
+
selection: [],
|
|
37
|
+
...partial,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const split = (
|
|
41
|
+
surface: DetailSurface,
|
|
42
|
+
partial: Partial<ListNode> = {},
|
|
43
|
+
): ListNode => ({
|
|
44
|
+
...listOnly({ aim, ...partial }),
|
|
45
|
+
pane: { layout: "split", detail: { surface } },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const detailOnly = (
|
|
49
|
+
surface: DetailSurface,
|
|
50
|
+
partial: Partial<ListNode> = {},
|
|
51
|
+
): ListNode => ({
|
|
52
|
+
...listOnly({ aim, ...partial }),
|
|
53
|
+
pane: { layout: "detail-only", detail: { surface } },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const tree = (partial: Partial<ShortcutTree> = {}): ShortcutTree => ({
|
|
57
|
+
overlays: [],
|
|
58
|
+
editing: null,
|
|
59
|
+
settling: null,
|
|
60
|
+
mail: { list: listOnly() },
|
|
61
|
+
...partial,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const on = (list: ListNode): ShortcutTree => tree({ mail: { list } });
|
|
65
|
+
|
|
66
|
+
const settings = (): ShortcutTree => tree({ mail: null });
|
|
67
|
+
|
|
68
|
+
const everywhere: RegisteredActions = {
|
|
69
|
+
app: ALL_TRIAGE_ACTIONS,
|
|
70
|
+
mail: ALL_TRIAGE_ACTIONS,
|
|
71
|
+
list: ALL_TRIAGE_ACTIONS,
|
|
72
|
+
detail: ALL_TRIAGE_ACTIONS,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const acted = (result: Resolution): string => {
|
|
76
|
+
if (result.outcome !== "act") {
|
|
77
|
+
assert.fail(`expected act, got ${result.outcome}`);
|
|
78
|
+
}
|
|
79
|
+
if (result.target.kind === "level") return result.target.level;
|
|
80
|
+
if (result.target.kind === "overlay") {
|
|
81
|
+
return `overlay:${result.target.frame.id}`;
|
|
82
|
+
}
|
|
83
|
+
return `editing:${result.target.field.id}`;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const containedBy = (result: Resolution): string => {
|
|
87
|
+
if (result.outcome !== "contained") {
|
|
88
|
+
assert.fail(`expected contained, got ${result.outcome}`);
|
|
89
|
+
}
|
|
90
|
+
if (result.by.kind === "overlay") return `overlay:${result.by.frame.id}`;
|
|
91
|
+
return `editing:${result.by.field.id}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const blockedFor = (result: Resolution): string => {
|
|
95
|
+
if (result.outcome !== "blocked") {
|
|
96
|
+
assert.fail(`expected blocked, got ${result.outcome}`);
|
|
97
|
+
}
|
|
98
|
+
return result.reason;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
describe("shortcut tree", () => {
|
|
102
|
+
test("every declared action is one the keymap can produce", () => {
|
|
103
|
+
const known = new Set<string>(ALL_TRIAGE_ACTIONS);
|
|
104
|
+
for (const rules of Object.values(NODE_ACTIONS)) {
|
|
105
|
+
for (const declared of rules) {
|
|
106
|
+
assert.ok(known.has(declared.action), `${declared.action} is known`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("every keymap action is declared somewhere in the tree", () => {
|
|
112
|
+
const declared = new Set<string>(
|
|
113
|
+
Object.values(NODE_ACTIONS).flatMap((rules) =>
|
|
114
|
+
rules.map((entry) => entry.action),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
for (const action of ALL_TRIAGE_ACTIONS) {
|
|
118
|
+
assert.ok(declared.has(action), `${action} is declared`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("the top overlay frame answers, and pre-empts every level", () => {
|
|
123
|
+
const overlays: OverlayFrame[] = [
|
|
124
|
+
{ id: "sheet", handles: ["back"] },
|
|
125
|
+
{ id: "confirm", handles: ["back", "reply"] },
|
|
126
|
+
];
|
|
127
|
+
const withOverlay = tree({ overlays });
|
|
128
|
+
assert.strictEqual(
|
|
129
|
+
acted(resolveShortcut("back", withOverlay, {})),
|
|
130
|
+
"overlay:confirm",
|
|
131
|
+
);
|
|
132
|
+
assert.strictEqual(
|
|
133
|
+
acted(resolveShortcut("reply", withOverlay, everywhere)),
|
|
134
|
+
"overlay:confirm",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("an overlay contains what it does not handle", () => {
|
|
139
|
+
const withOverlay = tree({
|
|
140
|
+
overlays: [{ id: "confirm", handles: ["back"] }],
|
|
141
|
+
});
|
|
142
|
+
assert.strictEqual(
|
|
143
|
+
containedBy(resolveShortcut("selectAll", withOverlay, everywhere)),
|
|
144
|
+
"overlay:confirm",
|
|
145
|
+
);
|
|
146
|
+
assert.strictEqual(
|
|
147
|
+
containedBy(resolveShortcut("focusSearch", withOverlay, everywhere)),
|
|
148
|
+
"overlay:confirm",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("an editing field answers or contains, and pre-empts the levels", () => {
|
|
153
|
+
const editing: EditingField = { id: "search", handles: ["back"] };
|
|
154
|
+
const typing = tree({ editing });
|
|
155
|
+
assert.strictEqual(
|
|
156
|
+
acted(resolveShortcut("back", typing, everywhere)),
|
|
157
|
+
"editing:search",
|
|
158
|
+
);
|
|
159
|
+
assert.strictEqual(
|
|
160
|
+
containedBy(resolveShortcut("focusNext", typing, everywhere)),
|
|
161
|
+
"editing:search",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an overlay outranks an editing field", () => {
|
|
166
|
+
const both = tree({
|
|
167
|
+
overlays: [{ id: "compose-modal", handles: ["back"] }],
|
|
168
|
+
editing: { id: "subject", handles: ["back"] },
|
|
169
|
+
});
|
|
170
|
+
assert.strictEqual(
|
|
171
|
+
acted(resolveShortcut("back", both, {})),
|
|
172
|
+
"overlay:compose-modal",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("settling blocks everything, and names its scope", () => {
|
|
177
|
+
for (const action of ALL_TRIAGE_ACTIONS) {
|
|
178
|
+
assert.strictEqual(
|
|
179
|
+
blockedFor(
|
|
180
|
+
resolveShortcut(action, tree({ settling: "app" }), everywhere),
|
|
181
|
+
),
|
|
182
|
+
"settling",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
assert.strictEqual(
|
|
186
|
+
blockedFor(
|
|
187
|
+
resolveShortcut("reply", tree({ settling: "list" }), everywhere),
|
|
188
|
+
),
|
|
189
|
+
"list-settling",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("focusNext walks to the list when the list shares the screen", () => {
|
|
194
|
+
const desktop = on(split(thread));
|
|
195
|
+
assert.strictEqual(
|
|
196
|
+
acted(resolveShortcut("focusNext", desktop, everywhere)),
|
|
197
|
+
"list",
|
|
198
|
+
);
|
|
199
|
+
assert.strictEqual(
|
|
200
|
+
acted(resolveShortcut("focusPrevious", desktop, everywhere)),
|
|
201
|
+
"list",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("focusNext stops at the detail when the detail owns the screen", () => {
|
|
206
|
+
const phone = on(detailOnly(thread));
|
|
207
|
+
assert.strictEqual(
|
|
208
|
+
acted(resolveShortcut("focusNext", phone, everywhere)),
|
|
209
|
+
"detail",
|
|
210
|
+
);
|
|
211
|
+
assert.strictEqual(
|
|
212
|
+
acted(resolveShortcut("focusPrevious", phone, everywhere)),
|
|
213
|
+
"detail",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("a level that serves an action but registers no handler is skipped", () => {
|
|
218
|
+
const phone = on(detailOnly(thread));
|
|
219
|
+
assert.strictEqual(
|
|
220
|
+
acted(resolveShortcut("reply", phone, { list: ["reply"] })),
|
|
221
|
+
"list",
|
|
222
|
+
);
|
|
223
|
+
assert.strictEqual(
|
|
224
|
+
acted(
|
|
225
|
+
resolveShortcut("reply", phone, { detail: ["reply"], list: ["reply"] }),
|
|
226
|
+
),
|
|
227
|
+
"detail",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("an action no level serves is unbound, requirements or not", () => {
|
|
232
|
+
assert.strictEqual(
|
|
233
|
+
resolveShortcut("compose", tree(), {}).outcome,
|
|
234
|
+
"unbound",
|
|
235
|
+
);
|
|
236
|
+
assert.strictEqual(
|
|
237
|
+
resolveShortcut("muteSender", tree(), {}).outcome,
|
|
238
|
+
"unbound",
|
|
239
|
+
);
|
|
240
|
+
assert.strictEqual(
|
|
241
|
+
resolveShortcut("toggleSelect", on(detailOnly(thread)), {}).outcome,
|
|
242
|
+
"unbound",
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("an unmet requirement only speaks when the level serves the action", () => {
|
|
247
|
+
const unaimed = on(listOnly());
|
|
248
|
+
assert.strictEqual(
|
|
249
|
+
resolveShortcut("muteSender", unaimed, {}).outcome,
|
|
250
|
+
"unbound",
|
|
251
|
+
);
|
|
252
|
+
assert.strictEqual(
|
|
253
|
+
blockedFor(
|
|
254
|
+
resolveShortcut("muteSender", unaimed, { list: ["muteSender"] }),
|
|
255
|
+
),
|
|
256
|
+
"not-aimed",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("the root-most served level's reason is the one reported", () => {
|
|
261
|
+
const composing = on(split(draft, { aim: null }));
|
|
262
|
+
assert.strictEqual(
|
|
263
|
+
blockedFor(
|
|
264
|
+
resolveShortcut("reply", composing, {
|
|
265
|
+
detail: ["reply"],
|
|
266
|
+
list: ["reply"],
|
|
267
|
+
}),
|
|
268
|
+
),
|
|
269
|
+
"not-aimed",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("back closes a compose detail, which is not a thread", () => {
|
|
274
|
+
const composing = on(detailOnly(draft));
|
|
275
|
+
assert.strictEqual(
|
|
276
|
+
acted(resolveShortcut("back", composing, everywhere)),
|
|
277
|
+
"detail",
|
|
278
|
+
);
|
|
279
|
+
assert.strictEqual(
|
|
280
|
+
blockedFor(resolveShortcut("reply", composing, { detail: ["reply"] })),
|
|
281
|
+
"no-thread",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("back clears a selection on the list", () => {
|
|
286
|
+
assert.strictEqual(
|
|
287
|
+
blockedFor(resolveShortcut("back", on(listOnly()), everywhere)),
|
|
288
|
+
"no-selection",
|
|
289
|
+
);
|
|
290
|
+
assert.strictEqual(
|
|
291
|
+
acted(
|
|
292
|
+
resolveShortcut(
|
|
293
|
+
"back",
|
|
294
|
+
on(listOnly({ selection: ["m1"] })),
|
|
295
|
+
everywhere,
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
"list",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("a phone thread reports geometry, not a fault", () => {
|
|
303
|
+
const phone = on(detailOnly(thread));
|
|
304
|
+
for (const action of [
|
|
305
|
+
"toggleSelect",
|
|
306
|
+
"selectAll",
|
|
307
|
+
"toggleDensity",
|
|
308
|
+
"openFocused",
|
|
309
|
+
"focusFirst",
|
|
310
|
+
"focusLast",
|
|
311
|
+
] as TriageAction[]) {
|
|
312
|
+
assert.strictEqual(
|
|
313
|
+
blockedFor(resolveShortcut(action, phone, everywhere)),
|
|
314
|
+
"list-off-screen",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("go-to keys know where they already are", () => {
|
|
320
|
+
assert.strictEqual(
|
|
321
|
+
blockedFor(resolveShortcut("goBrief", on(listOnly()), everywhere)),
|
|
322
|
+
"already-there",
|
|
323
|
+
);
|
|
324
|
+
assert.strictEqual(
|
|
325
|
+
blockedFor(
|
|
326
|
+
resolveShortcut(
|
|
327
|
+
"goFlagged",
|
|
328
|
+
on(listOnly({ list: { kind: "flagged" } })),
|
|
329
|
+
everywhere,
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
"already-there",
|
|
333
|
+
);
|
|
334
|
+
assert.strictEqual(
|
|
335
|
+
blockedFor(
|
|
336
|
+
resolveShortcut("goInbox", on(listOnly({ list: inbox })), everywhere),
|
|
337
|
+
),
|
|
338
|
+
"already-there",
|
|
339
|
+
);
|
|
340
|
+
assert.strictEqual(
|
|
341
|
+
blockedFor(
|
|
342
|
+
resolveShortcut("goSent", on(listOnly({ list: sent })), everywhere),
|
|
343
|
+
),
|
|
344
|
+
"already-there",
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a mailbox with no role is somewhere else entirely", () => {
|
|
349
|
+
const other = on(listOnly({ list: archive }));
|
|
350
|
+
assert.strictEqual(
|
|
351
|
+
acted(resolveShortcut("goInbox", other, everywhere)),
|
|
352
|
+
"app",
|
|
353
|
+
);
|
|
354
|
+
assert.strictEqual(
|
|
355
|
+
acted(resolveShortcut("goSent", other, everywhere)),
|
|
356
|
+
"app",
|
|
357
|
+
);
|
|
358
|
+
assert.strictEqual(
|
|
359
|
+
acted(resolveShortcut("goBrief", other, everywhere)),
|
|
360
|
+
"app",
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("a go-to key outside any list is not already-there", () => {
|
|
365
|
+
const noList = tree({ mail: { list: null } });
|
|
366
|
+
assert.strictEqual(
|
|
367
|
+
acted(resolveShortcut("goBrief", noList, everywhere)),
|
|
368
|
+
"app",
|
|
369
|
+
);
|
|
370
|
+
assert.strictEqual(
|
|
371
|
+
acted(resolveShortcut("goBrief", settings(), everywhere)),
|
|
372
|
+
"app",
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("help and the go-to family answer on a screen with no mail node", () => {
|
|
377
|
+
const globals: TriageAction[] = [
|
|
378
|
+
"help",
|
|
379
|
+
"goBrief",
|
|
380
|
+
"goFlagged",
|
|
381
|
+
"goInbox",
|
|
382
|
+
"goSent",
|
|
383
|
+
"goSettings",
|
|
384
|
+
];
|
|
385
|
+
for (const action of globals) {
|
|
386
|
+
assert.strictEqual(
|
|
387
|
+
acted(resolveShortcut(action, settings(), everywhere)),
|
|
388
|
+
"app",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("the mail surface's own keys are dead where there is no mail", () => {
|
|
394
|
+
const mailOnly: TriageAction[] = [
|
|
395
|
+
"compose",
|
|
396
|
+
"focusSearch",
|
|
397
|
+
"toggleIntelligence",
|
|
398
|
+
];
|
|
399
|
+
for (const action of mailOnly) {
|
|
400
|
+
assert.strictEqual(
|
|
401
|
+
resolveShortcut(action, settings(), everywhere).outcome,
|
|
402
|
+
"unbound",
|
|
403
|
+
);
|
|
404
|
+
assert.strictEqual(
|
|
405
|
+
acted(resolveShortcut(action, tree(), everywhere)),
|
|
406
|
+
"mail",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("every blocked reason is reachable", () => {
|
|
412
|
+
const scenarios: Record<BlockedReason, () => Resolution> = {
|
|
413
|
+
settling: () =>
|
|
414
|
+
resolveShortcut("help", tree({ settling: "app" }), everywhere),
|
|
415
|
+
"list-settling": () =>
|
|
416
|
+
resolveShortcut("help", tree({ settling: "list" }), everywhere),
|
|
417
|
+
"list-off-screen": () =>
|
|
418
|
+
resolveShortcut("toggleSelect", on(detailOnly(thread)), {
|
|
419
|
+
list: ["toggleSelect"],
|
|
420
|
+
}),
|
|
421
|
+
"list-shown": () =>
|
|
422
|
+
resolveShortcut("focusNext", on(split(thread)), {
|
|
423
|
+
detail: ["focusNext"],
|
|
424
|
+
}),
|
|
425
|
+
"not-aimed": () =>
|
|
426
|
+
resolveShortcut("toggleStar", on(listOnly()), { list: ["toggleStar"] }),
|
|
427
|
+
"no-selection": () =>
|
|
428
|
+
resolveShortcut("back", on(listOnly()), { list: ["back"] }),
|
|
429
|
+
"no-thread": () =>
|
|
430
|
+
resolveShortcut("reply", on(detailOnly(draft)), { detail: ["reply"] }),
|
|
431
|
+
"already-there": () =>
|
|
432
|
+
resolveShortcut("goBrief", on(listOnly()), { app: ["goBrief"] }),
|
|
433
|
+
};
|
|
434
|
+
for (const [reason, scenario] of Object.entries(scenarios)) {
|
|
435
|
+
assert.strictEqual(blockedFor(scenario()), reason);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
});
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { TriageAction } from "./keymap.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure resolver for the shortcut tree (#713, stage 1).
|
|
5
|
+
*
|
|
6
|
+
* `keymap-dispatch` decides *which* action a keystroke means. This module
|
|
7
|
+
* decides *who answers it*: given the app's current shape, an action runs at one
|
|
8
|
+
* level of the tree, is contained by an overlay or a field, is blocked for a
|
|
9
|
+
* named reason, or is unbound.
|
|
10
|
+
*
|
|
11
|
+
* A node is a level, never a branch value. There are four — `app`, `mail`,
|
|
12
|
+
* `list`, `detail` — plus two owners that pre-empt them: an overlay stack and
|
|
13
|
+
* an editing field. Which list you are on, which detail surface is open and
|
|
14
|
+
* which panes are on screen are *values* carried by those nodes, so the verb set
|
|
15
|
+
* of a level is declared once and a precondition discriminates the cases.
|
|
16
|
+
* Resolution walks leaf-to-root and the first served level wins, which is what
|
|
17
|
+
* makes `j` mean "next message in the thread" on a phone and "next row" on a
|
|
18
|
+
* desktop without either surface knowing the other exists.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here touches React, the DOM or the router: the caller supplies the
|
|
21
|
+
* tree and the handler registry, and maps an outcome to whatever the user
|
|
22
|
+
* should see and to whether the keystroke is consumed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The four levels of the tree, root first. */
|
|
26
|
+
export type ShortcutLevel = "app" | "mail" | "list" | "detail";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a mailbox is *for*. The go-to keys name the inbox and the sent folder
|
|
30
|
+
* without knowing any id, so the role travels with the list and stays a value
|
|
31
|
+
* the resolver can compare.
|
|
32
|
+
*/
|
|
33
|
+
export type MailboxRole = "inbox" | "sent" | "other";
|
|
34
|
+
|
|
35
|
+
/** The list being browsed — the first segment under `/mail`. */
|
|
36
|
+
export type MailList =
|
|
37
|
+
| { kind: "brief" }
|
|
38
|
+
| { kind: "flagged" }
|
|
39
|
+
| { kind: "outbox" }
|
|
40
|
+
| { kind: "mailbox"; mailboxId: string; role: MailboxRole };
|
|
41
|
+
|
|
42
|
+
/** Where a go-to key would take you. */
|
|
43
|
+
export type GoToDestination = "brief" | "flagged" | "inbox" | "sent";
|
|
44
|
+
|
|
45
|
+
/** The detail surface open under the list. */
|
|
46
|
+
export type DetailSurface =
|
|
47
|
+
| { kind: "thread"; threadId: string; messageId: string | null }
|
|
48
|
+
| { kind: "compose"; draftId: string | null };
|
|
49
|
+
|
|
50
|
+
/** What the message verbs act on: the focused row, or the open thread. */
|
|
51
|
+
export interface Aim {
|
|
52
|
+
threadId: string;
|
|
53
|
+
messageId: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DetailNode {
|
|
57
|
+
surface: DetailSurface;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Which panes the list holds. One value rather than a visibility flag beside an
|
|
62
|
+
* optional detail, so "neither pane on screen" cannot be built: a hidden list
|
|
63
|
+
* always means the detail has taken the screen.
|
|
64
|
+
*/
|
|
65
|
+
export type ListPane =
|
|
66
|
+
| { layout: "list-only" }
|
|
67
|
+
| { layout: "split"; detail: DetailNode }
|
|
68
|
+
| { layout: "detail-only"; detail: DetailNode };
|
|
69
|
+
|
|
70
|
+
export interface ListNode {
|
|
71
|
+
list: MailList;
|
|
72
|
+
pane: ListPane;
|
|
73
|
+
aim: Aim | null;
|
|
74
|
+
/** Ids of the selected rows; the resolver reads its size, not a flag. */
|
|
75
|
+
selection: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface MailNode {
|
|
79
|
+
list: ListNode | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A modal, sheet or menu. The stack's top frame is the leaf of the tree: it
|
|
84
|
+
* either answers the action or contains it, and never leaks a key to the
|
|
85
|
+
* surface behind it.
|
|
86
|
+
*/
|
|
87
|
+
export interface OverlayFrame {
|
|
88
|
+
id: string;
|
|
89
|
+
handles: readonly TriageAction[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A focused editable surface. Same containment rule as an overlay frame. */
|
|
93
|
+
export interface EditingField {
|
|
94
|
+
id: string;
|
|
95
|
+
handles: readonly TriageAction[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What is mid-transition. `"app"` is a navigation in flight, `"list"` a list
|
|
100
|
+
* still loading; both block every action until they land, so a key pressed
|
|
101
|
+
* during a transition can never act on the surface it was not aimed at.
|
|
102
|
+
*/
|
|
103
|
+
export type SettlingScope = "app" | "list";
|
|
104
|
+
|
|
105
|
+
export interface ShortcutTree {
|
|
106
|
+
overlays: readonly OverlayFrame[];
|
|
107
|
+
editing: EditingField | null;
|
|
108
|
+
settling: SettlingScope | null;
|
|
109
|
+
mail: MailNode | null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The facts a rule can require of the tree. */
|
|
113
|
+
export type ShortcutFlag = "listVisible" | "aimed" | "selection" | "threadOpen";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A precondition on a rule. A bare flag must hold; `{ not: "listVisible" }` is
|
|
117
|
+
* the one negation the tree needs, and it is what gives the detail its own
|
|
118
|
+
* navigation when the list is off screen; `listIsNot` is how a go-to action
|
|
119
|
+
* declares that it is already where it would take you.
|
|
120
|
+
*/
|
|
121
|
+
export type Requirement =
|
|
122
|
+
| ShortcutFlag
|
|
123
|
+
| { not: "listVisible" }
|
|
124
|
+
| { listIsNot: GoToDestination };
|
|
125
|
+
|
|
126
|
+
export interface ActionRule {
|
|
127
|
+
action: TriageAction;
|
|
128
|
+
requires: readonly Requirement[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Why a level that serves this action did not run it. Every reason is reachable
|
|
133
|
+
* and every reason is the caller's to phrase: `list-off-screen` is ordinary
|
|
134
|
+
* phone geometry and says nothing, while `not-aimed` and `no-selection` are
|
|
135
|
+
* worth telling the user about.
|
|
136
|
+
*/
|
|
137
|
+
export type BlockedReason =
|
|
138
|
+
| "settling"
|
|
139
|
+
| "list-settling"
|
|
140
|
+
| "list-off-screen"
|
|
141
|
+
| "list-shown"
|
|
142
|
+
| "not-aimed"
|
|
143
|
+
| "no-selection"
|
|
144
|
+
| "no-thread"
|
|
145
|
+
| "already-there";
|
|
146
|
+
|
|
147
|
+
/** Who runs the action. */
|
|
148
|
+
export type ActTarget =
|
|
149
|
+
| { kind: "overlay"; frame: OverlayFrame }
|
|
150
|
+
| { kind: "editing"; field: EditingField }
|
|
151
|
+
| { kind: "level"; level: ShortcutLevel };
|
|
152
|
+
|
|
153
|
+
/** Who swallowed the action without running it. */
|
|
154
|
+
export type ContainTarget =
|
|
155
|
+
| { kind: "overlay"; frame: OverlayFrame }
|
|
156
|
+
| { kind: "editing"; field: EditingField };
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* `contained` and `unbound` are not the same silence. A contained key belongs to
|
|
160
|
+
* the overlay or the field that swallowed it and must not reach the page behind
|
|
161
|
+
* it; an unbound key was never ours, so the browser keeps it.
|
|
162
|
+
*/
|
|
163
|
+
export type Resolution =
|
|
164
|
+
| { outcome: "act"; target: ActTarget }
|
|
165
|
+
| { outcome: "contained"; by: ContainTarget }
|
|
166
|
+
| { outcome: "blocked"; reason: BlockedReason }
|
|
167
|
+
| { outcome: "unbound" };
|
|
168
|
+
|
|
169
|
+
/** Which actions a host serves, per level. */
|
|
170
|
+
export type RegisteredActions = Readonly<
|
|
171
|
+
Partial<Record<ShortcutLevel, readonly TriageAction[]>>
|
|
172
|
+
>;
|
|
173
|
+
|
|
174
|
+
const LIST_VERBS: readonly TriageAction[] = [
|
|
175
|
+
"reply",
|
|
176
|
+
"replyAll",
|
|
177
|
+
"forward",
|
|
178
|
+
"delete",
|
|
179
|
+
"toggleStar",
|
|
180
|
+
"toggleRead",
|
|
181
|
+
"muteSender",
|
|
182
|
+
"blockSender",
|
|
183
|
+
"vipSender",
|
|
184
|
+
"markJunk",
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const LIST_NAVIGATION: readonly TriageAction[] = [
|
|
188
|
+
"focusNext",
|
|
189
|
+
"focusPrevious",
|
|
190
|
+
"focusFirst",
|
|
191
|
+
"focusLast",
|
|
192
|
+
"openFocused",
|
|
193
|
+
"toggleSelect",
|
|
194
|
+
"extendSelectDown",
|
|
195
|
+
"extendSelectUp",
|
|
196
|
+
"selectAll",
|
|
197
|
+
"toggleDensity",
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const rule = (
|
|
201
|
+
action: TriageAction,
|
|
202
|
+
...requires: Requirement[]
|
|
203
|
+
): ActionRule => ({ action, requires });
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Every action the app answers, declared at the level that owns it. An action
|
|
207
|
+
* may appear at more than one level; the leaf-most rule whose level serves it
|
|
208
|
+
* and whose requirements hold wins.
|
|
209
|
+
*
|
|
210
|
+
* Help and the go-to family sit at `app` on purpose: `?` and `g …` are how you
|
|
211
|
+
* leave a screen, so they must answer on settings, where there is no `mail`
|
|
212
|
+
* node at all. Compose, search and the intelligence rail act on the mail
|
|
213
|
+
* surface and stay at `mail`, where they are correctly dead on settings.
|
|
214
|
+
* `goSettings` carries no `listIsNot` because settings is not a list; "already
|
|
215
|
+
* on settings" is not expressible in a tree rooted at the mail address.
|
|
216
|
+
*/
|
|
217
|
+
export const NODE_ACTIONS: Record<ShortcutLevel, readonly ActionRule[]> = {
|
|
218
|
+
app: [
|
|
219
|
+
rule("help"),
|
|
220
|
+
rule("goBrief", { listIsNot: "brief" }),
|
|
221
|
+
rule("goFlagged", { listIsNot: "flagged" }),
|
|
222
|
+
rule("goInbox", { listIsNot: "inbox" }),
|
|
223
|
+
rule("goSent", { listIsNot: "sent" }),
|
|
224
|
+
rule("goSettings"),
|
|
225
|
+
],
|
|
226
|
+
mail: [rule("compose"), rule("focusSearch"), rule("toggleIntelligence")],
|
|
227
|
+
list: [
|
|
228
|
+
...LIST_NAVIGATION.map((action) => rule(action, "listVisible")),
|
|
229
|
+
...LIST_VERBS.map((action) => rule(action, "aimed")),
|
|
230
|
+
rule("back", "selection"),
|
|
231
|
+
],
|
|
232
|
+
detail: [
|
|
233
|
+
rule("focusNext", "threadOpen", { not: "listVisible" }),
|
|
234
|
+
rule("focusPrevious", "threadOpen", { not: "listVisible" }),
|
|
235
|
+
rule("reply", "threadOpen"),
|
|
236
|
+
rule("replyAll", "threadOpen"),
|
|
237
|
+
rule("forward", "threadOpen"),
|
|
238
|
+
rule("back"),
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const UNMET_REASON: Record<ShortcutFlag, BlockedReason> = {
|
|
243
|
+
listVisible: "list-off-screen",
|
|
244
|
+
aimed: "not-aimed",
|
|
245
|
+
selection: "no-selection",
|
|
246
|
+
threadOpen: "no-thread",
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function listNode(tree: ShortcutTree): ListNode | null {
|
|
250
|
+
return tree.mail?.list ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function detailNode(list: ListNode): DetailNode | null {
|
|
254
|
+
return list.pane.layout === "list-only" ? null : list.pane.detail;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function holds(flag: ShortcutFlag, tree: ShortcutTree): boolean {
|
|
258
|
+
const list = listNode(tree);
|
|
259
|
+
if (!list) return false;
|
|
260
|
+
if (flag === "listVisible") return list.pane.layout !== "detail-only";
|
|
261
|
+
if (flag === "aimed") return list.aim !== null;
|
|
262
|
+
if (flag === "selection") return list.selection.length > 0;
|
|
263
|
+
return detailNode(list)?.surface.kind === "thread";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isAt(list: MailList, destination: GoToDestination): boolean {
|
|
267
|
+
if (list.kind === "mailbox") {
|
|
268
|
+
return destination === list.role;
|
|
269
|
+
}
|
|
270
|
+
return list.kind === destination;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function unmet(
|
|
274
|
+
requirement: Requirement,
|
|
275
|
+
tree: ShortcutTree,
|
|
276
|
+
): BlockedReason | null {
|
|
277
|
+
if (typeof requirement === "string") {
|
|
278
|
+
return holds(requirement, tree) ? null : UNMET_REASON[requirement];
|
|
279
|
+
}
|
|
280
|
+
if ("not" in requirement) {
|
|
281
|
+
return holds(requirement.not, tree) ? "list-shown" : null;
|
|
282
|
+
}
|
|
283
|
+
const list = listNode(tree);
|
|
284
|
+
if (!list) return null;
|
|
285
|
+
return isAt(list.list, requirement.listIsNot) ? "already-there" : null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function firstUnmet(
|
|
289
|
+
requires: readonly Requirement[],
|
|
290
|
+
tree: ShortcutTree,
|
|
291
|
+
): BlockedReason | null {
|
|
292
|
+
for (const requirement of requires) {
|
|
293
|
+
const reason = unmet(requirement, tree);
|
|
294
|
+
if (reason) return reason;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The levels present in this tree, leaf first. */
|
|
300
|
+
function activeLevels(tree: ShortcutTree): ShortcutLevel[] {
|
|
301
|
+
const list = listNode(tree);
|
|
302
|
+
const levels: ShortcutLevel[] = [];
|
|
303
|
+
if (list && detailNode(list)) levels.push("detail");
|
|
304
|
+
if (list) levels.push("list");
|
|
305
|
+
if (tree.mail) levels.push("mail");
|
|
306
|
+
levels.push("app");
|
|
307
|
+
return levels;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isRegistered(
|
|
311
|
+
registered: RegisteredActions,
|
|
312
|
+
level: ShortcutLevel,
|
|
313
|
+
action: TriageAction,
|
|
314
|
+
): boolean {
|
|
315
|
+
return registered[level]?.includes(action) === true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve an action against the tree. Pure: no DOM, no router, no side effects.
|
|
320
|
+
*
|
|
321
|
+
* The overlay stack and the editing field are leaves that pre-empt the level
|
|
322
|
+
* chain entirely. Otherwise the walk runs leaf-to-root over the levels that
|
|
323
|
+
* *serve* this action: the first one whose requirements hold acts, and a level
|
|
324
|
+
* that declares the action but registers no handler is skipped as if it were not
|
|
325
|
+
* there. A level nobody serves never contributes a reason, so an action a screen
|
|
326
|
+
* does not offer stays silent instead of explaining itself.
|
|
327
|
+
*
|
|
328
|
+
* When no served level can run it, the root-most reason is reported: the
|
|
329
|
+
* leaf-most failure is usually the geometry the user did not choose, while the
|
|
330
|
+
* one above it is the thing they can act on.
|
|
331
|
+
*/
|
|
332
|
+
export function resolveShortcut(
|
|
333
|
+
action: TriageAction,
|
|
334
|
+
tree: ShortcutTree,
|
|
335
|
+
registered: RegisteredActions,
|
|
336
|
+
): Resolution {
|
|
337
|
+
const frame = tree.overlays.at(-1);
|
|
338
|
+
if (frame) {
|
|
339
|
+
if (!frame.handles.includes(action)) {
|
|
340
|
+
return { outcome: "contained", by: { kind: "overlay", frame } };
|
|
341
|
+
}
|
|
342
|
+
return { outcome: "act", target: { kind: "overlay", frame } };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const field = tree.editing;
|
|
346
|
+
if (field) {
|
|
347
|
+
if (!field.handles.includes(action)) {
|
|
348
|
+
return { outcome: "contained", by: { kind: "editing", field } };
|
|
349
|
+
}
|
|
350
|
+
return { outcome: "act", target: { kind: "editing", field } };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (tree.settling) {
|
|
354
|
+
const reason = tree.settling === "list" ? "list-settling" : "settling";
|
|
355
|
+
return { outcome: "blocked", reason };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let blockedOn: BlockedReason | null = null;
|
|
359
|
+
for (const level of activeLevels(tree)) {
|
|
360
|
+
for (const candidate of NODE_ACTIONS[level]) {
|
|
361
|
+
if (candidate.action !== action) continue;
|
|
362
|
+
if (!isRegistered(registered, level, action)) continue;
|
|
363
|
+
const reason = firstUnmet(candidate.requires, tree);
|
|
364
|
+
if (!reason) {
|
|
365
|
+
return { outcome: "act", target: { kind: "level", level } };
|
|
366
|
+
}
|
|
367
|
+
blockedOn = reason;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (blockedOn) return { outcome: "blocked", reason: blockedOn };
|
|
372
|
+
return { outcome: "unbound" };
|
|
373
|
+
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
clauseSentence,
|
|
11
11
|
clauseWords,
|
|
12
12
|
crossAccountMatchReason,
|
|
13
|
+
crossFolderDestinationReason,
|
|
14
|
+
crossFolderRuleReason,
|
|
13
15
|
ESCALATED_MATCH_HINT,
|
|
14
16
|
ESCALATED_REVIEW_WARNING,
|
|
15
17
|
escalatedMatchLabel,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
type Verb,
|
|
33
35
|
verbCopy,
|
|
34
36
|
type WizardDraft,
|
|
37
|
+
wizardScopeFor,
|
|
35
38
|
} from "./wizard-steps.js";
|
|
36
39
|
|
|
37
40
|
const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
|
|
@@ -858,3 +861,50 @@ describe("the match as words", () => {
|
|
|
858
861
|
);
|
|
859
862
|
});
|
|
860
863
|
});
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* A selection spanning folders of one account was told to pick a single account
|
|
867
|
+
* (#525) — an instruction a user holding one account's mail cannot follow, so
|
|
868
|
+
* the flow dead-ended on the folder step with nowhere to go.
|
|
869
|
+
*/
|
|
870
|
+
describe("the restriction a selection walks the wizard with", () => {
|
|
871
|
+
it("tells a selection spanning folders about folders, not accounts", () => {
|
|
872
|
+
const scope = wizardScopeFor("acc-personal", "spansFolders");
|
|
873
|
+
assert.equal(scope.destination, crossFolderDestinationReason);
|
|
874
|
+
assert.equal(scope.rule, crossFolderRuleReason);
|
|
875
|
+
assert.match(scope.destination ?? "", /within one folder/);
|
|
876
|
+
assert.doesNotMatch(scope.destination ?? "", /account/);
|
|
877
|
+
assert.doesNotMatch(scope.rule ?? "", /account/);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("keeps the account a folder-spanning selection has", () => {
|
|
881
|
+
// The widened doors are counted through a preview that account answers, so
|
|
882
|
+
// there is nothing to withhold them for.
|
|
883
|
+
const scope = wizardScopeFor("acc-personal", "spansFolders");
|
|
884
|
+
assert.equal(scope.accountId, "acc-personal");
|
|
885
|
+
assert.deepEqual(
|
|
886
|
+
[...matchDoorsFor(scope.accountId)],
|
|
887
|
+
["selected", "similar", "properties"],
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("tells a selection spanning accounts about accounts", () => {
|
|
892
|
+
const scope = wizardScopeFor(undefined, "spansAccounts");
|
|
893
|
+
assert.equal(scope.accountId, undefined);
|
|
894
|
+
assert.match(scope.destination ?? "", /within one account/);
|
|
895
|
+
assert.match(scope.rule ?? "", /within one account/);
|
|
896
|
+
assert.deepEqual([...matchDoorsFor(scope.accountId)], ["selected"]);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("restricts a selection with no account whatever it was handed", () => {
|
|
900
|
+
const scope = wizardScopeFor(undefined, undefined);
|
|
901
|
+
assert.match(scope.destination ?? "", /within one account/);
|
|
902
|
+
assert.match(scope.rule ?? "", /within one account/);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("restricts nothing when one account and one folder answer for it", () => {
|
|
906
|
+
assert.deepEqual(wizardScopeFor("acc-personal", undefined), {
|
|
907
|
+
accountId: "acc-personal",
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
});
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -232,6 +232,74 @@ export const crossAccountDestinationReason =
|
|
|
232
232
|
export const crossAccountMatchReason =
|
|
233
233
|
"Matching beyond the messages you picked only works within one account — clear the selection, or pick messages from a single account.";
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Why a selection spanning folders of one account cannot become a rule (#525).
|
|
237
|
+
* The account is settled and the source folder is not, so the restriction the
|
|
238
|
+
* wizard states is the folder one — telling a single-account selection to pick
|
|
239
|
+
* one account is an instruction it cannot follow.
|
|
240
|
+
*/
|
|
241
|
+
export const crossFolderRuleReason =
|
|
242
|
+
"A rule only works within one folder — clear the selection, or pick messages from a single folder.";
|
|
243
|
+
|
|
244
|
+
/** The same restriction on the step that asks for a folder. */
|
|
245
|
+
export const crossFolderDestinationReason =
|
|
246
|
+
"A destination only works within one folder — clear the selection, or pick messages from a single folder.";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Which scope a selection spans more of than the wizard's folder-scoped steps
|
|
250
|
+
* can take. The surface that resolved the selection knows which one it is, so it
|
|
251
|
+
* hands the case over rather than a flag both cases collapse into.
|
|
252
|
+
*/
|
|
253
|
+
export type SelectionRestriction = "spansAccounts" | "spansFolders";
|
|
254
|
+
|
|
255
|
+
const RESTRICTION_COPY: Record<
|
|
256
|
+
SelectionRestriction,
|
|
257
|
+
{ rule: string; destination: string }
|
|
258
|
+
> = {
|
|
259
|
+
spansAccounts: {
|
|
260
|
+
rule: crossAccountRuleReason,
|
|
261
|
+
destination: crossAccountDestinationReason,
|
|
262
|
+
},
|
|
263
|
+
spansFolders: {
|
|
264
|
+
rule: crossFolderRuleReason,
|
|
265
|
+
destination: crossFolderDestinationReason,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** What a restricted selection leaves the wizard's account-scoped steps to work with. */
|
|
270
|
+
export interface WizardScope {
|
|
271
|
+
/**
|
|
272
|
+
* The account a filter, a folder create and a widened door's preview all
|
|
273
|
+
* belong to. Absent only when the selection has no single account — a
|
|
274
|
+
* selection spanning folders of one account keeps it, so its widened doors
|
|
275
|
+
* have something to count against.
|
|
276
|
+
*/
|
|
277
|
+
accountId?: string;
|
|
278
|
+
/** What the rule step states, when the selection cannot reach a saved scope. */
|
|
279
|
+
rule?: string;
|
|
280
|
+
/** What the folder step states, when the selection cannot reach a destination. */
|
|
281
|
+
destination?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The scope a selection walks the wizard with. A selection with no single
|
|
286
|
+
* account is account-restricted whatever it was handed, since the account is
|
|
287
|
+
* what the filter, the folder create and the preview all hang off.
|
|
288
|
+
*/
|
|
289
|
+
export const wizardScopeFor = (
|
|
290
|
+
accountId: string | undefined,
|
|
291
|
+
restriction: SelectionRestriction | undefined,
|
|
292
|
+
): WizardScope => {
|
|
293
|
+
const applied = accountId === undefined ? "spansAccounts" : restriction;
|
|
294
|
+
if (!applied) return { accountId };
|
|
295
|
+
const copy = RESTRICTION_COPY[applied];
|
|
296
|
+
return {
|
|
297
|
+
accountId: applied === "spansAccounts" ? undefined : accountId,
|
|
298
|
+
rule: copy.rule,
|
|
299
|
+
destination: copy.destination,
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
235
303
|
/**
|
|
236
304
|
* The doors the match step offers. Withholding the two widened ones is what
|
|
237
305
|
* keeps a selection spanning accounts off a review that waits on a count nobody
|