@remit/web-client 0.0.214 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.214",
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": {
@@ -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
- { key: "j", handler: focusNext, preventDefault: true },
281
- { key: "ArrowDown", handler: focusNext, preventDefault: true },
282
- { key: "k", handler: focusPrevious, preventDefault: true },
283
- { key: "ArrowUp", handler: focusPrevious, preventDefault: true },
284
- { key: "Enter", handler: toggleFocusedMessage, preventDefault: true },
285
- { key: "o", handler: toggleFocusedMessage, preventDefault: true },
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
- ...handlers,
158
+ ...restHandlers,
159
+ ...(hasOpenThread ? {} : { reply, replyAll, forward }),
135
160
  },
136
161
  });
137
162