@remit/ui 0.0.122 → 0.0.124
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/app-shell-slotted.tsx +5 -1
- package/src/components/compose-body.language.test.ts +236 -0
- package/src/components/compose-body.stories.tsx +26 -0
- package/src/components/compose-form-shell.render.test.ts +59 -0
- package/src/components/compose-form-shell.tsx +36 -10
- package/src/components/email-frame-css.ts +110 -29
- package/src/components/isolated-email-frame.render.test.ts +174 -36
- package/src/components/isolated-email-frame.stories.tsx +518 -33
- package/src/components/isolated-email-frame.tsx +58 -135
- package/src/components/message-body-view.stories.tsx +200 -2
- package/src/components/message-body-view.tsx +82 -33
- package/src/components/mobile-reading-pane.tsx +2 -1
- package/src/components/reading-pane.stories.tsx +138 -3
- package/src/components/reading-pane.tsx +35 -26
- package/src/components/resizable.tsx +42 -0
- package/src/components/slide-panel.tsx +7 -3
- package/src/index.ts +8 -1
- package/src/lib/compose-language.test.ts +17 -0
- package/src/lib/compose-language.ts +17 -6
- package/src/lib/detect-compose-language.test.ts +18 -0
- package/src/lib/email-layout-clamp.test.ts +30 -0
- package/src/lib/email-layout-clamp.ts +17 -0
- package/src/lib/email-sanitizer.test.ts +153 -0
- package/src/lib/email-sanitizer.ts +165 -0
- package/src/lib/keymap.test.ts +23 -0
- package/src/lib/keymap.ts +61 -4
- package/src/lib/shortcut-tree.test.ts +438 -0
- package/src/lib/shortcut-tree.ts +373 -0
|
@@ -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
|
+
});
|