@remit/web-client 0.0.214 → 0.0.216
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/BriefPane.tsx +2 -0
- package/src/components/mail/ConversationView.tsx +26 -6
- package/src/components/mail/DeleteConfirmDialog.render.test.ts +37 -21
- package/src/components/mail/DeleteConfirmDialog.tsx +26 -14
- package/src/components/mail/FlaggedPane.tsx +2 -0
- package/src/components/mail/MailboxPane.tsx +2 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +21 -12
- package/src/components/mail/MessageList.tsx +2 -2
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +1 -0
- package/src/components/mail/ThreadListInteraction.tsx +2 -2
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +591 -0
- package/src/hooks/escalated-run-refusal.render.test.ts +156 -0
- package/src/hooks/useDeleteOutcome.ts +18 -13
- package/src/hooks/useEscalatedActions.ts +42 -2
- package/src/hooks/useTriageLayer.ts +26 -1
- package/src/lib/bulk-action-copy.ts +2 -2
- package/src/lib/format.test.ts +50 -41
- package/src/lib/format.ts +43 -32
|
@@ -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
|
+
});
|