@remit/web-client 0.0.166 → 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 +1 -1
- package/src/components/compose/ComposeForm.tsx +91 -29
- package/src/components/compose/ComposeProvider.tsx +19 -124
- package/src/components/compose/ConversationCompose.tsx +57 -0
- package/src/components/compose/FullCompose.tsx +29 -18
- package/src/components/compose/MobileComposeSheet.tsx +14 -18
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
- package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
- package/src/components/compose/compose-title.ts +10 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
- package/src/components/layout/ComposeFab.tsx +6 -15
- package/src/components/layout/MailShell.tsx +9 -1
- package/src/components/layout/MailTopBar.tsx +3 -6
- package/src/components/mail/BriefPane.tsx +49 -13
- package/src/components/mail/ConversationView.tsx +82 -77
- package/src/components/mail/DraftsView.tsx +12 -14
- package/src/components/mail/FlaggedPane.tsx +49 -13
- package/src/components/mail/MailboxPane.tsx +77 -167
- package/src/components/mail/OutboxPane.tsx +3 -9
- package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
- package/src/hooks/useSaveDraft.ts +11 -0
- package/src/hooks/useSearchMirror.ts +12 -1
- package/src/hooks/useThreadActions.ts +8 -17
- package/src/lib/mail-route.test.ts +35 -1
- package/src/lib/mail-route.ts +2 -1
- package/src/routeTree.gen.ts +220 -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/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -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/brief/compose.{-$outboxMessageId}.tsx +22 -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/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
- package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
- package/src/routes/mail.tsx +12 -8
- package/src/routing/browsed-list.ts +22 -0
- package/src/routing/compose-press-opens.render.test.ts +179 -0
- package/src/routing/compose.ts +205 -0
- package/src/routing/index.ts +20 -0
- package/src/routing/reply.ts +282 -0
- package/src/components/compose/InlineCompose.tsx +0 -37
- package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
- package/src/hooks/useComposeTargetMailbox.ts +0 -75
- package/src/lib/compose-routes.test.ts +0 -46
- package/src/lib/compose-routes.ts +0 -25
|
@@ -16,8 +16,6 @@
|
|
|
16
16
|
* On phone, use `<MailboxPane.Phone />` instead of the slot sub-views.
|
|
17
17
|
*/
|
|
18
18
|
import {
|
|
19
|
-
outboxDetailOperationsDeleteOutboxMessageMutation,
|
|
20
|
-
outboxOperationsListOutboxMessagesQueryKey,
|
|
21
19
|
threadOperationsListThreadsQueryKey,
|
|
22
20
|
threadOperationsSearchThreadsQueryKey,
|
|
23
21
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
@@ -35,11 +33,7 @@ import {
|
|
|
35
33
|
type SearchResult,
|
|
36
34
|
useAppShellLayout,
|
|
37
35
|
} from "@remit/ui";
|
|
38
|
-
import {
|
|
39
|
-
useInfiniteQuery,
|
|
40
|
-
useMutation,
|
|
41
|
-
useQueryClient,
|
|
42
|
-
} from "@tanstack/react-query";
|
|
36
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
43
37
|
import { useNavigate } from "@tanstack/react-router";
|
|
44
38
|
import {
|
|
45
39
|
createContext,
|
|
@@ -52,9 +46,6 @@ import {
|
|
|
52
46
|
useRef,
|
|
53
47
|
useState,
|
|
54
48
|
} from "react";
|
|
55
|
-
import type { ComposeMode } from "@/components/compose/ComposeProvider";
|
|
56
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
57
|
-
import { FullCompose } from "@/components/compose/FullCompose";
|
|
58
49
|
import { Drawer } from "@/components/layout/Drawer";
|
|
59
50
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
60
51
|
import { DraftsView } from "@/components/mail/DraftsView";
|
|
@@ -66,8 +57,6 @@ import {
|
|
|
66
57
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
67
58
|
import { PullToRefresh } from "@/components/mail/PullToRefresh";
|
|
68
59
|
import { SpamRescue } from "@/components/mail/SpamRescue";
|
|
69
|
-
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
70
|
-
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
71
60
|
import {
|
|
72
61
|
useArchiveMailbox,
|
|
73
62
|
useDraftsMailbox,
|
|
@@ -83,7 +72,6 @@ import {
|
|
|
83
72
|
} from "@/hooks/useDeleteMessages";
|
|
84
73
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
85
74
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
86
|
-
import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
|
|
87
75
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
88
76
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
89
77
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -127,6 +115,11 @@ import {
|
|
|
127
115
|
import {
|
|
128
116
|
type OpenThreadPath,
|
|
129
117
|
type OpenThreadTarget,
|
|
118
|
+
type ReplyMode,
|
|
119
|
+
useIsComposing,
|
|
120
|
+
useIsReplying,
|
|
121
|
+
useOpenCompose,
|
|
122
|
+
useOpenReply,
|
|
130
123
|
useRetainOpenPanels,
|
|
131
124
|
} from "@/routing";
|
|
132
125
|
import { MailViewChrome } from "./MailViewChrome";
|
|
@@ -208,18 +201,14 @@ interface MailboxPaneContextValue {
|
|
|
208
201
|
listCommandsRef: RefObject<MessageListCommands | null>;
|
|
209
202
|
onRetry: () => void;
|
|
210
203
|
// Toolbar / reading pane actions
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Answer the open conversation. Absent when none is open, which is what the
|
|
206
|
+
* toolbar turns into its own explanation.
|
|
207
|
+
*/
|
|
208
|
+
onReply: ((mode: ReplyMode) => void) | undefined;
|
|
216
209
|
onToolbarDelete: () => void;
|
|
217
210
|
onToolbarStar: () => void;
|
|
218
|
-
onToolbarDiscardDraft: () => void;
|
|
219
211
|
onToolbarMove: (destMailboxId: string) => void;
|
|
220
|
-
composeState: ReturnType<typeof useCompose>["state"];
|
|
221
|
-
closeCompose: () => void;
|
|
222
|
-
hasRemitDraftOpen: boolean;
|
|
223
212
|
// Phone actions
|
|
224
213
|
onBack: () => void;
|
|
225
214
|
/** The rows either side of the open one — the phone's swipe gestures. */
|
|
@@ -539,9 +528,6 @@ function MailboxPaneProvider({
|
|
|
539
528
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
540
529
|
const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
|
|
541
530
|
|
|
542
|
-
const queryClient = useQueryClient();
|
|
543
|
-
const { pushError } = useErrorBanners();
|
|
544
|
-
|
|
545
531
|
const toolbarActions = useThreadActions({
|
|
546
532
|
thread: selectedThread,
|
|
547
533
|
mailboxId,
|
|
@@ -557,60 +543,35 @@ function MailboxPaneProvider({
|
|
|
557
543
|
|
|
558
544
|
const getThreadMessageIds = useThreadMessageIds();
|
|
559
545
|
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
546
|
+
const openReply = useOpenReply();
|
|
547
|
+
|
|
548
|
+
// The toolbar answers the conversation on screen; the keyboard answers the
|
|
549
|
+
// row the cursor is on, which may be one the address has not opened yet. Both
|
|
550
|
+
// are one navigation, because the mode and the message it answers are
|
|
551
|
+
// segments of the same address.
|
|
552
|
+
const replyToOpenThread = useMemo(() => {
|
|
553
|
+
if (!selectedThread || !selectedMessageId) return undefined;
|
|
554
|
+
return (mode: ReplyMode) =>
|
|
555
|
+
openReply({
|
|
556
|
+
threadId: selectedThread.threadId,
|
|
557
|
+
messageId: selectedMessageId,
|
|
558
|
+
mode,
|
|
559
|
+
});
|
|
560
|
+
}, [openReply, selectedThread, selectedMessageId]);
|
|
561
|
+
|
|
562
|
+
const replyToFocusedThread = useMemo(() => {
|
|
563
|
+
if (!focusedThread) return undefined;
|
|
564
|
+
return (mode: ReplyMode) =>
|
|
565
|
+
openReply({
|
|
566
|
+
threadId: focusedThread.threadId,
|
|
567
|
+
messageId: focusedThread.messageId,
|
|
568
|
+
mode,
|
|
569
|
+
});
|
|
570
|
+
}, [openReply, focusedThread]);
|
|
573
571
|
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
// A thread opening closes compose. Only a selection arriving counts, so this
|
|
579
|
-
// cannot close the compose that just cleared one.
|
|
580
|
-
const previousSelectionRef = useRef(selectedMessageId);
|
|
581
|
-
useEffect(() => {
|
|
582
|
-
const previous = previousSelectionRef.current;
|
|
583
|
-
previousSelectionRef.current = selectedMessageId;
|
|
584
|
-
if (!selectedMessageId || selectedMessageId === previous) return;
|
|
585
|
-
closeCompose();
|
|
586
|
-
}, [selectedMessageId, closeCompose]);
|
|
587
|
-
|
|
588
|
-
const deleteOutboxMutation = useMutation({
|
|
589
|
-
...outboxDetailOperationsDeleteOutboxMessageMutation(),
|
|
590
|
-
onError: (mutationError) => {
|
|
591
|
-
pushError(
|
|
592
|
-
buildMutationErrorBanner(
|
|
593
|
-
"Couldn't discard draft",
|
|
594
|
-
"The draft wasn't deleted.",
|
|
595
|
-
mutationError,
|
|
596
|
-
),
|
|
597
|
-
);
|
|
598
|
-
},
|
|
599
|
-
});
|
|
600
|
-
const handleToolbarDiscardDraft = useCallback(() => {
|
|
601
|
-
const outboxMessageId = composeState.outboxMessageId;
|
|
602
|
-
if (!outboxMessageId) return;
|
|
603
|
-
deleteOutboxMutation.mutate({ path: { outboxMessageId } });
|
|
604
|
-
queryClient.invalidateQueries({
|
|
605
|
-
queryKey: outboxOperationsListOutboxMessagesQueryKey(),
|
|
606
|
-
});
|
|
607
|
-
closeCompose();
|
|
608
|
-
}, [
|
|
609
|
-
composeState.outboxMessageId,
|
|
610
|
-
deleteOutboxMutation,
|
|
611
|
-
queryClient,
|
|
612
|
-
closeCompose,
|
|
613
|
-
]);
|
|
572
|
+
const isComposing = useIsComposing();
|
|
573
|
+
const isReplying = useIsReplying();
|
|
574
|
+
const openCompose = useOpenCompose();
|
|
614
575
|
|
|
615
576
|
const messageIdsForFocusedThread = useCallback(
|
|
616
577
|
(thread: typeof focusedThread): string[] => {
|
|
@@ -655,26 +616,18 @@ function MailboxPaneProvider({
|
|
|
655
616
|
senderEmail: focusedThread?.fromEmail ?? undefined,
|
|
656
617
|
});
|
|
657
618
|
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
const triageReplyAll = useCallback(() => {
|
|
671
|
-
if (ensureFocusedOpen()) return;
|
|
672
|
-
if (selectedThread) setToolbarComposeRequest("reply_all");
|
|
673
|
-
}, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
|
|
674
|
-
const triageForward = useCallback(() => {
|
|
675
|
-
if (ensureFocusedOpen()) return;
|
|
676
|
-
if (selectedThread) setToolbarComposeRequest("forward");
|
|
677
|
-
}, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
|
|
619
|
+
const triageReply = useCallback(
|
|
620
|
+
() => replyToFocusedThread?.("reply"),
|
|
621
|
+
[replyToFocusedThread],
|
|
622
|
+
);
|
|
623
|
+
const triageReplyAll = useCallback(
|
|
624
|
+
() => replyToFocusedThread?.("reply-all"),
|
|
625
|
+
[replyToFocusedThread],
|
|
626
|
+
);
|
|
627
|
+
const triageForward = useCallback(
|
|
628
|
+
() => replyToFocusedThread?.("forward"),
|
|
629
|
+
[replyToFocusedThread],
|
|
630
|
+
);
|
|
678
631
|
|
|
679
632
|
const triageTargetMessageIds = useCallback(
|
|
680
633
|
(): string[] => messageIdsForFocusedThread(focusedThread),
|
|
@@ -784,17 +737,14 @@ function MailboxPaneProvider({
|
|
|
784
737
|
}
|
|
785
738
|
}, [normalizedSearchQuery, mailboxType, telemetry]);
|
|
786
739
|
|
|
787
|
-
const hasRemitDraftOpen =
|
|
788
|
-
isDraftsMailbox &&
|
|
789
|
-
composeState.isOpen &&
|
|
790
|
-
!!composeState.outboxMessageId &&
|
|
791
|
-
!selectedThread;
|
|
792
|
-
|
|
793
740
|
const { goBack, nextMessageId, previousMessageId } = useTriageLayer({
|
|
794
741
|
context: triage,
|
|
795
742
|
orderedIds: threads.map((t) => t.messageId),
|
|
796
743
|
selectedMessageId,
|
|
797
|
-
|
|
744
|
+
// The list stays mounted under both writing surfaces, so the triage keys
|
|
745
|
+
// would otherwise fire at the message behind whatever is being typed — or
|
|
746
|
+
// answer a row the cursor moved to while a reply was open.
|
|
747
|
+
enabled: !isComposing && !isReplying,
|
|
798
748
|
onClose: closeThread,
|
|
799
749
|
handlers: {
|
|
800
750
|
reply: triageReply,
|
|
@@ -808,7 +758,7 @@ function MailboxPaneProvider({
|
|
|
808
758
|
vipSender: triageVip,
|
|
809
759
|
markJunk: triageMarkJunk,
|
|
810
760
|
toggleIntelligence: selectedThread ? onToggleIntelligence : undefined,
|
|
811
|
-
compose:
|
|
761
|
+
compose: openCompose,
|
|
812
762
|
goBrief: () => goToRoute("/mail/brief"),
|
|
813
763
|
goInbox: () => goToRoute("/mail/brief"),
|
|
814
764
|
goSent: () => goToRoute("/mail/brief"),
|
|
@@ -817,11 +767,6 @@ function MailboxPaneProvider({
|
|
|
817
767
|
},
|
|
818
768
|
});
|
|
819
769
|
|
|
820
|
-
useKeyboardNavigation({
|
|
821
|
-
enabled: composeState.isOpen,
|
|
822
|
-
bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
|
|
823
|
-
});
|
|
824
|
-
|
|
825
770
|
// The swipe gestures open a whole conversation, so the adjacent row has to
|
|
826
771
|
// name its thread. This folder's own listing is where that is looked up, so a
|
|
827
772
|
// row it does not hold offers no gesture rather than a tap that goes nowhere.
|
|
@@ -877,18 +822,10 @@ function MailboxPaneProvider({
|
|
|
877
822
|
onTriageContextChange: handleTriageContextChange,
|
|
878
823
|
listCommandsRef,
|
|
879
824
|
onRetry: () => refetch(),
|
|
880
|
-
|
|
881
|
-
onToolbarReply: handleToolbarReply,
|
|
882
|
-
onToolbarReplyAll: handleToolbarReplyAll,
|
|
883
|
-
onToolbarForward: handleToolbarForward,
|
|
884
|
-
onClearComposeRequest: toolbarActions.clearComposeRequest,
|
|
825
|
+
onReply: replyToOpenThread,
|
|
885
826
|
onToolbarDelete: toolbarActions.deleteThread,
|
|
886
827
|
onToolbarStar: toolbarActions.toggleStar,
|
|
887
|
-
onToolbarDiscardDraft: handleToolbarDiscardDraft,
|
|
888
828
|
onToolbarMove: toolbarActions.moveThread,
|
|
889
|
-
composeState,
|
|
890
|
-
closeCompose,
|
|
891
|
-
hasRemitDraftOpen,
|
|
892
829
|
onBack: goBack,
|
|
893
830
|
nextThread: adjacentThread(nextMessageId),
|
|
894
831
|
previousThread: adjacentThread(previousMessageId),
|
|
@@ -1138,19 +1075,12 @@ function MailboxReading() {
|
|
|
1138
1075
|
mailboxAccountId,
|
|
1139
1076
|
selectedThread,
|
|
1140
1077
|
conversation,
|
|
1141
|
-
hasRemitDraftOpen,
|
|
1142
1078
|
intelligenceOpen,
|
|
1143
1079
|
onToggleIntelligence,
|
|
1144
|
-
|
|
1145
|
-
onToolbarReply,
|
|
1146
|
-
onToolbarReplyAll,
|
|
1147
|
-
onToolbarForward,
|
|
1148
|
-
onClearComposeRequest,
|
|
1080
|
+
onReply,
|
|
1149
1081
|
onToolbarDelete,
|
|
1150
1082
|
onToolbarStar,
|
|
1151
|
-
onToolbarDiscardDraft,
|
|
1152
1083
|
onToolbarMove,
|
|
1153
|
-
composeState,
|
|
1154
1084
|
handleDeselectIfRemoved,
|
|
1155
1085
|
} = useMailboxPane();
|
|
1156
1086
|
// Which surface intelligence has here: the rail between 1280 and up, the
|
|
@@ -1197,25 +1127,20 @@ function MailboxReading() {
|
|
|
1197
1127
|
const intelligenceShowing =
|
|
1198
1128
|
hasThread && (railFits ? intelligenceOpen : drawerOpen);
|
|
1199
1129
|
|
|
1200
|
-
const detailPane =
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
onComposeClose={onClearComposeRequest}
|
|
1215
|
-
/>
|
|
1216
|
-
) : (
|
|
1217
|
-
<ReadingPaneEmpty />
|
|
1218
|
-
);
|
|
1130
|
+
const detailPane = conversation ? (
|
|
1131
|
+
<ConversationView
|
|
1132
|
+
threadId={conversation.threadId}
|
|
1133
|
+
mailboxId={conversation.mailboxId}
|
|
1134
|
+
subject={conversation.subject}
|
|
1135
|
+
selectedMessageId={conversation.messageId}
|
|
1136
|
+
authenticity={conversation.authenticity}
|
|
1137
|
+
onOpenIntelligence={
|
|
1138
|
+
conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
|
|
1139
|
+
}
|
|
1140
|
+
/>
|
|
1141
|
+
) : (
|
|
1142
|
+
<ReadingPaneEmpty />
|
|
1143
|
+
);
|
|
1219
1144
|
|
|
1220
1145
|
return (
|
|
1221
1146
|
<>
|
|
@@ -1225,17 +1150,11 @@ function MailboxReading() {
|
|
|
1225
1150
|
intelligenceOpen={intelligenceShowing}
|
|
1226
1151
|
canToggleIntelligence={hasThread}
|
|
1227
1152
|
onToggleIntelligence={toggleIntelligence}
|
|
1228
|
-
onReply={
|
|
1229
|
-
onReplyAll={
|
|
1230
|
-
onForward={
|
|
1231
|
-
canDelete={hasThread
|
|
1232
|
-
onDelete={
|
|
1233
|
-
hasThread
|
|
1234
|
-
? onToolbarDelete
|
|
1235
|
-
: hasRemitDraftOpen
|
|
1236
|
-
? onToolbarDiscardDraft
|
|
1237
|
-
: undefined
|
|
1238
|
-
}
|
|
1153
|
+
onReply={onReply ? () => onReply("reply") : undefined}
|
|
1154
|
+
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
1155
|
+
onForward={onReply ? () => onReply("forward") : undefined}
|
|
1156
|
+
canDelete={hasThread}
|
|
1157
|
+
onDelete={hasThread ? onToolbarDelete : undefined}
|
|
1239
1158
|
onToggleStar={hasThread ? onToolbarStar : undefined}
|
|
1240
1159
|
isStarred={selectedThread?.hasStars}
|
|
1241
1160
|
moveContext={
|
|
@@ -1309,7 +1228,6 @@ function MailboxPhone() {
|
|
|
1309
1228
|
onBack,
|
|
1310
1229
|
nextThread,
|
|
1311
1230
|
previousThread,
|
|
1312
|
-
composeState,
|
|
1313
1231
|
handleDeselectIfRemoved,
|
|
1314
1232
|
} = useMailboxPane();
|
|
1315
1233
|
|
|
@@ -1349,14 +1267,6 @@ function MailboxPhone() {
|
|
|
1349
1267
|
);
|
|
1350
1268
|
}
|
|
1351
1269
|
|
|
1352
|
-
if (composeState.isOpen) {
|
|
1353
|
-
return (
|
|
1354
|
-
<div className="h-full">
|
|
1355
|
-
<FullCompose />
|
|
1356
|
-
</div>
|
|
1357
|
-
);
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
1270
|
return <MailboxList />;
|
|
1361
1271
|
}
|
|
1362
1272
|
|
|
@@ -48,7 +48,6 @@ import {
|
|
|
48
48
|
useMemo,
|
|
49
49
|
useRef,
|
|
50
50
|
} from "react";
|
|
51
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
52
51
|
import { MessageBody } from "@/components/mail/MessageBody";
|
|
53
52
|
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
54
53
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
@@ -63,7 +62,7 @@ import {
|
|
|
63
62
|
import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
|
|
64
63
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
65
64
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
66
|
-
import { useRetainOpenPanels } from "@/routing";
|
|
65
|
+
import { useEditDraft, useRetainOpenPanels } from "@/routing";
|
|
67
66
|
|
|
68
67
|
/* ------------------------------------------------------------------ */
|
|
69
68
|
/* Helpers (shared between List, Reading, Phone sub-views) */
|
|
@@ -266,7 +265,7 @@ function OutboxMessageRow({
|
|
|
266
265
|
onSelect,
|
|
267
266
|
}: OutboxMessageRowProps) {
|
|
268
267
|
const queryClient = useQueryClient();
|
|
269
|
-
const
|
|
268
|
+
const editDraft = useEditDraft();
|
|
270
269
|
const { pushError } = useErrorBanners();
|
|
271
270
|
|
|
272
271
|
const invalidateOutbox = useCallback(() => {
|
|
@@ -331,12 +330,7 @@ function OutboxMessageRow({
|
|
|
331
330
|
: undefined
|
|
332
331
|
}
|
|
333
332
|
retrying={retryMutation.isPending}
|
|
334
|
-
onEdit={() =>
|
|
335
|
-
openCompose({
|
|
336
|
-
mode: "new",
|
|
337
|
-
outboxMessageId: message.outboxMessageId,
|
|
338
|
-
})
|
|
339
|
-
}
|
|
333
|
+
onEdit={() => editDraft(message.outboxMessageId)}
|
|
340
334
|
onDelete={() =>
|
|
341
335
|
deleteMutation.mutate({
|
|
342
336
|
path: { outboxMessageId: message.outboxMessageId },
|
|
@@ -126,35 +126,55 @@ afterEach(() => {
|
|
|
126
126
|
// `window`.
|
|
127
127
|
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
130
|
+
const REPLY_PATH = `${MESSAGE_PATH}/reply`;
|
|
131
|
+
|
|
132
|
+
// The conversation is what the message route renders, and the reply is the
|
|
133
|
+
// segment under it — so the tree here is the app's: the folder, the thread, the
|
|
134
|
+
// message, and the mode below it. The address is what opens the reply, so the
|
|
135
|
+
// cases below mount at one or the other.
|
|
136
|
+
const testRouter = (href: string): AnyRouter => {
|
|
133
137
|
// The compose provider sits at the root the way `__root.tsx` mounts it.
|
|
134
138
|
const rootRoute = createRootRoute({
|
|
135
139
|
component: () =>
|
|
136
140
|
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
137
141
|
});
|
|
142
|
+
const mailboxRoute = createRoute({
|
|
143
|
+
getParentRoute: () => rootRoute,
|
|
144
|
+
path: "/mail/$mailboxId",
|
|
145
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
146
|
+
});
|
|
147
|
+
const threadRoute = createRoute({
|
|
148
|
+
getParentRoute: () => mailboxRoute,
|
|
149
|
+
path: "$threadId",
|
|
150
|
+
});
|
|
151
|
+
const messageRoute = createRoute({
|
|
152
|
+
getParentRoute: () => threadRoute,
|
|
153
|
+
path: "$messageId",
|
|
154
|
+
component: () =>
|
|
155
|
+
createElement(ConversationView, {
|
|
156
|
+
threadId: THREAD_ID,
|
|
157
|
+
mailboxId: MAILBOX_ID,
|
|
158
|
+
subject: "Lunch Thursday?",
|
|
159
|
+
selectedMessageId: MESSAGE_ID,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
const replyRoute = createRoute({
|
|
163
|
+
getParentRoute: () => messageRoute,
|
|
164
|
+
path: "$mode/{-$outboxMessageId}",
|
|
165
|
+
});
|
|
138
166
|
const routeTree = rootRoute.addChildren([
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
validateSearch: (search: Record<string, unknown>) => search,
|
|
143
|
-
component: () =>
|
|
144
|
-
createElement(ConversationView, {
|
|
145
|
-
threadId: THREAD_ID,
|
|
146
|
-
mailboxId: MAILBOX_ID,
|
|
147
|
-
subject: "Lunch Thursday?",
|
|
148
|
-
}),
|
|
149
|
-
}),
|
|
167
|
+
mailboxRoute.addChildren([
|
|
168
|
+
threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
|
|
169
|
+
]),
|
|
150
170
|
]);
|
|
151
171
|
return createRouter({
|
|
152
172
|
routeTree,
|
|
153
|
-
history: createMemoryHistory({ initialEntries: [
|
|
173
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
154
174
|
}) as unknown as AnyRouter;
|
|
155
175
|
};
|
|
156
176
|
|
|
157
|
-
const
|
|
177
|
+
const mountAt = async (href: string): Promise<[DomHarness, AnyRouter]> => {
|
|
158
178
|
http = mockFetch((call) => {
|
|
159
179
|
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
160
180
|
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
@@ -164,17 +184,39 @@ const mount = async (): Promise<DomHarness> => {
|
|
|
164
184
|
return { items: [] };
|
|
165
185
|
});
|
|
166
186
|
|
|
167
|
-
const router = testRouter();
|
|
187
|
+
const router = testRouter(href);
|
|
168
188
|
await router.load();
|
|
169
189
|
harness = createDomHarness();
|
|
170
190
|
harness.renderApp(createElement(RouterProvider, { router }));
|
|
171
191
|
await harness.flush();
|
|
172
192
|
await harness.wait(20);
|
|
173
193
|
await harness.flush();
|
|
174
|
-
return harness;
|
|
194
|
+
return [harness, router];
|
|
175
195
|
};
|
|
176
196
|
|
|
177
|
-
/** The
|
|
197
|
+
/** The conversation, with nothing being written under it. */
|
|
198
|
+
const mount = async (): Promise<DomHarness> => {
|
|
199
|
+
const [mounted] = await mountAt(MESSAGE_PATH);
|
|
200
|
+
return mounted;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The same conversation at the address that names a reply — which is the whole
|
|
205
|
+
* of what opens one.
|
|
206
|
+
*
|
|
207
|
+
* A cold load rather than a press: a navigation inside this harness moves the
|
|
208
|
+
* router but never re-renders the tree, so nothing here can say what the pane
|
|
209
|
+
* does as an address changes under it. Every case below is what one address
|
|
210
|
+
* renders, and the transitions between two of them — the reply opening, and its
|
|
211
|
+
* closing again with the conversation left standing — are asserted in
|
|
212
|
+
* `packages/e2e/specs/detail-routes.spec.ts`, against a real browser.
|
|
213
|
+
*/
|
|
214
|
+
const mountReplying = async (): Promise<DomHarness> => {
|
|
215
|
+
const [mounted] = await mountAt(REPLY_PATH);
|
|
216
|
+
return mounted;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** The r shortcut the reading pane binds — how a reader asks for the reply. */
|
|
178
220
|
const pressReply = async (mounted: DomHarness): Promise<void> => {
|
|
179
221
|
mounted.dispatch(
|
|
180
222
|
mounted.window,
|
|
@@ -224,9 +266,7 @@ const verticalScrollers = (root: Element): Element[] =>
|
|
|
224
266
|
|
|
225
267
|
describe("answering the message that is open", () => {
|
|
226
268
|
it("puts the reply in the same scrolling region as the message", async () => {
|
|
227
|
-
const mounted = await
|
|
228
|
-
|
|
229
|
-
await pressReply(mounted);
|
|
269
|
+
const mounted = await mountReplying();
|
|
230
270
|
|
|
231
271
|
const pane = mounted.query("article");
|
|
232
272
|
assert.ok(pane, "the conversation pane is mounted");
|
|
@@ -246,9 +286,7 @@ describe("answering the message that is open", () => {
|
|
|
246
286
|
});
|
|
247
287
|
|
|
248
288
|
it("leads the pane with the reply, above the thread it answers", async () => {
|
|
249
|
-
const mounted = await
|
|
250
|
-
|
|
251
|
-
await pressReply(mounted);
|
|
289
|
+
const mounted = await mountReplying();
|
|
252
290
|
|
|
253
291
|
const compose = mounted.query('[data-testid="compose-body-area"]');
|
|
254
292
|
assert.ok(compose, "the reply opened");
|
|
@@ -313,7 +351,7 @@ describe("answering the message that is open", () => {
|
|
|
313
351
|
);
|
|
314
352
|
});
|
|
315
353
|
|
|
316
|
-
it("gives the pane one scrollbar
|
|
354
|
+
it("gives the pane one scrollbar with nothing being written", async () => {
|
|
317
355
|
const mounted = await mount();
|
|
318
356
|
|
|
319
357
|
const pane = mounted.query("article");
|
|
@@ -323,9 +361,13 @@ describe("answering the message that is open", () => {
|
|
|
323
361
|
1,
|
|
324
362
|
"reading a thread scrolls one thing",
|
|
325
363
|
);
|
|
364
|
+
});
|
|
326
365
|
|
|
327
|
-
|
|
366
|
+
it("gives the pane one scrollbar with the reply open", async () => {
|
|
367
|
+
const mounted = await mountReplying();
|
|
328
368
|
|
|
369
|
+
const pane = mounted.query("article");
|
|
370
|
+
assert.ok(pane, "the conversation pane is mounted");
|
|
329
371
|
assert.equal(
|
|
330
372
|
verticalScrollers(pane).length,
|
|
331
373
|
1,
|
|
@@ -333,6 +375,17 @@ describe("answering the message that is open", () => {
|
|
|
333
375
|
);
|
|
334
376
|
});
|
|
335
377
|
|
|
378
|
+
// The address is the whole of what opens a reply, so what the key does is
|
|
379
|
+
// move it: the mode lands under the message that was open, and the thread
|
|
380
|
+
// that was matched behind it stays matched.
|
|
381
|
+
it("moves the address to the reply under the open message", async () => {
|
|
382
|
+
const [mounted, router] = await mountAt(MESSAGE_PATH);
|
|
383
|
+
|
|
384
|
+
await pressReply(mounted);
|
|
385
|
+
|
|
386
|
+
assert.equal(router.state.location.pathname, REPLY_PATH);
|
|
387
|
+
});
|
|
388
|
+
|
|
336
389
|
it("collapses the message from the chevron beside the sender", async () => {
|
|
337
390
|
const mounted = await mount();
|
|
338
391
|
|
|
@@ -52,8 +52,19 @@ export const useSaveDraft = ({
|
|
|
52
52
|
const propIdRef = useRef(outboxMessageId);
|
|
53
53
|
const targetIdRef = useRef(outboxMessageId);
|
|
54
54
|
if (propIdRef.current !== outboxMessageId) {
|
|
55
|
+
// A write waiting out the debounce holds the document that has just been
|
|
56
|
+
// left, aimed at whatever entry is current when it fires. Leaving one is
|
|
57
|
+
// therefore dropping it: otherwise it lands on the next draft, or — with
|
|
58
|
+
// no entry to land on at all — creates one holding the last message's
|
|
59
|
+
// content, which is what a second Compose press used to do.
|
|
60
|
+
//
|
|
61
|
+
// Adoption is the exception, as it is everywhere: the id arriving is this
|
|
62
|
+
// session's own draft coming back, and a save scheduled while it was in
|
|
63
|
+
// flight is still the right content for it.
|
|
64
|
+
const leavingADocument = propIdRef.current !== undefined;
|
|
55
65
|
propIdRef.current = outboxMessageId;
|
|
56
66
|
targetIdRef.current = outboxMessageId;
|
|
67
|
+
if (leavingADocument && timerRef.current) clearTimeout(timerRef.current);
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
const createMutation = useMutation(
|
|
@@ -2,6 +2,7 @@ import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { useMailContext } from "@/lib/mail-context";
|
|
4
4
|
import { shouldMirrorQuery } from "@/lib/search-view";
|
|
5
|
+
import { useIsComposing } from "@/routing";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The list the mirror writes to. Each list calls the hook with its own route,
|
|
@@ -40,12 +41,17 @@ export type SearchMirrorTarget =
|
|
|
40
41
|
* was opened under it (not a pre-search leftover) and must survive. The close
|
|
41
42
|
* otherwise raced the tap: the row shows before the debounce settles, so this
|
|
42
43
|
* mirror can land just after the open and undo it.
|
|
44
|
+
*
|
|
45
|
+
* A composer is not the pre-search list's leftover either. It is the reader's
|
|
46
|
+
* own unsent message, so a search started while it is up narrows the list
|
|
47
|
+
* behind it and leaves it standing.
|
|
43
48
|
*/
|
|
44
49
|
export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
45
50
|
const navigate = useNavigate();
|
|
46
51
|
const { searchInput, searchQuery: committedQuery } = useMailContext();
|
|
47
52
|
const { q: urlQuery = "" } = useSearch({ from: "/mail" });
|
|
48
53
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
54
|
+
const isComposing = useIsComposing();
|
|
49
55
|
|
|
50
56
|
// Read at effect time rather than depended on: the URL is what the mirror
|
|
51
57
|
// compares against, not what re-triggers it.
|
|
@@ -67,13 +73,17 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
67
73
|
if (!mayWrite) return;
|
|
68
74
|
const queryGoesActive =
|
|
69
75
|
Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
|
|
76
|
+
// The composer is the reader's own unsent message, not a leftover of the
|
|
77
|
+
// list they were on, so a query going active narrows the list behind it and
|
|
78
|
+
// leaves it where it is.
|
|
79
|
+
const closesTheOpenSurface = queryGoesActive && !isComposing;
|
|
70
80
|
const search = (prev: Record<string, unknown>) => ({
|
|
71
81
|
...prev,
|
|
72
82
|
q: committedQuery || undefined,
|
|
73
83
|
});
|
|
74
84
|
// A query is a mode of the view the reader is already in, so the panels
|
|
75
85
|
// they have up are not ones they navigated away from.
|
|
76
|
-
if (!
|
|
86
|
+
if (!closesTheOpenSurface) {
|
|
77
87
|
navigate({ to: ".", search, hash: true, replace: true });
|
|
78
88
|
return;
|
|
79
89
|
}
|
|
@@ -97,5 +107,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
97
107
|
mailboxId,
|
|
98
108
|
listPath,
|
|
99
109
|
pathname,
|
|
110
|
+
isComposing,
|
|
100
111
|
]);
|
|
101
112
|
}
|