@remit/web-client 0.0.167 → 0.0.169
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/compose/ComposeForm.tsx +14 -4
- package/src/components/compose/ComposeProvider.tsx +7 -1
- package/src/components/compose/ConversationCompose.tsx +57 -0
- package/src/components/mail/BriefPane.tsx +49 -13
- package/src/components/mail/ConversationView.tsx +82 -77
- package/src/components/mail/FlaggedPane.tsx +49 -13
- package/src/components/mail/MailboxPane.tsx +55 -55
- package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
- package/src/hooks/useSearchMirror.ts +10 -6
- package/src/hooks/useThreadActions.ts +8 -17
- package/src/routeTree.gen.ts +128 -15
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +26 -0
- package/src/routes/mail/brief/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/flagged/$threadId/$messageId.tsx +4 -0
- package/src/routing/browsed-list.ts +22 -0
- package/src/routing/compose.ts +2 -17
- package/src/routing/index.ts +12 -0
- package/src/routing/reply.ts +282 -0
- package/src/components/compose/InlineCompose.tsx +0 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.169",
|
|
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 !== "
|
|
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 === "
|
|
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 === "
|
|
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 === "
|
|
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
|
-
|
|
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: () =>
|
|
214
|
-
replyAll: () =>
|
|
215
|
-
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={
|
|
309
|
-
onReplyAll={
|
|
310
|
-
|
|
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
|
|
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
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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(
|
|
244
|
+
const handleReply = useCallback(
|
|
245
|
+
() => replyWith("reply", answeredMessageId),
|
|
246
|
+
[replyWith, answeredMessageId],
|
|
247
|
+
);
|
|
276
248
|
|
|
277
249
|
const handleReplyAll = useCallback(
|
|
278
|
-
() =>
|
|
279
|
-
[
|
|
250
|
+
() => replyWith("reply-all", answeredMessageId),
|
|
251
|
+
[replyWith, answeredMessageId],
|
|
280
252
|
);
|
|
281
253
|
|
|
282
254
|
const handleForward = useCallback(
|
|
283
|
-
() =>
|
|
284
|
-
[
|
|
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 (
|
|
274
|
+
if (!answering) return;
|
|
300
275
|
composeRef.current?.scrollIntoView({
|
|
301
276
|
behavior: "smooth",
|
|
302
277
|
block: "start",
|
|
303
278
|
});
|
|
304
|
-
}, [
|
|
279
|
+
}, [answering]);
|
|
305
280
|
|
|
306
281
|
// Register keyboard shortcuts
|
|
307
282
|
useKeyboardNavigation({
|
|
308
|
-
enabled: !isLoading && messages.length > 0 &&
|
|
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
|
-
|
|
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={
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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: () =>
|
|
220
|
-
replyAll: () =>
|
|
221
|
-
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={
|
|
312
|
-
onReplyAll={
|
|
313
|
-
|
|
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 />
|