@remit/web-client 0.0.213 → 0.0.215
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/layout/Drawer.focus.test.ts +33 -0
- package/src/components/layout/Drawer.tsx +12 -8
- package/src/components/mail/BriefPane.tsx +2 -0
- package/src/components/mail/ConversationView.tsx +26 -6
- package/src/components/mail/FlaggedPane.tsx +2 -0
- package/src/components/mail/MailboxPane.tsx +2 -0
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +591 -0
- package/src/hooks/useTriageLayer.ts +26 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.215",
|
|
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": {
|
|
@@ -206,6 +206,39 @@ describe("Drawer's ring is what a reader can reach", () => {
|
|
|
206
206
|
|
|
207
207
|
assert.equal(document.activeElement, named("Cancel"));
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The pane renders that dialog without moving focus into it, so focus sits
|
|
212
|
+
* on the drawer control that opened it — now behind the dialog's backdrop.
|
|
213
|
+
* Tab has to enter the dialog's ring rather than walk the controls the
|
|
214
|
+
* backdrop covers.
|
|
215
|
+
*/
|
|
216
|
+
it("pulls focus behind a nested dialog's backdrop into its ring", () => {
|
|
217
|
+
const nestedDialog = createElement(
|
|
218
|
+
"div",
|
|
219
|
+
{
|
|
220
|
+
key: "nested",
|
|
221
|
+
role: "dialog",
|
|
222
|
+
"aria-modal": "true",
|
|
223
|
+
"aria-label": "Reclassify sender",
|
|
224
|
+
},
|
|
225
|
+
button("automated"),
|
|
226
|
+
button("Cancel"),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
open(createElement(Fragment, null, button("trigger"), nestedDialog));
|
|
230
|
+
named("trigger").focus();
|
|
231
|
+
|
|
232
|
+
press("Tab");
|
|
233
|
+
|
|
234
|
+
assert.equal(document.activeElement, named("automated"));
|
|
235
|
+
|
|
236
|
+
named("trigger").focus();
|
|
237
|
+
|
|
238
|
+
press("Tab", true);
|
|
239
|
+
|
|
240
|
+
assert.equal(document.activeElement, named("Cancel"));
|
|
241
|
+
});
|
|
209
242
|
});
|
|
210
243
|
|
|
211
244
|
describe("Drawer gives focus back on the way out", () => {
|
|
@@ -73,25 +73,29 @@ export const Drawer = ({
|
|
|
73
73
|
onClose();
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
76
|
+
// Trap Tab inside the drawer's ring: walking off either end cycles
|
|
77
|
+
// round, and focus that is in the drawer but outside the ring — a
|
|
78
|
+
// control behind a nested dialog's backdrop — is pulled in, so Tab
|
|
79
|
+
// never lands where a pointer cannot reach (#747). The trap only
|
|
80
|
+
// applies while the drawer already holds focus; an error banner or
|
|
81
|
+
// the fatal-error overlay above it keeps its own Tab (#970).
|
|
81
82
|
if (event.key !== "Tab" || !drawerRef.current) return;
|
|
83
|
+
const active = document.activeElement;
|
|
84
|
+
if (!(active instanceof HTMLElement)) return;
|
|
85
|
+
if (!drawerRef.current.contains(active)) return;
|
|
82
86
|
const ring = tabRing(drawerRef.current);
|
|
83
87
|
if (ring.length === 0) return;
|
|
84
88
|
const first = ring[0];
|
|
85
89
|
const last = ring[ring.length - 1];
|
|
86
|
-
const
|
|
90
|
+
const inside = ring.includes(active);
|
|
87
91
|
if (event.shiftKey) {
|
|
88
|
-
if (active === first) {
|
|
92
|
+
if (!inside || active === first) {
|
|
89
93
|
event.preventDefault();
|
|
90
94
|
last.focus();
|
|
91
95
|
}
|
|
92
96
|
return;
|
|
93
97
|
}
|
|
94
|
-
if (active === last) {
|
|
98
|
+
if (!inside || active === last) {
|
|
95
99
|
event.preventDefault();
|
|
96
100
|
first.focus();
|
|
97
101
|
}
|
|
@@ -234,6 +234,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
234
234
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
235
235
|
// answer a row the cursor moved to while a reply was open.
|
|
236
236
|
enabled: !isComposing && !isReplying,
|
|
237
|
+
hasOpenThread: threadId !== undefined,
|
|
237
238
|
onClose: closeThread,
|
|
238
239
|
handlers: {
|
|
239
240
|
reply: () => replyToFocusedThread?.("reply"),
|
|
@@ -359,6 +360,7 @@ function BriefReading() {
|
|
|
359
360
|
selectedMessageId={conversation.messageId}
|
|
360
361
|
authenticity={conversation.authenticity}
|
|
361
362
|
onOpenIntelligence={intelligence.open}
|
|
363
|
+
listOnScreen
|
|
362
364
|
/>
|
|
363
365
|
) : (
|
|
364
366
|
<ReadingPaneEmpty />
|
|
@@ -56,6 +56,14 @@ interface ConversationViewProps {
|
|
|
56
56
|
onSwipePrevious?: () => void;
|
|
57
57
|
/** Whether the intelligence drawer is currently open (drives the ⓘ button pressed state). */
|
|
58
58
|
mobileIntelligenceOpen?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The message list is on screen beside this conversation, so it — not this
|
|
61
|
+
* pane — owns j/k, the arrows and Enter (`shortcut-tree`: those sit at
|
|
62
|
+
* `detail` only where no list is visible). Both used to act on the same
|
|
63
|
+
* press, moving two cursors at once (#723). Answering the conversation is
|
|
64
|
+
* unaffected: r, R and f are the open thread's at every width.
|
|
65
|
+
*/
|
|
66
|
+
listOnScreen?: boolean;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
const LoadingSkeleton = () => (
|
|
@@ -87,6 +95,7 @@ export const ConversationView = ({
|
|
|
87
95
|
onSwipeNext,
|
|
88
96
|
onSwipePrevious,
|
|
89
97
|
mobileIntelligenceOpen,
|
|
98
|
+
listOnScreen = false,
|
|
90
99
|
}: ConversationViewProps) => {
|
|
91
100
|
const isDesktop = useIsDesktop();
|
|
92
101
|
const { handlers: swipeHandlers } = useSwipeNavigation({
|
|
@@ -277,12 +286,23 @@ export const ConversationView = ({
|
|
|
277
286
|
useKeyboardNavigation({
|
|
278
287
|
enabled: !isLoading && messages.length > 0 && reply === undefined,
|
|
279
288
|
bindings: [
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
// Left unbound rather than bound and guarded: an unbound key is never
|
|
290
|
+
// preventDefault-ed, so it reaches the list's own window listener
|
|
291
|
+
// instead of the two racing on one press.
|
|
292
|
+
...(listOnScreen
|
|
293
|
+
? []
|
|
294
|
+
: [
|
|
295
|
+
{ key: "j", handler: focusNext, preventDefault: true },
|
|
296
|
+
{ key: "ArrowDown", handler: focusNext, preventDefault: true },
|
|
297
|
+
{ key: "k", handler: focusPrevious, preventDefault: true },
|
|
298
|
+
{ key: "ArrowUp", handler: focusPrevious, preventDefault: true },
|
|
299
|
+
{
|
|
300
|
+
key: "Enter",
|
|
301
|
+
handler: toggleFocusedMessage,
|
|
302
|
+
preventDefault: true,
|
|
303
|
+
},
|
|
304
|
+
{ key: "o", handler: toggleFocusedMessage, preventDefault: true },
|
|
305
|
+
]),
|
|
286
306
|
{ key: "r", handler: handleReply, preventDefault: true },
|
|
287
307
|
{
|
|
288
308
|
key: "R",
|
|
@@ -240,6 +240,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
240
240
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
241
241
|
// answer a row the cursor moved to while a reply was open.
|
|
242
242
|
enabled: !isComposing && !isReplying,
|
|
243
|
+
hasOpenThread: threadId !== undefined,
|
|
243
244
|
onClose: closeThread,
|
|
244
245
|
handlers: {
|
|
245
246
|
reply: () => replyToFocusedThread?.("reply"),
|
|
@@ -362,6 +363,7 @@ function FlaggedReading() {
|
|
|
362
363
|
selectedMessageId={conversation.messageId}
|
|
363
364
|
authenticity={conversation.authenticity}
|
|
364
365
|
onOpenIntelligence={intelligence.open}
|
|
366
|
+
listOnScreen
|
|
365
367
|
/>
|
|
366
368
|
) : (
|
|
367
369
|
<ReadingPaneEmpty />
|
|
@@ -726,6 +726,7 @@ function MailboxPaneProvider({
|
|
|
726
726
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
727
727
|
// answer a row the cursor moved to while a reply was open.
|
|
728
728
|
enabled: !isComposing && !isReplying,
|
|
729
|
+
hasOpenThread: threadId !== undefined,
|
|
729
730
|
onClose: closeThread,
|
|
730
731
|
handlers: {
|
|
731
732
|
reply: triageReply,
|
|
@@ -1123,6 +1124,7 @@ function MailboxReading() {
|
|
|
1123
1124
|
selectedMessageId={conversation.messageId}
|
|
1124
1125
|
authenticity={conversation.authenticity}
|
|
1125
1126
|
onOpenIntelligence={intelligence.open}
|
|
1127
|
+
listOnScreen
|
|
1126
1128
|
/>
|
|
1127
1129
|
) : (
|
|
1128
1130
|
<ReadingPaneEmpty />
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* With a thread open, one press must move one cursor and answer one message
|
|
3
|
+
* (#723).
|
|
4
|
+
*
|
|
5
|
+
* `useTriageLayer` (the pane's list cursor) and `ConversationView` (the open
|
|
6
|
+
* conversation's own message cursor) each bind their own keydown listener on
|
|
7
|
+
* `window`. Both used to act on the same press, so opening a thread and
|
|
8
|
+
* pressing `j` once walked the list underneath it and the conversation on top
|
|
9
|
+
* of it at the same time.
|
|
10
|
+
*
|
|
11
|
+
* Each key is settled where `shortcut-tree` already declares it, rather than
|
|
12
|
+
* handing the open thread everything: `focusNext`/`focusPrevious` sit at
|
|
13
|
+
* `detail` only where no list is visible, so a list on screen keeps the cursor
|
|
14
|
+
* keys and `ConversationView` stands down from them; `reply`/`replyAll`/
|
|
15
|
+
* `forward` sit at `detail` on `threadOpen` alone, so the open thread answers
|
|
16
|
+
* at every width and the layer drops all three.
|
|
17
|
+
*
|
|
18
|
+
* The first half stands the list in with a stub `MessageListCommands` — the
|
|
19
|
+
* same seam `MailboxPane` wires the real list through (`listCommandsRef`), so
|
|
20
|
+
* every key the list would have served is recorded rather than inferred,
|
|
21
|
+
* without the real list's virtualization along for the ride. The second half
|
|
22
|
+
* gives up that reading to mount a real pane instead, because what each pane
|
|
23
|
+
* passes for `hasOpenThread` is the other half of the handover.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import { afterEach, describe, it } from "node:test";
|
|
28
|
+
import type {
|
|
29
|
+
RemitImapDescribeMessageResponse,
|
|
30
|
+
RemitImapThreadMessageResponse,
|
|
31
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
32
|
+
import {
|
|
33
|
+
type AnyRouter,
|
|
34
|
+
createMemoryHistory,
|
|
35
|
+
createRootRoute,
|
|
36
|
+
createRoute,
|
|
37
|
+
createRouter,
|
|
38
|
+
Outlet,
|
|
39
|
+
RouterProvider,
|
|
40
|
+
} from "@tanstack/react-router";
|
|
41
|
+
import { createElement, useEffect } from "react";
|
|
42
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
43
|
+
import { useTriageContext, useTriageLayer } from "@/hooks/useTriageLayer";
|
|
44
|
+
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
45
|
+
import { useOpenThreadPath } from "@/routing";
|
|
46
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
47
|
+
import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
|
|
48
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
49
|
+
import { BriefPane } from "./BriefPane";
|
|
50
|
+
import { ConversationView } from "./ConversationView";
|
|
51
|
+
import type { MessageListCommands } from "./MessageList";
|
|
52
|
+
|
|
53
|
+
const ACCOUNT_ID = "acc-1";
|
|
54
|
+
const THREAD_ID = "thread-1";
|
|
55
|
+
const MAILBOX_ID = "mbx-inbox";
|
|
56
|
+
const MESSAGE_ID = "msg-1";
|
|
57
|
+
const OTHER_MESSAGE_ID = "msg-other";
|
|
58
|
+
|
|
59
|
+
const account = makeAccount({ accountId: ACCOUNT_ID });
|
|
60
|
+
|
|
61
|
+
const OPENING_SENT = 1_767_139_200_000;
|
|
62
|
+
const LATEST_SENT = 1_767_225_600_000;
|
|
63
|
+
|
|
64
|
+
const openingMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
65
|
+
messageId: "msg-0",
|
|
66
|
+
threadId: THREAD_ID,
|
|
67
|
+
mailboxId: MAILBOX_ID,
|
|
68
|
+
accountId: ACCOUNT_ID,
|
|
69
|
+
subject: "Lunch Thursday?",
|
|
70
|
+
fromName: "Grace Hopper",
|
|
71
|
+
fromEmail: "grace@example.com",
|
|
72
|
+
sentDate: OPENING_SENT,
|
|
73
|
+
isRead: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
77
|
+
messageId: MESSAGE_ID,
|
|
78
|
+
threadId: THREAD_ID,
|
|
79
|
+
mailboxId: MAILBOX_ID,
|
|
80
|
+
accountId: ACCOUNT_ID,
|
|
81
|
+
subject: "Lunch Thursday?",
|
|
82
|
+
fromName: "Ada Lovelace",
|
|
83
|
+
fromEmail: "ada@example.com",
|
|
84
|
+
sentDate: LATEST_SENT,
|
|
85
|
+
isRead: true,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const describeMessage: RemitImapDescribeMessageResponse = {
|
|
89
|
+
message: {
|
|
90
|
+
messageId: MESSAGE_ID,
|
|
91
|
+
mailboxId: MAILBOX_ID,
|
|
92
|
+
uid: 1,
|
|
93
|
+
rfc822Size: 512,
|
|
94
|
+
internalDate: LATEST_SENT,
|
|
95
|
+
},
|
|
96
|
+
envelope: {
|
|
97
|
+
messageId: MESSAGE_ID,
|
|
98
|
+
date: LATEST_SENT,
|
|
99
|
+
subject: "Lunch Thursday?",
|
|
100
|
+
messageIdValue: "<ada-1@example.com>",
|
|
101
|
+
from: [
|
|
102
|
+
{
|
|
103
|
+
addressId: "addr-ada",
|
|
104
|
+
displayName: "Ada Lovelace",
|
|
105
|
+
normalizedEmail: "ada@example.com",
|
|
106
|
+
addressRole: "from",
|
|
107
|
+
addressOrder: 0,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
to: [
|
|
111
|
+
{
|
|
112
|
+
addressId: "addr-me",
|
|
113
|
+
normalizedEmail: "alice@example.com",
|
|
114
|
+
addressRole: "to",
|
|
115
|
+
addressOrder: 0,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
cc: [],
|
|
119
|
+
bcc: [],
|
|
120
|
+
replyTo: [],
|
|
121
|
+
category: "uncategorized",
|
|
122
|
+
senderTrust: "unknown",
|
|
123
|
+
},
|
|
124
|
+
flags: ["\\Seen"],
|
|
125
|
+
bodyParts: [],
|
|
126
|
+
references: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let harness: DomHarness | undefined;
|
|
130
|
+
let http: HttpMock | undefined;
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
harness?.close();
|
|
134
|
+
harness = undefined;
|
|
135
|
+
http?.restore();
|
|
136
|
+
http = undefined;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
140
|
+
// `window`.
|
|
141
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
142
|
+
|
|
143
|
+
const LIST_PATH = `/mail/${MAILBOX_ID}`;
|
|
144
|
+
const MESSAGE_PATH = `${LIST_PATH}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
145
|
+
|
|
146
|
+
const press = async (mounted: DomHarness, key: string): Promise<void> => {
|
|
147
|
+
mounted.dispatch(
|
|
148
|
+
mounted.window,
|
|
149
|
+
new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
150
|
+
);
|
|
151
|
+
await mounted.flush();
|
|
152
|
+
await mounted.wait(20);
|
|
153
|
+
await mounted.flush();
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The two shapes a pane takes with a thread open: `two-pane` is the desktop
|
|
158
|
+
* reading pane, where the list stays mounted beside the conversation, and
|
|
159
|
+
* `conversation-only` is the phone view, where opening a thread takes the list
|
|
160
|
+
* off screen entirely.
|
|
161
|
+
*/
|
|
162
|
+
type Surface = "two-pane" | "conversation-only";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The pane's own wiring, the way `MailboxPane` binds it to the real list: the
|
|
166
|
+
* layer over a stub list, with the conversation mounted above it exactly while
|
|
167
|
+
* a thread is open. Every list action and every pane verb the stub is asked for
|
|
168
|
+
* is recorded, so a press that reaches the list as well as the conversation
|
|
169
|
+
* shows up here.
|
|
170
|
+
*/
|
|
171
|
+
function PaneShell({
|
|
172
|
+
hasOpenThread,
|
|
173
|
+
surface,
|
|
174
|
+
onListAction,
|
|
175
|
+
}: {
|
|
176
|
+
hasOpenThread: boolean;
|
|
177
|
+
surface: Surface;
|
|
178
|
+
onListAction: (action: string) => void;
|
|
179
|
+
}) {
|
|
180
|
+
const triage = useTriageContext();
|
|
181
|
+
const hasList = surface === "two-pane";
|
|
182
|
+
|
|
183
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: registered once, the way a mounted list reports itself once — the stub never changes shape across the test.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const commands: MessageListCommands = {
|
|
186
|
+
focusNext: () => onListAction("focusNext"),
|
|
187
|
+
focusPrevious: () => onListAction("focusPrevious"),
|
|
188
|
+
focusFirst: () => onListAction("focusFirst"),
|
|
189
|
+
focusLast: () => onListAction("focusLast"),
|
|
190
|
+
openFocused: () => onListAction("openFocused"),
|
|
191
|
+
toggleSelect: () => onListAction("toggleSelect"),
|
|
192
|
+
extendSelectDown: () => onListAction("extendSelectDown"),
|
|
193
|
+
extendSelectUp: () => onListAction("extendSelectUp"),
|
|
194
|
+
selectAll: () => onListAction("selectAll"),
|
|
195
|
+
clearSelection: () => false,
|
|
196
|
+
requestVerb: () => false,
|
|
197
|
+
toggleDensity: () => onListAction("toggleDensity"),
|
|
198
|
+
};
|
|
199
|
+
triage.listCommandsRef.current = hasList ? commands : null;
|
|
200
|
+
triage.onTriageContextChange({
|
|
201
|
+
focusedMessageId: MESSAGE_ID,
|
|
202
|
+
selectedIds: [],
|
|
203
|
+
hasList,
|
|
204
|
+
blocksKeyboard: false,
|
|
205
|
+
orderedIds: [MESSAGE_ID, OTHER_MESSAGE_ID],
|
|
206
|
+
});
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
useTriageLayer({
|
|
210
|
+
context: triage,
|
|
211
|
+
orderedIds: [MESSAGE_ID, OTHER_MESSAGE_ID],
|
|
212
|
+
selectedMessageId: MESSAGE_ID,
|
|
213
|
+
hasOpenThread,
|
|
214
|
+
onClose: () => undefined,
|
|
215
|
+
handlers: {
|
|
216
|
+
reply: () => onListAction("reply"),
|
|
217
|
+
replyAll: () => onListAction("replyAll"),
|
|
218
|
+
forward: () => onListAction("forward"),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (!hasOpenThread) return null;
|
|
223
|
+
|
|
224
|
+
return createElement(ConversationView, {
|
|
225
|
+
threadId: THREAD_ID,
|
|
226
|
+
mailboxId: MAILBOX_ID,
|
|
227
|
+
subject: "Lunch Thursday?",
|
|
228
|
+
selectedMessageId: MESSAGE_ID,
|
|
229
|
+
listOnScreen: hasList,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const testRouter = (
|
|
234
|
+
href: string,
|
|
235
|
+
surface: Surface,
|
|
236
|
+
onListAction: (action: string) => void,
|
|
237
|
+
): AnyRouter => {
|
|
238
|
+
const rootRoute = createRootRoute({
|
|
239
|
+
component: () =>
|
|
240
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
241
|
+
});
|
|
242
|
+
const mailboxRoute = createRoute({
|
|
243
|
+
getParentRoute: () => rootRoute,
|
|
244
|
+
path: "/mail/$mailboxId",
|
|
245
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
246
|
+
});
|
|
247
|
+
const listRoute = createRoute({
|
|
248
|
+
getParentRoute: () => mailboxRoute,
|
|
249
|
+
path: "/",
|
|
250
|
+
component: () =>
|
|
251
|
+
createElement(PaneShell, {
|
|
252
|
+
hasOpenThread: false,
|
|
253
|
+
surface: "two-pane",
|
|
254
|
+
onListAction,
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
const threadRoute = createRoute({
|
|
258
|
+
getParentRoute: () => mailboxRoute,
|
|
259
|
+
path: "$threadId",
|
|
260
|
+
});
|
|
261
|
+
const messageRoute = createRoute({
|
|
262
|
+
getParentRoute: () => threadRoute,
|
|
263
|
+
path: "$messageId",
|
|
264
|
+
component: () =>
|
|
265
|
+
createElement(PaneShell, {
|
|
266
|
+
hasOpenThread: true,
|
|
267
|
+
surface,
|
|
268
|
+
onListAction,
|
|
269
|
+
}),
|
|
270
|
+
});
|
|
271
|
+
const replyRoute = createRoute({
|
|
272
|
+
getParentRoute: () => messageRoute,
|
|
273
|
+
path: "$mode/{-$outboxMessageId}",
|
|
274
|
+
});
|
|
275
|
+
const routeTree = rootRoute.addChildren([
|
|
276
|
+
mailboxRoute.addChildren([
|
|
277
|
+
listRoute,
|
|
278
|
+
threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
|
|
279
|
+
]),
|
|
280
|
+
]);
|
|
281
|
+
return createRouter({
|
|
282
|
+
routeTree,
|
|
283
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
284
|
+
}) as unknown as AnyRouter;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const mount = async (
|
|
288
|
+
href: string,
|
|
289
|
+
surface: Surface,
|
|
290
|
+
onListAction: (action: string) => void,
|
|
291
|
+
): Promise<[DomHarness, AnyRouter]> => {
|
|
292
|
+
http = mockFetch((call) => {
|
|
293
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
294
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
295
|
+
return { items: [openingMessage, threadMessage] };
|
|
296
|
+
}
|
|
297
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
298
|
+
return { items: [] };
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const router = testRouter(href, surface, onListAction);
|
|
302
|
+
await router.load();
|
|
303
|
+
const mounted = createDomHarness();
|
|
304
|
+
harness = mounted;
|
|
305
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
306
|
+
await mounted.flush();
|
|
307
|
+
await mounted.wait(20);
|
|
308
|
+
await mounted.flush();
|
|
309
|
+
return [mounted, router];
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** The inset ring `MessageCard` draws on the row the keyboard is sitting on. */
|
|
313
|
+
const FOCUS_RING = /ring-accent\//;
|
|
314
|
+
|
|
315
|
+
/** The turn the conversation's own cursor has, read off the ring it draws. */
|
|
316
|
+
const focusedTurn = (mounted: DomHarness): string => {
|
|
317
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
318
|
+
assert.ok(thread, "the thread is on screen");
|
|
319
|
+
const focused = [...thread.children].filter((card) =>
|
|
320
|
+
[...card.querySelectorAll("*")].some((node) =>
|
|
321
|
+
FOCUS_RING.test(node.getAttribute("class") ?? ""),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
assert.equal(focused.length, 1, "one turn carries the conversation cursor");
|
|
325
|
+
return focused[0]?.textContent ?? "";
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
describe("one press with a thread open beside the list (#723)", () => {
|
|
329
|
+
it("j moves the list's cursor and leaves the conversation's alone", async () => {
|
|
330
|
+
const listActions: string[] = [];
|
|
331
|
+
const [mounted] = await mount(MESSAGE_PATH, "two-pane", (action) =>
|
|
332
|
+
listActions.push(action),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
assert.ok(
|
|
336
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
337
|
+
"the conversation cursor starts on the latest turn",
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
await press(mounted, "j");
|
|
341
|
+
|
|
342
|
+
assert.deepEqual(
|
|
343
|
+
listActions,
|
|
344
|
+
["focusNext"],
|
|
345
|
+
"the visible list is the one surface j moves, and it did not move",
|
|
346
|
+
);
|
|
347
|
+
assert.ok(
|
|
348
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
349
|
+
"the conversation walked its own turns as well, so one press moved two cursors",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("Home and End reach the list the reader can see", async () => {
|
|
354
|
+
const listActions: string[] = [];
|
|
355
|
+
const [mounted] = await mount(MESSAGE_PATH, "two-pane", (action) =>
|
|
356
|
+
listActions.push(action),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await press(mounted, "Home");
|
|
360
|
+
await press(mounted, "End");
|
|
361
|
+
|
|
362
|
+
assert.deepEqual(
|
|
363
|
+
listActions,
|
|
364
|
+
["focusFirst", "focusLast"],
|
|
365
|
+
"the ends of the visible list stopped answering with a thread open",
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("r answers the open conversation rather than the row the pane is aimed at", async () => {
|
|
370
|
+
const listActions: string[] = [];
|
|
371
|
+
const [mounted, router] = await mount(MESSAGE_PATH, "two-pane", (action) =>
|
|
372
|
+
listActions.push(action),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
await press(mounted, "r");
|
|
376
|
+
|
|
377
|
+
assert.equal(
|
|
378
|
+
router.state.location.pathname,
|
|
379
|
+
`${MESSAGE_PATH}/reply`,
|
|
380
|
+
"r answered something other than the message on screen",
|
|
381
|
+
);
|
|
382
|
+
assert.deepEqual(
|
|
383
|
+
listActions,
|
|
384
|
+
[],
|
|
385
|
+
"the pane answered as well, so one press opened two replies",
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// `a` is the verb with no binding in the conversation — it answers with `R`
|
|
390
|
+
// there. Left registered on the pane it would answer the row the list cursor
|
|
391
|
+
// happens to be on, which is a different message than `r` just answered.
|
|
392
|
+
it("a goes nowhere rather than answering a different message than r", async () => {
|
|
393
|
+
const listActions: string[] = [];
|
|
394
|
+
const [mounted, router] = await mount(MESSAGE_PATH, "two-pane", (action) =>
|
|
395
|
+
listActions.push(action),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await press(mounted, "a");
|
|
399
|
+
|
|
400
|
+
assert.deepEqual(
|
|
401
|
+
listActions,
|
|
402
|
+
[],
|
|
403
|
+
"the pane answered a message of its own choosing under the open conversation",
|
|
404
|
+
);
|
|
405
|
+
assert.equal(
|
|
406
|
+
router.state.location.pathname,
|
|
407
|
+
MESSAGE_PATH,
|
|
408
|
+
"a press that must be inert moved the address",
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("one press with the conversation alone on screen (#723)", () => {
|
|
414
|
+
it("j moves the conversation's own cursor once the list is off screen", async () => {
|
|
415
|
+
const listActions: string[] = [];
|
|
416
|
+
const [mounted] = await mount(MESSAGE_PATH, "conversation-only", (action) =>
|
|
417
|
+
listActions.push(action),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
assert.ok(
|
|
421
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
422
|
+
"the conversation cursor starts on the latest turn",
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
await press(mounted, "j");
|
|
426
|
+
|
|
427
|
+
assert.ok(
|
|
428
|
+
focusedTurn(mounted).includes("Grace Hopper"),
|
|
429
|
+
"the conversation lost j to a list the reader cannot see",
|
|
430
|
+
);
|
|
431
|
+
assert.deepEqual(listActions, [], "an unmounted list still took the press");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("the verbs with no thread open (#723)", () => {
|
|
436
|
+
it("hands r, a and f back to the pane", async () => {
|
|
437
|
+
const listActions: string[] = [];
|
|
438
|
+
const [mounted] = await mount(LIST_PATH, "two-pane", (action) =>
|
|
439
|
+
listActions.push(action),
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
await press(mounted, "r");
|
|
443
|
+
await press(mounted, "a");
|
|
444
|
+
await press(mounted, "f");
|
|
445
|
+
|
|
446
|
+
assert.deepEqual(
|
|
447
|
+
listActions,
|
|
448
|
+
["reply", "replyAll", "forward"],
|
|
449
|
+
"the layer kept the verbs dropped after the conversation closed",
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The other half: what a pane passes for `hasOpenThread`. The brief is mounted
|
|
456
|
+
* whole — its own list, its own reading pane, and the address as the only thing
|
|
457
|
+
* that says whether a thread is open. `a` is what reads it, because the
|
|
458
|
+
* conversation binds `R` for reply-all and never plain `a`: with the thread open
|
|
459
|
+
* the key must reach nothing at all, and with it closed the same pane must
|
|
460
|
+
* answer the row its cursor is on.
|
|
461
|
+
*/
|
|
462
|
+
const BRIEF_LIST_PATH = "/mail/brief";
|
|
463
|
+
const BRIEF_MESSAGE_PATH = `${BRIEF_LIST_PATH}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
464
|
+
|
|
465
|
+
function BriefLayout() {
|
|
466
|
+
return createElement(BriefPane, {
|
|
467
|
+
thread: useOpenThreadPath(),
|
|
468
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
469
|
+
children: [
|
|
470
|
+
createElement(BriefPane.List, { key: "list" }),
|
|
471
|
+
createElement(Outlet, { key: "reading" }),
|
|
472
|
+
],
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const briefRouter = (href: string): AnyRouter => {
|
|
477
|
+
const rootRoute = createRootRoute({
|
|
478
|
+
component: () =>
|
|
479
|
+
createElement(
|
|
480
|
+
ComposeProvider,
|
|
481
|
+
null,
|
|
482
|
+
createElement(MailFreshnessProvider, {
|
|
483
|
+
accountIds: [],
|
|
484
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
485
|
+
children: createElement(Outlet),
|
|
486
|
+
}),
|
|
487
|
+
),
|
|
488
|
+
});
|
|
489
|
+
const mailRoute = createRoute({
|
|
490
|
+
getParentRoute: () => rootRoute,
|
|
491
|
+
path: "/mail",
|
|
492
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
493
|
+
component: Outlet,
|
|
494
|
+
});
|
|
495
|
+
// Present so `useBrowsedList` has the route its `from` names, the way the
|
|
496
|
+
// generated tree does.
|
|
497
|
+
const mailboxRoute = createRoute({
|
|
498
|
+
getParentRoute: () => mailRoute,
|
|
499
|
+
path: "/$mailboxId",
|
|
500
|
+
component: Outlet,
|
|
501
|
+
});
|
|
502
|
+
const briefRoute = createRoute({
|
|
503
|
+
getParentRoute: () => mailRoute,
|
|
504
|
+
path: "/brief",
|
|
505
|
+
component: BriefLayout,
|
|
506
|
+
});
|
|
507
|
+
const threadRoute = createRoute({
|
|
508
|
+
getParentRoute: () => briefRoute,
|
|
509
|
+
path: "$threadId",
|
|
510
|
+
component: Outlet,
|
|
511
|
+
});
|
|
512
|
+
const messageRoute = createRoute({
|
|
513
|
+
getParentRoute: () => threadRoute,
|
|
514
|
+
path: "$messageId",
|
|
515
|
+
component: () => createElement(BriefPane.Reading),
|
|
516
|
+
});
|
|
517
|
+
const replyRoute = createRoute({
|
|
518
|
+
getParentRoute: () => messageRoute,
|
|
519
|
+
path: "$mode/{-$outboxMessageId}",
|
|
520
|
+
});
|
|
521
|
+
const routeTree = rootRoute.addChildren([
|
|
522
|
+
mailRoute.addChildren([
|
|
523
|
+
mailboxRoute,
|
|
524
|
+
briefRoute.addChildren([
|
|
525
|
+
threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
|
|
526
|
+
]),
|
|
527
|
+
]),
|
|
528
|
+
]);
|
|
529
|
+
return createRouter({
|
|
530
|
+
routeTree,
|
|
531
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
532
|
+
}) as unknown as AnyRouter;
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Narrow, where the reading pane does not follow the cursor: on desktop the
|
|
537
|
+
* first `j` would open the row it lands on, which is the very state the closed
|
|
538
|
+
* case is here to read.
|
|
539
|
+
*/
|
|
540
|
+
const mountBrief = async (href: string): Promise<[DomHarness, AnyRouter]> => {
|
|
541
|
+
http = mockFetch((call) => {
|
|
542
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
543
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
544
|
+
return { items: [threadMessage] };
|
|
545
|
+
}
|
|
546
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
547
|
+
if (call.path.includes("/threads")) return { items: [threadMessage] };
|
|
548
|
+
return { items: [] };
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const router = briefRouter(href);
|
|
552
|
+
await router.load();
|
|
553
|
+
const mounted = createDomHarness({
|
|
554
|
+
viewportWidth: 420,
|
|
555
|
+
pointer: "coarse",
|
|
556
|
+
orientation: "portrait",
|
|
557
|
+
});
|
|
558
|
+
harness = mounted;
|
|
559
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
560
|
+
await mounted.flush();
|
|
561
|
+
await mounted.wait(20);
|
|
562
|
+
await mounted.flush();
|
|
563
|
+
return [mounted, router];
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
describe("a real pane's own answer for hasOpenThread (#723)", () => {
|
|
567
|
+
it("leaves a with nothing to do while the brief has a thread open", async () => {
|
|
568
|
+
const [mounted, router] = await mountBrief(BRIEF_MESSAGE_PATH);
|
|
569
|
+
|
|
570
|
+
await press(mounted, "a");
|
|
571
|
+
|
|
572
|
+
assert.equal(
|
|
573
|
+
router.state.location.pathname,
|
|
574
|
+
BRIEF_MESSAGE_PATH,
|
|
575
|
+
"the brief answered a message of its own choosing under the open conversation",
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("answers the cursor's row with a once the brief has no thread open", async () => {
|
|
580
|
+
const [mounted, router] = await mountBrief(BRIEF_LIST_PATH);
|
|
581
|
+
|
|
582
|
+
await press(mounted, "j");
|
|
583
|
+
await press(mounted, "a");
|
|
584
|
+
|
|
585
|
+
assert.equal(
|
|
586
|
+
router.state.location.pathname,
|
|
587
|
+
`${BRIEF_MESSAGE_PATH}/reply-all`,
|
|
588
|
+
"the brief kept a dropped with no conversation to hand it to",
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* It is two hooks because a pane's verbs are aimed at the focused row: the
|
|
14
14
|
* context comes first, the pane builds its handlers from it, and the keyboard
|
|
15
15
|
* registration comes last.
|
|
16
|
+
*
|
|
17
|
+
* While a thread is open, `ConversationView` binds its own window listener for
|
|
18
|
+
* the messages inside it, and both used to act on one press (#723). The split
|
|
19
|
+
* is the one `shortcut-tree` already declares: the cursor keys belong to
|
|
20
|
+
* whichever surface has a list on screen, and answering a thread belongs to
|
|
21
|
+
* the open thread — so `hasOpenThread` hands r, a and f over, and the list
|
|
22
|
+
* keeps j/k, Home/End and Enter for as long as it is mounted.
|
|
16
23
|
*/
|
|
17
24
|
import { type TriageHandlers, useTriageKeyboard } from "@remit/ui";
|
|
18
25
|
import { type RefObject, useCallback, useRef, useState } from "react";
|
|
@@ -83,6 +90,16 @@ interface UseTriageLayerOptions {
|
|
|
83
90
|
onClose: () => void;
|
|
84
91
|
/** The pane's own verbs — reply, star, delete and the rest. */
|
|
85
92
|
handlers: TriageHandlers;
|
|
93
|
+
/**
|
|
94
|
+
* A thread is open, so `ConversationView` is mounted and answers for it.
|
|
95
|
+
* This layer drops the three verbs aimed at a whole message — `reply`,
|
|
96
|
+
* `replyAll` and `forward` — while that is true, so one press opens one
|
|
97
|
+
* reply (#723) and `a` can never answer a different message than `r` just
|
|
98
|
+
* did. The cursor keys are not in here: a mounted list keeps those at every
|
|
99
|
+
* width, and it is the conversation that stands down from them wherever the
|
|
100
|
+
* list is on screen beside it (`ConversationView.listOnScreen`).
|
|
101
|
+
*/
|
|
102
|
+
hasOpenThread?: boolean;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export interface TriageLayer {
|
|
@@ -99,6 +116,7 @@ export const useTriageLayer = ({
|
|
|
99
116
|
enabled = true,
|
|
100
117
|
onClose,
|
|
101
118
|
handlers,
|
|
119
|
+
hasOpenThread = false,
|
|
102
120
|
}: UseTriageLayerOptions): TriageLayer => {
|
|
103
121
|
const { listCommandsRef, hasList, blocksKeyboard } = context;
|
|
104
122
|
|
|
@@ -107,6 +125,12 @@ export const useTriageLayer = ({
|
|
|
107
125
|
if (selectedMessageId) onClose();
|
|
108
126
|
}, [listCommandsRef, selectedMessageId, onClose]);
|
|
109
127
|
|
|
128
|
+
// Dropped rather than kept and guarded: an unregistered action is never
|
|
129
|
+
// preventDefault-ed (see useTriageKeyboard), so the keystroke falls through
|
|
130
|
+
// to ConversationView's own listener instead of two handlers racing on one
|
|
131
|
+
// keypress.
|
|
132
|
+
const { reply, replyAll, forward, ...restHandlers } = handlers;
|
|
133
|
+
|
|
110
134
|
useTriageKeyboard({
|
|
111
135
|
// A modal owns the keyboard outright. Suspending the layer is what keeps a
|
|
112
136
|
// second Delete press from reaching a delete while the confirmation for the
|
|
@@ -131,7 +155,8 @@ export const useTriageLayer = ({
|
|
|
131
155
|
}
|
|
132
156
|
: {}),
|
|
133
157
|
back: goBack,
|
|
134
|
-
...
|
|
158
|
+
...restHandlers,
|
|
159
|
+
...(hasOpenThread ? {} : { reply, replyAll, forward }),
|
|
135
160
|
},
|
|
136
161
|
});
|
|
137
162
|
|