@remit/web-client 0.0.167 → 0.0.168

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.167",
3
+ "version": "0.0.168",
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": {
@@ -140,7 +140,7 @@ const getReplyAddresses = (
140
140
  displayName: a.displayName,
141
141
  }));
142
142
 
143
- if (mode !== "reply_all") return { to, cc: [] };
143
+ if (mode !== "reply-all") return { to, cc: [] };
144
144
 
145
145
  const myEmailLower = myEmail?.toLowerCase();
146
146
  const toEmails = new Set(to.map((a) => a.email.toLowerCase()));
@@ -353,6 +353,14 @@ export const ComposeForm = ({
353
353
  const [openDocumentId, setOpenDocumentId] = useState(outboxMessageId);
354
354
  const smtpConfigureRef = useRef<HTMLButtonElement>(null);
355
355
  const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
356
+ /**
357
+ * Whether the document on screen came from a draft the server already held.
358
+ * A reply carries the message it answers whether it is being started or being
359
+ * resumed, so this is what separates the two: the source seeds a reply that
360
+ * has nothing behind it, and leaves a saved one holding the recipients and
361
+ * the subject the reader edited.
362
+ */
363
+ const resumedDraftRef = useRef(outboxMessageId !== undefined);
356
364
 
357
365
  // The document this form is on changed under it, so it starts again: another
358
366
  // draft, or no draft at all. Without this the previous one's fields stay on
@@ -373,6 +381,7 @@ export const ComposeForm = ({
373
381
  if (previous === outboxMessageId) return;
374
382
  prevOutboxMessageIdRef.current = outboxMessageId;
375
383
  if (previous === undefined) return;
384
+ resumedDraftRef.current = outboxMessageId !== undefined;
376
385
  // A draft brings its own body along in a moment; a new message opens on
377
386
  // the signature, the same document a fresh mount would have started on.
378
387
  const opening = outboxMessageId
@@ -473,9 +482,10 @@ export const ComposeForm = ({
473
482
  }, [draftData, draftLoaded]);
474
483
 
475
484
  useEffect(() => {
485
+ if (resumedDraftRef.current) return;
476
486
  if (!sourceMessage) return;
477
487
 
478
- if (mode === "reply" || mode === "reply_all") {
488
+ if (mode === "reply" || mode === "reply-all") {
479
489
  const { to, cc } = getReplyAddresses(sourceMessage, mode, account?.email);
480
490
  setToAddresses(to);
481
491
  setCcAddresses(cc);
@@ -493,7 +503,7 @@ export const ComposeForm = ({
493
503
  // to an empty quote when nothing renderable exists (the user can still
494
504
  // attribute the reply manually).
495
505
  const isQuoting =
496
- mode === "reply" || mode === "reply_all" || mode === "forward";
506
+ mode === "reply" || mode === "reply-all" || mode === "forward";
497
507
  const { data: sourceBody } = useMessageBodyContent({
498
508
  messageId: sourceMessage?.message.messageId,
499
509
  bodyParts: sourceMessage?.bodyParts,
@@ -679,7 +689,7 @@ export const ComposeForm = ({
679
689
  stopAutoSave();
680
690
 
681
691
  const replyData =
682
- sourceMessage && (mode === "reply" || mode === "reply_all")
692
+ sourceMessage && (mode === "reply" || mode === "reply-all")
683
693
  ? getReferences(sourceMessage)
684
694
  : {};
685
695
 
@@ -12,8 +12,14 @@ import {
12
12
  useRef,
13
13
  useState,
14
14
  } from "react";
15
+ import type { ReplyMode } from "@/routing";
15
16
 
16
- export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
17
+ /**
18
+ * What a composer opened on. The three ways to answer a message are the path
19
+ * segment `$mode` verbatim, so the address and the form say one word each
20
+ * rather than translating between two vocabularies.
21
+ */
22
+ export type ComposeMode = ReplyMode | "new";
17
23
 
18
24
  /**
19
25
  * A message on its way out outlives the composer that wrote it: the surface
@@ -0,0 +1,57 @@
1
+ import {
2
+ configOperationsGetConfigOptions,
3
+ messageOperationsDescribeMessageOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ import {
7
+ type ReplyAddress,
8
+ useAdoptReplyDraft,
9
+ useCloseReply,
10
+ } from "@/routing";
11
+ import { ComposeForm } from "./ComposeForm";
12
+
13
+ /**
14
+ * The answer to a message, as the top block of the conversation it answers.
15
+ *
16
+ * It takes no height of its own: it is as tall as what has been written in it
17
+ * and pushes the thread down as that grows, so the pane keeps the one scrollbar
18
+ * it had before the reply opened. A height here would give the pane a second
19
+ * one, with the caret in the inner track.
20
+ *
21
+ * Mode, source and draft are all segments of the address, so the surface is on
22
+ * screen because the path says so and is writing to what the path names. The
23
+ * conversation renders it because it belongs inside the thread and because the
24
+ * phone has no reading `Outlet` for a route to fill.
25
+ */
26
+ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
27
+ const adoptCreatedDraft = useAdoptReplyDraft();
28
+ const closeReply = useCloseReply();
29
+
30
+ const { data: sourceMessage } = useQuery({
31
+ ...messageOperationsDescribeMessageOptions({
32
+ path: { messageId: surface.sourceMessageId },
33
+ }),
34
+ });
35
+
36
+ const { data: config } = useQuery({
37
+ ...configOperationsGetConfigOptions(),
38
+ staleTime: Infinity,
39
+ });
40
+
41
+ return (
42
+ <div className="border-b border-line bg-canvas">
43
+ {/* No key on the draft: the first autosave writes the id it created into
44
+ the address, and remounting the form on that would take the caret out
45
+ of the sentence being typed. */}
46
+ <ComposeForm
47
+ layout="flow"
48
+ mode={surface.mode}
49
+ account={config?.accounts?.[0]}
50
+ sourceMessage={sourceMessage}
51
+ outboxMessageId={surface.outboxMessageId}
52
+ onDraftCreated={adoptCreatedDraft}
53
+ onClose={closeReply}
54
+ />
55
+ </div>
56
+ );
57
+ };
@@ -44,6 +44,10 @@ import { useMailContext } from "@/lib/mail-context";
44
44
  import {
45
45
  type OpenThreadPath,
46
46
  type OpenThreadTarget,
47
+ type ReplyMode,
48
+ useIsComposing,
49
+ useIsReplying,
50
+ useOpenReply,
47
51
  useRetainOpenPanels,
48
52
  } from "@/routing";
49
53
 
@@ -70,6 +74,11 @@ interface BriefPaneContextValue {
70
74
  * account — the brief spans accounts, so there is no route mailbox to key by.
71
75
  */
72
76
  actions: ThreadActions;
77
+ /**
78
+ * Answer the conversation the address has open. Absent when it names none,
79
+ * which is what the toolbar turns into its own explanation.
80
+ */
81
+ onReply: ((mode: ReplyMode) => void) | undefined;
73
82
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
74
83
  triage: TriageContext;
75
84
  onDeleteMessages: (messageIds: string[]) => void;
@@ -204,15 +213,47 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
204
213
  const triageTarget = focusedThread ?? selectedThread;
205
214
  const triageActions = useThreadActions({ thread: triageTarget });
206
215
 
216
+ // The toolbar answers the conversation on screen; the keyboard answers the
217
+ // row the cursor is on, which may be one the address has not opened yet.
218
+ // Both are one navigation, because the mode and the message it answers are
219
+ // segments of the same address — there is no open-it-first step for the
220
+ // answering half to be dropped from.
221
+ const isComposing = useIsComposing();
222
+ const isReplying = useIsReplying();
223
+ const openReply = useOpenReply();
224
+ const replyToOpenThread = useMemo(() => {
225
+ if (!selectedThread || !selectedMessageId) return undefined;
226
+ return (mode: ReplyMode) =>
227
+ openReply({
228
+ threadId: selectedThread.threadId,
229
+ messageId: selectedMessageId,
230
+ mode,
231
+ });
232
+ }, [openReply, selectedThread, selectedMessageId]);
233
+
234
+ const replyToFocusedThread = useMemo(() => {
235
+ if (!triageTarget) return undefined;
236
+ return (mode: ReplyMode) =>
237
+ openReply({
238
+ threadId: triageTarget.threadId,
239
+ messageId: triageTarget.messageId,
240
+ mode,
241
+ });
242
+ }, [openReply, triageTarget]);
243
+
207
244
  const { nextMessageId, previousMessageId } = useTriageLayer({
208
245
  context: triage,
209
246
  orderedIds: triage.orderedIds,
210
247
  selectedMessageId,
248
+ // The list stays mounted under both writing surfaces, so the triage keys
249
+ // would otherwise fire at the message behind whatever is being typed — or
250
+ // answer a row the cursor moved to while a reply was open.
251
+ enabled: !isComposing && !isReplying,
211
252
  onClose: handleCloseThread,
212
253
  handlers: {
213
- reply: () => actions.requestCompose("reply"),
214
- replyAll: () => actions.requestCompose("reply_all"),
215
- forward: () => actions.requestCompose("forward"),
254
+ reply: () => replyToFocusedThread?.("reply"),
255
+ replyAll: () => replyToFocusedThread?.("reply-all"),
256
+ forward: () => replyToFocusedThread?.("forward"),
216
257
  // The list takes any verb aimed at its selection and opens the wizard
217
258
  // on it, so a shortcut can never reach a bulk action the bar would have
218
259
  // reviewed. What comes back here is a verb aimed at the bare cursor.
@@ -252,6 +293,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
252
293
  nextThread: adjacentThread(nextMessageId),
253
294
  previousThread: adjacentThread(previousMessageId),
254
295
  actions,
296
+ onReply: replyToOpenThread,
255
297
  triage,
256
298
  onDeleteMessages: deleteMessages,
257
299
  handleDeselectIfRemoved,
@@ -289,7 +331,7 @@ function BriefList() {
289
331
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
290
332
  */
291
333
  function BriefReading() {
292
- const { conversation, actions } = useBriefPane();
334
+ const { conversation, actions, onReply } = useBriefPane();
293
335
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
294
336
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
295
337
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -305,13 +347,9 @@ function BriefReading() {
305
347
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
306
348
  canToggleIntelligence={canToggleIntelligence}
307
349
  onToggleIntelligence={onToggleIntelligence}
308
- onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
309
- onReplyAll={
310
- hasThread ? () => actions.requestCompose("reply_all") : undefined
311
- }
312
- onForward={
313
- hasThread ? () => actions.requestCompose("forward") : undefined
314
- }
350
+ onReply={onReply ? () => onReply("reply") : undefined}
351
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
352
+ onForward={onReply ? () => onReply("forward") : undefined}
315
353
  onDelete={hasThread ? actions.deleteThread : undefined}
316
354
  onToggleStar={hasThread ? actions.toggleStar : undefined}
317
355
  isStarred={actions.isStarred}
@@ -333,8 +371,6 @@ function BriefReading() {
333
371
  subject={conversation.subject}
334
372
  selectedMessageId={conversation.messageId}
335
373
  authenticity={conversation.authenticity}
336
- composeRequest={actions.composeRequest}
337
- onComposeClose={actions.clearComposeRequest}
338
374
  />
339
375
  ) : (
340
376
  <ReadingPaneEmpty />
@@ -1,14 +1,12 @@
1
1
  import {
2
2
  configOperationsGetConfigOptions,
3
- messageOperationsDescribeMessageOptions,
4
3
  threadDetailOperationsListThreadMessagesOptions,
5
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
5
  import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
7
6
  import { MobileReadingPane } from "@remit/ui";
8
7
  import { useQuery } from "@tanstack/react-query";
9
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
- import type { ComposeMode } from "@/components/compose/ComposeProvider";
11
- import { InlineCompose } from "@/components/compose/InlineCompose";
9
+ import { ConversationCompose } from "@/components/compose/ConversationCompose";
12
10
  import { EmptyState } from "@/components/ui/EmptyState";
13
11
  import { ErrorState } from "@/components/ui/ErrorState";
14
12
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
@@ -17,6 +15,7 @@ import { useMarkAsRead } from "@/hooks/useMarkAsRead";
17
15
  import { useIsDesktop } from "@/hooks/useMediaQuery";
18
16
  import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
19
17
  import { useToggleStar } from "@/hooks/useToggleStar";
18
+ import { type ReplyMode, useOpenReply, useReplySurface } from "@/routing";
20
19
  import { AuthenticityBanner } from "./AuthenticityBanner";
21
20
  import { MessageCard } from "./MessageCard";
22
21
 
@@ -47,21 +46,6 @@ interface ConversationViewProps {
47
46
  * list is always visible in the resizable side pane.
48
47
  */
49
48
  onBack?: () => void;
50
- /**
51
- * When set, immediately opens the inline compose in this mode. The
52
- * parent (e.g. the reading-pane toolbar) sets this to wire the
53
- * top-bar reply/reply-all/forward buttons into the inline compose
54
- * that lives inside the conversation. Reset by calling
55
- * `onComposeClose` after the compose opens.
56
- */
57
- composeRequest?: ComposeMode | null;
58
- /**
59
- * Called once the conversation has acted on `composeRequest` (or
60
- * whenever the inline compose is dismissed). The caller must reset
61
- * `composeRequest` to `null`: a request left standing is read as a fresh
62
- * one on every render.
63
- */
64
- onComposeClose?: () => void;
65
49
  /**
66
50
  * Mobile only: swipe-left handler to open the next message in the list the
67
51
  * thread was opened from. Omitted at the end of the list so the gesture
@@ -103,8 +87,6 @@ export const ConversationView = ({
103
87
  authenticity,
104
88
  onOpenIntelligence,
105
89
  onBack,
106
- composeRequest,
107
- onComposeClose,
108
90
  onSwipeNext,
109
91
  onSwipePrevious,
110
92
  mobileIntelligenceOpen,
@@ -240,72 +222,65 @@ export const ConversationView = ({
240
222
  messages,
241
223
  });
242
224
 
243
- // Compose state for inline reply/forward.
244
- // Controlled via the `composeRequest` prop (toolbar wire-up) or
245
- // locally via r/a/f keyboard shortcuts.
246
- const [composeMode, setComposeMode] = useState<ComposeMode | null>(null);
247
-
248
- // Counts openings rather than tracking the mode: replying from a second
249
- // message while compose is already open is the same request again, and the
250
- // reader still has to be taken to it.
251
- const [composeOpenings, setComposeOpenings] = useState(0);
252
- const openCompose = useCallback((mode: ComposeMode) => {
253
- setComposeMode(mode);
254
- setComposeOpenings((count) => count + 1);
255
- }, []);
256
-
257
- // When the toolbar passes a composeRequest, open the inline compose.
258
- useEffect(() => {
259
- if (composeRequest && composeRequest !== "new") {
260
- openCompose(composeRequest);
261
- // Notify the parent that the request has been consumed.
262
- onComposeClose?.();
263
- }
264
- }, [composeRequest, onComposeClose, openCompose]);
265
-
266
- // Reply and forward act on the latest turn of the conversation, which is the
267
- // last message now that the thread reads oldest first.
268
- const { data: latestMessageData } = useQuery({
269
- ...messageOperationsDescribeMessageOptions({
270
- path: { messageId: latestMessage?.messageId ?? "" },
271
- }),
272
- enabled: !!latestMessage && composeMode !== null,
273
- });
225
+ // The reply is a segment under the message it answers, so what is being
226
+ // written and which turn it answers are the address's to state. Nothing here
227
+ // keeps a second opinion about whether a composer is up.
228
+ const reply = useReplySurface();
229
+ const openReply = useOpenReply();
230
+
231
+ // Which turn a verb aimed at the whole conversation answers: the message the
232
+ // address names, or the latest turn when it names none. A per-message button
233
+ // passes its own instead, which the address could not express before this.
234
+ const answeredMessageId = selectedMessageId ?? latestMessage?.messageId;
235
+
236
+ const replyWith = useCallback(
237
+ (mode: ReplyMode, messageId: string | undefined) => {
238
+ if (!messageId) return;
239
+ openReply({ threadId, messageId, mode });
240
+ },
241
+ [openReply, threadId],
242
+ );
274
243
 
275
- const handleReply = useCallback(() => openCompose("reply"), [openCompose]);
244
+ const handleReply = useCallback(
245
+ () => replyWith("reply", answeredMessageId),
246
+ [replyWith, answeredMessageId],
247
+ );
276
248
 
277
249
  const handleReplyAll = useCallback(
278
- () => openCompose("reply_all"),
279
- [openCompose],
250
+ () => replyWith("reply-all", answeredMessageId),
251
+ [replyWith, answeredMessageId],
280
252
  );
281
253
 
282
254
  const handleForward = useCallback(
283
- () => openCompose("forward"),
284
- [openCompose],
255
+ () => replyWith("forward", answeredMessageId),
256
+ [replyWith, answeredMessageId],
285
257
  );
286
258
 
287
- const handleCloseCompose = useCallback(() => {
288
- setComposeMode(null);
289
- onComposeClose?.();
290
- }, [onComposeClose]);
291
-
292
259
  // Compose opens at the head of the pane, which from halfway down a long
293
260
  // thread is several screens up: without this, replying looks like nothing
294
261
  // happening. Aligned on its top edge, where the recipients are, and left
295
262
  // alone afterwards — it grows downward as it is written into, so following
296
263
  // its height would pull the rows being typed into off the top.
264
+ //
265
+ // Keyed on what is being answered and how, not on the draft under it: the
266
+ // address gains a draft segment while the reader types, and scrolling there
267
+ // would move the pane mid-sentence.
297
268
  const composeRef = useRef<HTMLDivElement>(null);
269
+ const answering =
270
+ reply?.kind === "reply"
271
+ ? `${reply.mode}:${reply.sourceMessageId}`
272
+ : undefined;
298
273
  useEffect(() => {
299
- if (composeMode === null || composeOpenings === 0) return;
274
+ if (!answering) return;
300
275
  composeRef.current?.scrollIntoView({
301
276
  behavior: "smooth",
302
277
  block: "start",
303
278
  });
304
- }, [composeOpenings, composeMode]);
279
+ }, [answering]);
305
280
 
306
281
  // Register keyboard shortcuts
307
282
  useKeyboardNavigation({
308
- enabled: !isLoading && messages.length > 0 && composeMode === null,
283
+ enabled: !isLoading && messages.length > 0 && reply === undefined,
309
284
  bindings: [
310
285
  { key: "j", handler: focusNext, preventDefault: true },
311
286
  { key: "ArrowDown", handler: focusNext, preventDefault: true },
@@ -340,10 +315,22 @@ export const ConversationView = ({
340
315
  );
341
316
  }
342
317
 
318
+ // An address naming a reply over a conversation with nothing in it says so.
319
+ // The mailbox is shared with other clients, so a thread can empty underneath
320
+ // the reader between opening it and answering it, and a composer that simply
321
+ // failed to appear reads as a broken button.
343
322
  if (messages.length === 0) {
344
323
  return (
345
- <div className="flex h-full items-center justify-center">
346
- <EmptyState message="No messages in this thread" />
324
+ <div className="flex h-full items-center justify-center p-4">
325
+ {reply ? (
326
+ <ErrorState
327
+ variant="inline"
328
+ title="There is nothing here to answer"
329
+ error="This conversation has no messages left. Another mail client may have moved or deleted them."
330
+ />
331
+ ) : (
332
+ <EmptyState message="No messages in this thread" />
333
+ )}
347
334
  </div>
348
335
  );
349
336
  }
@@ -355,7 +342,8 @@ export const ConversationView = ({
355
342
  // its own px-5 inset (matches the AppShell ReadingPane geometry). On mobile
356
343
  // each expanded card owns a per-message action bar. Its reply verbs are
357
344
  // wired whatever the account's SMTP state: compose is where an account that
358
- // cannot send says so.
345
+ // cannot send says so. A card's verb answers that card's message, which is
346
+ // the turn the address then names.
359
347
  const renderMessages = (mobile: boolean) => (
360
348
  <div data-testid="conversation-messages">
361
349
  {orderedMessages.map((message, index) => (
@@ -376,9 +364,17 @@ export const ConversationView = ({
376
364
  }
377
365
  accountId={mailboxAccountId}
378
366
  mobile={mobile}
379
- onReply={mobile ? handleReply : undefined}
380
- onReplyAll={mobile ? handleReplyAll : undefined}
381
- onForward={mobile ? handleForward : undefined}
367
+ onReply={
368
+ mobile ? () => replyWith("reply", message.messageId) : undefined
369
+ }
370
+ onReplyAll={
371
+ mobile
372
+ ? () => replyWith("reply-all", message.messageId)
373
+ : undefined
374
+ }
375
+ onForward={
376
+ mobile ? () => replyWith("forward", message.messageId) : undefined
377
+ }
382
378
  />
383
379
  </div>
384
380
  ))}
@@ -389,14 +385,23 @@ export const ConversationView = ({
389
385
  // is what the reader came back for, and it is not something to go looking
390
386
  // for under a thread. It scrolls with the conversation rather than floating
391
387
  // over it, so the pane has one scrollbar whether or not a reply is open.
392
- const compose = composeMode !== null && (
388
+ //
389
+ // A segment naming no mode is a hand-typed address, and it says so where the
390
+ // composer would have been rather than opening nothing: the conversation
391
+ // behind it is still open and still readable.
392
+ const compose = reply && (
393
393
  <div ref={composeRef}>
394
- <InlineCompose
395
- mode={composeMode}
396
- account={activeAccount}
397
- sourceMessage={latestMessageData}
398
- onClose={handleCloseCompose}
399
- />
394
+ {reply.kind === "reply" ? (
395
+ <ConversationCompose surface={reply} />
396
+ ) : (
397
+ <div className="border-b border-line bg-canvas p-4">
398
+ <ErrorState
399
+ variant="inline"
400
+ title="That is not a way to answer a message"
401
+ error={`"${reply.segment}" is not reply, reply-all or forward.`}
402
+ />
403
+ </div>
404
+ )}
400
405
  </div>
401
406
  );
402
407
 
@@ -55,6 +55,10 @@ import { useMailContext } from "@/lib/mail-context";
55
55
  import {
56
56
  type OpenThreadPath,
57
57
  type OpenThreadTarget,
58
+ type ReplyMode,
59
+ useIsComposing,
60
+ useIsReplying,
61
+ useOpenReply,
58
62
  useRetainOpenPanels,
59
63
  } from "@/routing";
60
64
 
@@ -81,6 +85,11 @@ interface FlaggedPaneContextValue {
81
85
  * account — Flagged spans accounts, so there is no route mailbox to key by.
82
86
  */
83
87
  actions: ThreadActions;
88
+ /**
89
+ * Answer the conversation the address has open. Absent when it names none,
90
+ * which is what the toolbar turns into its own explanation.
91
+ */
92
+ onReply: ((mode: ReplyMode) => void) | undefined;
84
93
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
85
94
  triage: TriageContext;
86
95
  onDeleteMessages: (messageIds: string[]) => void;
@@ -210,15 +219,47 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
210
219
  const triageTarget = focusedThread ?? selectedThread;
211
220
  const triageActions = useThreadActions({ thread: triageTarget });
212
221
 
222
+ // The toolbar answers the conversation on screen; the keyboard answers the
223
+ // row the cursor is on, which may be one the address has not opened yet.
224
+ // Both are one navigation, because the mode and the message it answers are
225
+ // segments of the same address — there is no open-it-first step for the
226
+ // answering half to be dropped from.
227
+ const isComposing = useIsComposing();
228
+ const isReplying = useIsReplying();
229
+ const openReply = useOpenReply();
230
+ const replyToOpenThread = useMemo(() => {
231
+ if (!selectedThread || !selectedMessageId) return undefined;
232
+ return (mode: ReplyMode) =>
233
+ openReply({
234
+ threadId: selectedThread.threadId,
235
+ messageId: selectedMessageId,
236
+ mode,
237
+ });
238
+ }, [openReply, selectedThread, selectedMessageId]);
239
+
240
+ const replyToFocusedThread = useMemo(() => {
241
+ if (!triageTarget) return undefined;
242
+ return (mode: ReplyMode) =>
243
+ openReply({
244
+ threadId: triageTarget.threadId,
245
+ messageId: triageTarget.messageId,
246
+ mode,
247
+ });
248
+ }, [openReply, triageTarget]);
249
+
213
250
  const { nextMessageId, previousMessageId } = useTriageLayer({
214
251
  context: triage,
215
252
  orderedIds: triage.orderedIds,
216
253
  selectedMessageId,
254
+ // The list stays mounted under both writing surfaces, so the triage keys
255
+ // would otherwise fire at the message behind whatever is being typed — or
256
+ // answer a row the cursor moved to while a reply was open.
257
+ enabled: !isComposing && !isReplying,
217
258
  onClose: handleCloseThread,
218
259
  handlers: {
219
- reply: () => actions.requestCompose("reply"),
220
- replyAll: () => actions.requestCompose("reply_all"),
221
- forward: () => actions.requestCompose("forward"),
260
+ reply: () => replyToFocusedThread?.("reply"),
261
+ replyAll: () => replyToFocusedThread?.("reply-all"),
262
+ forward: () => replyToFocusedThread?.("forward"),
222
263
  // The list takes any verb aimed at its selection and opens the wizard
223
264
  // on it, so a shortcut can never reach a bulk action the bar would have
224
265
  // reviewed. What comes back here is a verb aimed at the bare cursor.
@@ -258,6 +299,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
258
299
  nextThread: adjacentThread(nextMessageId),
259
300
  previousThread: adjacentThread(previousMessageId),
260
301
  actions,
302
+ onReply: replyToOpenThread,
261
303
  triage,
262
304
  onDeleteMessages: deleteMessages,
263
305
  handleDeselectIfRemoved,
@@ -292,7 +334,7 @@ function FlaggedListSlot() {
292
334
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
293
335
  */
294
336
  function FlaggedReading() {
295
- const { conversation, actions } = useFlaggedPane();
337
+ const { conversation, actions, onReply } = useFlaggedPane();
296
338
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
297
339
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
298
340
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -308,13 +350,9 @@ function FlaggedReading() {
308
350
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
309
351
  canToggleIntelligence={canToggleIntelligence}
310
352
  onToggleIntelligence={onToggleIntelligence}
311
- onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
312
- onReplyAll={
313
- hasThread ? () => actions.requestCompose("reply_all") : undefined
314
- }
315
- onForward={
316
- hasThread ? () => actions.requestCompose("forward") : undefined
317
- }
353
+ onReply={onReply ? () => onReply("reply") : undefined}
354
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
355
+ onForward={onReply ? () => onReply("forward") : undefined}
318
356
  onDelete={hasThread ? actions.deleteThread : undefined}
319
357
  onToggleStar={hasThread ? actions.toggleStar : undefined}
320
358
  isStarred={actions.isStarred}
@@ -336,8 +374,6 @@ function FlaggedReading() {
336
374
  subject={conversation.subject}
337
375
  selectedMessageId={conversation.messageId}
338
376
  authenticity={conversation.authenticity}
339
- composeRequest={actions.composeRequest}
340
- onComposeClose={actions.clearComposeRequest}
341
377
  />
342
378
  ) : (
343
379
  <ReadingPaneEmpty />