@remit/web-client 0.0.150 → 0.0.152
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/ComposeProvider.tsx +27 -5
- package/src/components/layout/ComposeFab.tsx +3 -2
- package/src/components/mail/BriefPane.tsx +8 -8
- package/src/components/mail/DailyBrief.tsx +3 -7
- package/src/components/mail/DraftsView.tsx +7 -3
- package/src/components/mail/IntelligencePane.tsx +4 -3
- package/src/components/mail/MailListHeader.tsx +1 -5
- package/src/components/mail/MailSidebarAdapter.tsx +3 -2
- package/src/components/mail/MailboxPane.tsx +130 -150
- package/src/components/mail/MessageActionMenu.tsx +12 -8
- package/src/components/mail/MessageList.selection.test.ts +6 -7
- package/src/components/mail/MessageList.tsx +28 -32
- package/src/components/mail/MessageListItem.tsx +2 -1
- package/src/components/mail/MessageRow.tsx +13 -10
- package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +8 -3
- package/src/components/mail/SwipeableMessageRow.tsx +8 -12
- package/src/hooks/useIntelligenceData.ts +1 -0
- package/src/hooks/useLayoutTier.ts +8 -5
- package/src/hooks/useMediaQuery.ts +2 -29
- package/src/lib/conversation-target.ts +7 -49
- package/src/lib/mail-search.ts +7 -8
- package/src/routeTree.gen.ts +74 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +19 -0
- package/src/routes/mail/$mailboxId/$threadId/index.tsx +14 -0
- package/src/routes/mail/$mailboxId/$threadId.tsx +12 -0
- package/src/routes/mail/$mailboxId.tsx +7 -3
- package/src/routes/mail/brief.tsx +2 -2
- package/src/routing/index.ts +5 -5
- package/src/routing/open-thread.ts +58 -0
- package/src/lib/conversation-target.test.ts +0 -68
- package/src/lib/search-pending.test.ts +0 -99
- package/src/lib/search-pending.ts +0 -49
- package/src/routing/brief-thread.ts +0 -47
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* MailboxPane — compound component for the mailbox view.
|
|
3
3
|
*
|
|
4
4
|
* Encapsulates all state, hooks, and rendering for the /mail/$mailboxId view.
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
5
|
+
* The list layout route mounts `<MailboxPane>` around the shell and passes the
|
|
6
|
+
* sub-views into slots, with the reading pane as the `Outlet`:
|
|
7
7
|
*
|
|
8
|
-
* <MailboxPane mailboxId={...}
|
|
9
|
-
* <
|
|
8
|
+
* <MailboxPane mailboxId={...} thread={useOpenThreadPath()}>
|
|
9
|
+
* <MailShell
|
|
10
10
|
* list={<MailboxPane.List />}
|
|
11
|
-
* reading={<
|
|
11
|
+
* reading={<Outlet />}
|
|
12
12
|
* intelligence={<MailboxPane.Intelligence />}
|
|
13
13
|
* />
|
|
14
14
|
* </MailboxPane>
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
useMutation,
|
|
41
41
|
useQueryClient,
|
|
42
42
|
} from "@tanstack/react-query";
|
|
43
|
-
import { useNavigate
|
|
43
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
44
44
|
import {
|
|
45
45
|
createContext,
|
|
46
46
|
type ReactNode,
|
|
@@ -94,13 +94,11 @@ import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
|
94
94
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
95
95
|
import { useThreadActions } from "@/hooks/useThreadActions";
|
|
96
96
|
import { useThreadMessageIds } from "@/hooks/useThreadMessageIds";
|
|
97
|
+
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
97
98
|
import { useToggleStar } from "@/hooks/useToggleStar";
|
|
98
99
|
import { useTriageContext, useTriageLayer } from "@/hooks/useTriageLayer";
|
|
99
100
|
import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
100
|
-
import {
|
|
101
|
-
buildConversationTarget,
|
|
102
|
-
type ConversationTarget,
|
|
103
|
-
} from "@/lib/conversation-target";
|
|
101
|
+
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
104
102
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
105
103
|
import {
|
|
106
104
|
filterReach,
|
|
@@ -116,11 +114,6 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
116
114
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
117
115
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
118
116
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
119
|
-
import {
|
|
120
|
-
isSearchPending as computeIsSearchPending,
|
|
121
|
-
resolveOpenThread,
|
|
122
|
-
resolveSelectedThread,
|
|
123
|
-
} from "@/lib/search-pending";
|
|
124
117
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
125
118
|
import {
|
|
126
119
|
relatedSearchResults,
|
|
@@ -132,6 +125,7 @@ import {
|
|
|
132
125
|
applyResidualTokens,
|
|
133
126
|
threadSearchTokens,
|
|
134
127
|
} from "@/lib/thread-search-tokens";
|
|
128
|
+
import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
|
|
135
129
|
import { MailViewChrome } from "./MailViewChrome";
|
|
136
130
|
|
|
137
131
|
/* ------------------------------------------------------------------ */
|
|
@@ -140,10 +134,13 @@ import { MailViewChrome } from "./MailViewChrome";
|
|
|
140
134
|
|
|
141
135
|
interface MailboxPaneContextValue {
|
|
142
136
|
mailboxId: string;
|
|
137
|
+
/** The row the reader pointed at, which is the one the list highlights. */
|
|
143
138
|
selectedMessageId: string | undefined;
|
|
144
139
|
selectedThread: RemitImapThreadMessageResponse | undefined;
|
|
145
|
-
/** The conversation
|
|
140
|
+
/** The conversation the pane shows, or none when the address names no thread. */
|
|
146
141
|
conversation: ConversationTarget | undefined;
|
|
142
|
+
/** Opens a conversation in this folder's reading pane. */
|
|
143
|
+
onOpenThread: (target: OpenThreadTarget) => void;
|
|
147
144
|
threads: RemitImapThreadMessageResponse[];
|
|
148
145
|
isLoading: boolean;
|
|
149
146
|
isError: boolean;
|
|
@@ -222,8 +219,9 @@ interface MailboxPaneContextValue {
|
|
|
222
219
|
hasRemitDraftOpen: boolean;
|
|
223
220
|
// Phone actions
|
|
224
221
|
onBack: () => void;
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
/** The rows either side of the open one — the phone's swipe gestures. */
|
|
223
|
+
nextThread: OpenThreadTarget | undefined;
|
|
224
|
+
previousThread: OpenThreadTarget | undefined;
|
|
227
225
|
}
|
|
228
226
|
|
|
229
227
|
/** The server's own default page size (`DEFAULT_THREADS_PAGE_SIZE`), sent so the
|
|
@@ -252,23 +250,25 @@ function useMailboxPane(): MailboxPaneContextValue {
|
|
|
252
250
|
|
|
253
251
|
interface MailboxPaneProps {
|
|
254
252
|
mailboxId: string;
|
|
255
|
-
|
|
253
|
+
/** The open conversation, as the address states it. */
|
|
254
|
+
thread: OpenThreadPath | undefined;
|
|
256
255
|
children: ReactNode;
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
function MailboxPaneProvider({
|
|
260
259
|
mailboxId,
|
|
261
|
-
|
|
260
|
+
thread,
|
|
262
261
|
children,
|
|
263
262
|
}: MailboxPaneProps) {
|
|
264
263
|
const navigate = useNavigate();
|
|
264
|
+
const threadId = thread?.threadId;
|
|
265
|
+
const pointedAtMessageId = thread?.messageId;
|
|
265
266
|
const tier = useLayoutTier();
|
|
266
267
|
const isDesktop = tier === "desktop";
|
|
267
268
|
const telemetry = useTelemetry();
|
|
268
269
|
const {
|
|
269
270
|
accounts,
|
|
270
271
|
searchQuery,
|
|
271
|
-
searchInput,
|
|
272
272
|
intelligenceOpen,
|
|
273
273
|
onToggleIntelligence,
|
|
274
274
|
onSetIntelligenceOpen,
|
|
@@ -387,39 +387,9 @@ function MailboxPaneProvider({
|
|
|
387
387
|
: undefined,
|
|
388
388
|
});
|
|
389
389
|
|
|
390
|
-
const handleDeselectIfRemoved = useCallback(
|
|
391
|
-
(removedIds: string[]) => {
|
|
392
|
-
if (!selectedMessageId) return;
|
|
393
|
-
if (!removedIds.includes(selectedMessageId)) return;
|
|
394
|
-
navigate({
|
|
395
|
-
to: "/mail/$mailboxId",
|
|
396
|
-
params: { mailboxId },
|
|
397
|
-
search: (prev: Record<string, unknown>) => ({
|
|
398
|
-
...prev,
|
|
399
|
-
selectedMessageId: undefined,
|
|
400
|
-
selectedThreadId: undefined,
|
|
401
|
-
}),
|
|
402
|
-
});
|
|
403
|
-
},
|
|
404
|
-
[selectedMessageId, mailboxId, navigate],
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
const { deleteMessages: handleDeleteMessages, isPending: isDeleting } =
|
|
408
|
-
useDeleteMessages({
|
|
409
|
-
mailboxId,
|
|
410
|
-
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
411
|
-
});
|
|
412
|
-
|
|
413
390
|
const { accountId: mailboxAccountId } = useMailboxAccount(mailboxId);
|
|
414
391
|
const mailboxName = useCurrentMailboxName({ accounts });
|
|
415
392
|
|
|
416
|
-
const { moveMessages: handleMoveMessages, isPending: isMoving } =
|
|
417
|
-
useMoveMessages({
|
|
418
|
-
mailboxId,
|
|
419
|
-
accountId: mailboxAccountId,
|
|
420
|
-
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
421
|
-
});
|
|
422
|
-
|
|
423
393
|
// The server answered the active predicate, so these rows are the list: the
|
|
424
394
|
// dedupe spans pages and the deleted drop repeats the server's own
|
|
425
395
|
// `excludeDeleted`, and neither result changes when another page loads.
|
|
@@ -468,49 +438,74 @@ function MailboxPaneProvider({
|
|
|
468
438
|
}
|
|
469
439
|
: undefined;
|
|
470
440
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
441
|
+
// The row this folder itself lists, preferred because a mutation patches it in
|
|
442
|
+
// place. A thread the loaded pages do not hold — a chip that paged it out, a
|
|
443
|
+
// cross-folder hit, a cold address — is in no listing here and answers for
|
|
444
|
+
// itself, which is what the snapshot and the URL's spare thread id used to
|
|
445
|
+
// stand in for.
|
|
446
|
+
const listedThread = threadId
|
|
447
|
+
? (threads.find((t) => t.messageId === pointedAtMessageId) ??
|
|
448
|
+
threads.find((t) => t.threadId === threadId))
|
|
449
|
+
: undefined;
|
|
450
|
+
const ownRow = useThreadRow(threadId, pointedAtMessageId);
|
|
451
|
+
const selectedThread = listedThread ?? ownRow;
|
|
452
|
+
|
|
453
|
+
const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
|
|
454
|
+
|
|
455
|
+
const conversation = useMemo<ConversationTarget | undefined>(() => {
|
|
456
|
+
if (!threadId) return undefined;
|
|
457
|
+
return {
|
|
458
|
+
threadId,
|
|
459
|
+
mailboxId: selectedThread?.mailboxId ?? mailboxId,
|
|
460
|
+
subject: selectedThread?.subject,
|
|
461
|
+
messageId: selectedMessageId,
|
|
462
|
+
authenticity: selectedThread?.authenticity,
|
|
463
|
+
};
|
|
464
|
+
}, [threadId, selectedThread, selectedMessageId, mailboxId]);
|
|
465
|
+
|
|
466
|
+
// Esc unwinds one step at a time: an active selection first (handled by the
|
|
467
|
+
// triage layer), then the open thread — which is a navigation up to the list,
|
|
468
|
+
// so nothing is left mounted below it.
|
|
469
|
+
const closeThread = useCallback(() => {
|
|
470
|
+
navigate({
|
|
471
|
+
to: "/mail/$mailboxId",
|
|
472
|
+
params: { mailboxId },
|
|
473
|
+
search: (prev) => prev,
|
|
474
|
+
});
|
|
475
|
+
}, [mailboxId, navigate]);
|
|
476
|
+
|
|
477
|
+
const handleOpenThread = useCallback(
|
|
478
|
+
(target: OpenThreadTarget) => {
|
|
479
|
+
navigate({
|
|
480
|
+
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
481
|
+
params: { mailboxId, ...target },
|
|
482
|
+
search: (prev) => prev,
|
|
483
|
+
});
|
|
484
|
+
},
|
|
485
|
+
[mailboxId, navigate],
|
|
476
486
|
);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}, [listedThread]);
|
|
486
|
-
const selectedThread = resolveOpenThread(
|
|
487
|
-
listedThread,
|
|
488
|
-
openedThread,
|
|
489
|
-
selectedMessageId,
|
|
490
|
-
isSearchPending,
|
|
487
|
+
|
|
488
|
+
const handleDeselectIfRemoved = useCallback(
|
|
489
|
+
(removedIds: string[]) => {
|
|
490
|
+
if (!selectedMessageId) return;
|
|
491
|
+
if (!removedIds.includes(selectedMessageId)) return;
|
|
492
|
+
closeThread();
|
|
493
|
+
},
|
|
494
|
+
[selectedMessageId, closeThread],
|
|
491
495
|
);
|
|
492
496
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// route param) so the conversation still opens.
|
|
496
|
-
const { selectedThreadId } = useSearch({ strict: false }) as {
|
|
497
|
-
selectedThreadId?: string;
|
|
498
|
-
};
|
|
499
|
-
const conversation = useMemo(
|
|
500
|
-
() =>
|
|
501
|
-
buildConversationTarget(selectedThread, {
|
|
502
|
-
messageId: selectedMessageId,
|
|
503
|
-
threadId: isSearchPending ? undefined : selectedThreadId,
|
|
504
|
-
mailboxId,
|
|
505
|
-
}),
|
|
506
|
-
[
|
|
507
|
-
selectedThread,
|
|
508
|
-
selectedMessageId,
|
|
509
|
-
selectedThreadId,
|
|
510
|
-
isSearchPending,
|
|
497
|
+
const { deleteMessages: handleDeleteMessages, isPending: isDeleting } =
|
|
498
|
+
useDeleteMessages({
|
|
511
499
|
mailboxId,
|
|
512
|
-
|
|
513
|
-
|
|
500
|
+
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const { moveMessages: handleMoveMessages, isPending: isMoving } =
|
|
504
|
+
useMoveMessages({
|
|
505
|
+
mailboxId,
|
|
506
|
+
accountId: mailboxAccountId,
|
|
507
|
+
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
508
|
+
});
|
|
514
509
|
|
|
515
510
|
const triage = useTriageContext();
|
|
516
511
|
const {
|
|
@@ -635,20 +630,6 @@ function MailboxPaneProvider({
|
|
|
635
630
|
closeCompose,
|
|
636
631
|
]);
|
|
637
632
|
|
|
638
|
-
// Esc unwinds one step at a time: an active selection first (handled by the
|
|
639
|
-
// triage layer), then the open thread.
|
|
640
|
-
const closeThread = useCallback(() => {
|
|
641
|
-
navigate({
|
|
642
|
-
to: "/mail/$mailboxId",
|
|
643
|
-
params: { mailboxId },
|
|
644
|
-
search: (prev: Record<string, unknown>) => ({
|
|
645
|
-
...prev,
|
|
646
|
-
selectedMessageId: undefined,
|
|
647
|
-
selectedThreadId: undefined,
|
|
648
|
-
}),
|
|
649
|
-
});
|
|
650
|
-
}, [mailboxId, navigate]);
|
|
651
|
-
|
|
652
633
|
const messageIdsForFocusedThread = useCallback(
|
|
653
634
|
(thread: typeof focusedThread): string[] => {
|
|
654
635
|
if (!thread) return [];
|
|
@@ -694,16 +675,11 @@ function MailboxPaneProvider({
|
|
|
694
675
|
|
|
695
676
|
const ensureFocusedOpen = useCallback(() => {
|
|
696
677
|
if (selectedMessageId || !triageFocusedId) return false;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
search: (prev: Record<string, unknown>) => ({
|
|
701
|
-
...prev,
|
|
702
|
-
selectedMessageId: triageFocusedId,
|
|
703
|
-
}),
|
|
704
|
-
});
|
|
678
|
+
const row = threads.find((t) => t.messageId === triageFocusedId);
|
|
679
|
+
if (!row) return false;
|
|
680
|
+
handleOpenThread({ threadId: row.threadId, messageId: triageFocusedId });
|
|
705
681
|
return true;
|
|
706
|
-
}, [selectedMessageId, triageFocusedId,
|
|
682
|
+
}, [selectedMessageId, triageFocusedId, threads, handleOpenThread]);
|
|
707
683
|
|
|
708
684
|
const triageReply = useCallback(() => {
|
|
709
685
|
if (ensureFocusedOpen()) return;
|
|
@@ -864,11 +840,23 @@ function MailboxPaneProvider({
|
|
|
864
840
|
bindings: [{ key: "Escape", handler: closeCompose, preventDefault: true }],
|
|
865
841
|
});
|
|
866
842
|
|
|
843
|
+
// The swipe gestures open a whole conversation, so the adjacent row has to
|
|
844
|
+
// name its thread. This folder's own listing is where that is looked up, so a
|
|
845
|
+
// row it does not hold offers no gesture rather than a tap that goes nowhere.
|
|
846
|
+
const adjacentThread = (
|
|
847
|
+
messageId: string | undefined,
|
|
848
|
+
): OpenThreadTarget | undefined => {
|
|
849
|
+
if (!messageId) return undefined;
|
|
850
|
+
const row = threads.find((t) => t.messageId === messageId);
|
|
851
|
+
return row ? { threadId: row.threadId, messageId } : undefined;
|
|
852
|
+
};
|
|
853
|
+
|
|
867
854
|
const ctx: MailboxPaneContextValue = {
|
|
868
855
|
mailboxId,
|
|
869
856
|
selectedMessageId,
|
|
870
857
|
selectedThread,
|
|
871
858
|
conversation,
|
|
859
|
+
onOpenThread: handleOpenThread,
|
|
872
860
|
threads,
|
|
873
861
|
isLoading,
|
|
874
862
|
isError,
|
|
@@ -920,8 +908,8 @@ function MailboxPaneProvider({
|
|
|
920
908
|
closeCompose,
|
|
921
909
|
hasRemitDraftOpen,
|
|
922
910
|
onBack: goBack,
|
|
923
|
-
nextMessageId,
|
|
924
|
-
previousMessageId,
|
|
911
|
+
nextThread: adjacentThread(nextMessageId),
|
|
912
|
+
previousThread: adjacentThread(previousMessageId),
|
|
925
913
|
};
|
|
926
914
|
|
|
927
915
|
return (
|
|
@@ -1036,28 +1024,33 @@ function MailboxList() {
|
|
|
1036
1024
|
),
|
|
1037
1025
|
[semanticHits, threads, resultFolderIndex],
|
|
1038
1026
|
);
|
|
1027
|
+
// The two-engine results panel, whose semantic hits are in no list at all —
|
|
1028
|
+
// each one carries the thread it belongs to.
|
|
1039
1029
|
const handleSelectSearchResult = useCallback(
|
|
1040
|
-
(result: SearchResult) =>
|
|
1030
|
+
(result: SearchResult) => {
|
|
1031
|
+
const threadId =
|
|
1032
|
+
result.threadId ??
|
|
1033
|
+
threads.find((thread) => thread.messageId === result.id)?.threadId;
|
|
1034
|
+
if (!threadId) return;
|
|
1041
1035
|
navigate({
|
|
1042
|
-
to: "/mail/$mailboxId",
|
|
1036
|
+
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
1043
1037
|
// Both sections are scoped to this mailbox, so a result's own
|
|
1044
1038
|
// mailbox is normally this one; keep reading it off the result so a
|
|
1045
1039
|
// row can never open under a mailbox it does not belong to.
|
|
1046
|
-
params: {
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
[mailboxId, navigate, searchInput],
|
|
1040
|
+
params: {
|
|
1041
|
+
mailboxId: result.mailboxId ?? mailboxId,
|
|
1042
|
+
threadId,
|
|
1043
|
+
messageId: result.id,
|
|
1044
|
+
},
|
|
1045
|
+
// Commit the active query with the open so the debounced q-mirror —
|
|
1046
|
+
// which walks back up to the list when the query goes active — is
|
|
1047
|
+
// already satisfied and leaves the conversation alone. The *live*
|
|
1048
|
+
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
1049
|
+
// the committed query is still empty.
|
|
1050
|
+
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
1051
|
+
});
|
|
1052
|
+
},
|
|
1053
|
+
[mailboxId, navigate, searchInput, threads],
|
|
1061
1054
|
);
|
|
1062
1055
|
|
|
1063
1056
|
// Drafts keep their own dedicated view (and header); they don't carry the
|
|
@@ -1273,28 +1266,17 @@ function MailboxPhone() {
|
|
|
1273
1266
|
mailboxAccountId,
|
|
1274
1267
|
selectedThread,
|
|
1275
1268
|
conversation,
|
|
1269
|
+
onOpenThread,
|
|
1276
1270
|
intelligenceOpen,
|
|
1277
1271
|
onToggleIntelligence,
|
|
1278
1272
|
onBack,
|
|
1279
|
-
|
|
1280
|
-
|
|
1273
|
+
nextThread,
|
|
1274
|
+
previousThread,
|
|
1281
1275
|
composeState,
|
|
1282
1276
|
handleDeselectIfRemoved,
|
|
1283
1277
|
} = useMailboxPane();
|
|
1284
|
-
const navigate = useNavigate();
|
|
1285
1278
|
|
|
1286
1279
|
if (conversation) {
|
|
1287
|
-
const openMessage = (messageId: string) =>
|
|
1288
|
-
navigate({
|
|
1289
|
-
to: "/mail/$mailboxId",
|
|
1290
|
-
params: { mailboxId },
|
|
1291
|
-
search: (prev: Record<string, unknown>) => ({
|
|
1292
|
-
...prev,
|
|
1293
|
-
selectedMessageId: messageId,
|
|
1294
|
-
selectedThreadId: undefined,
|
|
1295
|
-
}),
|
|
1296
|
-
});
|
|
1297
|
-
|
|
1298
1280
|
return (
|
|
1299
1281
|
<>
|
|
1300
1282
|
<ConversationView
|
|
@@ -1305,11 +1287,9 @@ function MailboxPhone() {
|
|
|
1305
1287
|
authenticity={conversation.authenticity}
|
|
1306
1288
|
onBack={onBack}
|
|
1307
1289
|
onOpenIntelligence={onToggleIntelligence}
|
|
1308
|
-
onSwipeNext={
|
|
1309
|
-
nextMessageId ? () => openMessage(nextMessageId) : undefined
|
|
1310
|
-
}
|
|
1290
|
+
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
1311
1291
|
onSwipePrevious={
|
|
1312
|
-
|
|
1292
|
+
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
1313
1293
|
}
|
|
1314
1294
|
mobileIntelligenceOpen={intelligenceOpen}
|
|
1315
1295
|
/>
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type ThreadListSnapshotEntry,
|
|
36
36
|
threadListCacheKeys,
|
|
37
37
|
} from "@/lib/thread-list-cache";
|
|
38
|
+
import { useOpenThreadPath } from "@/routing";
|
|
38
39
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
39
40
|
|
|
40
41
|
interface ThreadMessagesData {
|
|
@@ -103,9 +104,15 @@ export const MessageActionMenu = ({
|
|
|
103
104
|
const queryClient = useQueryClient();
|
|
104
105
|
const navigate = useNavigate();
|
|
105
106
|
const { pushError } = useErrorBanners();
|
|
106
|
-
|
|
107
|
+
// Which message the address has open. The brief and a folder say so in their
|
|
108
|
+
// path; the flagged list, still to move, says so in `?selectedMessageId=`.
|
|
109
|
+
const openThread = useOpenThreadPath();
|
|
110
|
+
const { selectedMessageId: selectedFromQuery } = useSearch({
|
|
111
|
+
strict: false,
|
|
112
|
+
}) as {
|
|
107
113
|
selectedMessageId?: string;
|
|
108
114
|
};
|
|
115
|
+
const selectedMessageId = openThread?.messageId ?? selectedFromQuery;
|
|
109
116
|
|
|
110
117
|
const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
|
|
111
118
|
...messageBulkOperationsUpdateFlagsMutation(),
|
|
@@ -182,10 +189,10 @@ export const MessageActionMenu = ({
|
|
|
182
189
|
},
|
|
183
190
|
});
|
|
184
191
|
|
|
185
|
-
//
|
|
192
|
+
// Close the conversation before the server response arrives if the message
|
|
186
193
|
// being deleted is the one currently routed to. Mirrors the bulk-delete
|
|
187
|
-
// flow on the mailbox route — without this, the
|
|
188
|
-
//
|
|
194
|
+
// flow on the mailbox route — without this, the address keeps naming a
|
|
195
|
+
// message after the row vanishes from the list.
|
|
189
196
|
const handleAfterOptimisticRemove = useCallback(
|
|
190
197
|
(removedIds: string[]) => {
|
|
191
198
|
if (!selectedMessageId) return;
|
|
@@ -193,10 +200,7 @@ export const MessageActionMenu = ({
|
|
|
193
200
|
navigate({
|
|
194
201
|
to: "/mail/$mailboxId",
|
|
195
202
|
params: { mailboxId },
|
|
196
|
-
search: (prev
|
|
197
|
-
...prev,
|
|
198
|
-
selectedMessageId: undefined,
|
|
199
|
-
}),
|
|
203
|
+
search: (prev) => prev,
|
|
200
204
|
});
|
|
201
205
|
},
|
|
202
206
|
[selectedMessageId, mailboxId, navigate],
|
|
@@ -172,18 +172,17 @@ describe("MessageList escalation reaches desktop (#212)", () => {
|
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
|
-
* A confirmed delete
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* list and raises a completion banner instead.
|
|
175
|
+
* A confirmed delete opens the surviving neighbour. On desktop that fills the
|
|
176
|
+
* reading pane beside the list; on a single-pane mobile layout the same
|
|
177
|
+
* navigation replaced the list with a full-screen message, so the delete read as
|
|
178
|
+
* "jumped into a random message" rather than "the row is gone" (#202). Mobile
|
|
179
|
+
* now stays on the list and raises a completion banner instead.
|
|
181
180
|
*/
|
|
182
181
|
describe("MessageList confirmed delete stays on the list on mobile (#202)", () => {
|
|
183
182
|
it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
|
|
184
183
|
assert.match(
|
|
185
184
|
source,
|
|
186
|
-
/if \(isDesktop\) \{\s*
|
|
185
|
+
/if \(isDesktop\) \{\s*openRow\(nextFocus, \{ replace: true \}\);\s*\}/,
|
|
187
186
|
);
|
|
188
187
|
});
|
|
189
188
|
|
|
@@ -621,16 +621,31 @@ export const MessageList = ({
|
|
|
621
621
|
],
|
|
622
622
|
);
|
|
623
623
|
|
|
624
|
-
//
|
|
625
|
-
//
|
|
624
|
+
// Opening a row is a navigation to the conversation under this folder. Every
|
|
625
|
+
// row the list renders names its thread, which is what the reading pane
|
|
626
|
+
// fetches by — a row the list no longer holds cannot be opened from here.
|
|
627
|
+
const openRow = useCallback(
|
|
628
|
+
(messageId: string, options?: { replace?: boolean }) => {
|
|
629
|
+
const threadId = threads.find(
|
|
630
|
+
(thread) => thread.messageId === messageId,
|
|
631
|
+
)?.threadId;
|
|
632
|
+
if (!threadId) return;
|
|
633
|
+
navigate({
|
|
634
|
+
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
635
|
+
params: { mailboxId, threadId, messageId },
|
|
636
|
+
search: (prev) => prev,
|
|
637
|
+
replace: options?.replace,
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
[navigate, mailboxId, threads],
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// Enter: open the focused row in the reading pane. This is the focus→open
|
|
644
|
+
// transition of the 2-state model.
|
|
626
645
|
const handleOpenFocused = useCallback(() => {
|
|
627
646
|
if (!focusedMessageId) return;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
params: { mailboxId },
|
|
631
|
-
search: (prev) => ({ ...prev, selectedMessageId: focusedMessageId }),
|
|
632
|
-
});
|
|
633
|
-
}, [focusedMessageId, navigate, mailboxId]);
|
|
647
|
+
openRow(focusedMessageId);
|
|
648
|
+
}, [focusedMessageId, openRow]);
|
|
634
649
|
|
|
635
650
|
// The reading pane follows the cursor on desktop: j/k load the row they land
|
|
636
651
|
// on, without the user having to press Enter for every message. It replaces
|
|
@@ -643,15 +658,8 @@ export const MessageList = ({
|
|
|
643
658
|
// Suspended off desktop too, where the reading pane replaces the list
|
|
644
659
|
// entirely, so following the cursor would throw the user off the list.
|
|
645
660
|
const followFocusOpen = useCallback(
|
|
646
|
-
(messageId: string) => {
|
|
647
|
-
|
|
648
|
-
to: "/mail/$mailboxId",
|
|
649
|
-
params: { mailboxId },
|
|
650
|
-
search: (prev) => ({ ...prev, selectedMessageId: messageId }),
|
|
651
|
-
replace: true,
|
|
652
|
-
});
|
|
653
|
-
},
|
|
654
|
-
[navigate, mailboxId],
|
|
661
|
+
(messageId: string) => openRow(messageId, { replace: true }),
|
|
662
|
+
[openRow],
|
|
655
663
|
);
|
|
656
664
|
useFollowFocusOpen({
|
|
657
665
|
keyboardFocusedMessageId,
|
|
@@ -724,12 +732,7 @@ export const MessageList = ({
|
|
|
724
732
|
// like it opened a random neighbour instead of removing the rows (#202).
|
|
725
733
|
// Mobile keeps the cursor move but stays on the list.
|
|
726
734
|
if (isDesktop) {
|
|
727
|
-
|
|
728
|
-
to: "/mail/$mailboxId",
|
|
729
|
-
params: { mailboxId },
|
|
730
|
-
search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
|
|
731
|
-
replace: true,
|
|
732
|
-
});
|
|
735
|
+
openRow(nextFocus, { replace: true });
|
|
733
736
|
}
|
|
734
737
|
}
|
|
735
738
|
|
|
@@ -744,8 +747,7 @@ export const MessageList = ({
|
|
|
744
747
|
threads,
|
|
745
748
|
onDeleteMessages,
|
|
746
749
|
exitSelection,
|
|
747
|
-
|
|
748
|
-
mailboxId,
|
|
750
|
+
openRow,
|
|
749
751
|
isDesktop,
|
|
750
752
|
setFocusedMessageId,
|
|
751
753
|
]);
|
|
@@ -1323,13 +1325,7 @@ export const MessageList = ({
|
|
|
1323
1325
|
onReportError={handleReportError}
|
|
1324
1326
|
density={density}
|
|
1325
1327
|
selectedThreadId={selectedMessageId}
|
|
1326
|
-
onSelectThread={(id) =>
|
|
1327
|
-
navigate({
|
|
1328
|
-
to: "/mail/$mailboxId",
|
|
1329
|
-
params: { mailboxId },
|
|
1330
|
-
search: (prev) => ({ ...prev, selectedMessageId: id }),
|
|
1331
|
-
})
|
|
1332
|
-
}
|
|
1328
|
+
onSelectThread={(id) => openRow(id)}
|
|
1333
1329
|
isDesktop={isDesktop}
|
|
1334
1330
|
hideHeader={hideHeader}
|
|
1335
1331
|
selectionBar={activeSelectionBar}
|
|
@@ -45,6 +45,7 @@ export const threadToRowData = (
|
|
|
45
45
|
id: thread.messageId,
|
|
46
46
|
accountId: thread.accountId,
|
|
47
47
|
mailboxId: thread.mailboxId,
|
|
48
|
+
threadId: thread.threadId,
|
|
48
49
|
fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
|
|
49
50
|
fromEmail: thread.fromEmail ?? "",
|
|
50
51
|
subject: thread.subject ?? "(No subject)",
|
|
@@ -81,7 +82,7 @@ const MessageListItemComponent = ({
|
|
|
81
82
|
}: MessageListItemProps) => (
|
|
82
83
|
<MessageRow
|
|
83
84
|
thread={threadToRowData(thread, messageCount)}
|
|
84
|
-
|
|
85
|
+
link={{ mailboxId, threadId: thread.threadId }}
|
|
85
86
|
inListbox
|
|
86
87
|
active={isSelected}
|
|
87
88
|
focused={isFocused}
|