@remit/web-client 0.0.146 → 0.0.147
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/layout/ComposeFab.tsx +7 -3
- package/src/components/mail/BriefPane.tsx +92 -122
- package/src/components/mail/DailyBrief.tsx +28 -26
- package/src/components/mail/MailSidebarAdapter.tsx +2 -5
- package/src/hooks/search-mirror-detail.render.test.ts +205 -0
- package/src/hooks/useSearchMirror.ts +30 -22
- package/src/hooks/useSearchScope.ts +1 -1
- package/src/hooks/useThreadRow.ts +35 -0
- package/src/lib/mail-route.test.ts +26 -0
- package/src/lib/mail-route.ts +13 -0
- package/src/lib/mail-search.ts +7 -7
- package/src/routeTree.gen.ts +71 -0
- package/src/routes/mail/brief/$threadId/$messageId.tsx +19 -0
- package/src/routes/mail/brief/$threadId/index.tsx +14 -0
- package/src/routes/mail/brief/$threadId.tsx +16 -0
- package/src/routes/mail/brief.tsx +7 -4
- package/src/routing/brief-thread.ts +47 -0
- package/src/routing/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.147",
|
|
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": {
|
|
@@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router";
|
|
|
2
2
|
import { Pencil } from "lucide-react";
|
|
3
3
|
import { useCallback } from "react";
|
|
4
4
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
|
+
import { locationOpensDetail } from "@/lib/mail-route";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Floating Action Button for composing a new message. Mobile-only.
|
|
@@ -12,8 +13,9 @@ import { useCompose } from "@/components/compose/ComposeProvider";
|
|
|
12
13
|
* `/mail` shell also stops mounting the FAB above that width; the
|
|
13
14
|
* `lg:hidden` class covers the pre-hydration frame.
|
|
14
15
|
* - The compose surface is already open.
|
|
15
|
-
* - The user is reading a thread
|
|
16
|
-
*
|
|
16
|
+
* - The user is reading a thread — the single pane is the conversation, and
|
|
17
|
+
* its reply bar is under this corner. The brief says so in its path; the
|
|
18
|
+
* lists still to move say so in `?selectedMessageId=…`.
|
|
17
19
|
* - The user is off `/mail`, which is every route with no mail in it.
|
|
18
20
|
*/
|
|
19
21
|
export const ComposeFab = () => {
|
|
@@ -24,7 +26,9 @@ export const ComposeFab = () => {
|
|
|
24
26
|
}, [openCompose]);
|
|
25
27
|
|
|
26
28
|
const search = location.search as Record<string, unknown> | undefined;
|
|
27
|
-
const isReadingThread =
|
|
29
|
+
const isReadingThread =
|
|
30
|
+
Boolean(search?.selectedMessageId) ||
|
|
31
|
+
locationOpensDetail(location.pathname);
|
|
28
32
|
|
|
29
33
|
if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
|
|
30
34
|
return null;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BriefPane — compound component for the daily
|
|
2
|
+
* BriefPane — compound component for the daily brief.
|
|
3
3
|
*
|
|
4
|
-
* Usage in
|
|
4
|
+
* Usage in the list layout route:
|
|
5
5
|
*
|
|
6
|
-
* <BriefPane
|
|
7
|
-
* <
|
|
6
|
+
* <BriefPane thread={useBriefThreadPath()}>
|
|
7
|
+
* <MailShell
|
|
8
8
|
* list={<BriefPane.List />}
|
|
9
|
-
* reading={<
|
|
9
|
+
* reading={<Outlet />}
|
|
10
10
|
* />
|
|
11
11
|
* </BriefPane>
|
|
12
12
|
*
|
|
@@ -14,13 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
16
16
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
17
|
-
import {
|
|
18
|
-
ReadingPaneEmpty,
|
|
19
|
-
type SearchResult,
|
|
20
|
-
useAppShellLayout,
|
|
21
|
-
} from "@remit/ui";
|
|
17
|
+
import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
|
|
22
18
|
import { useQuery } from "@tanstack/react-query";
|
|
23
|
-
import { useNavigate
|
|
19
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
24
20
|
import {
|
|
25
21
|
createContext,
|
|
26
22
|
type ReactNode,
|
|
@@ -37,32 +33,34 @@ import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction
|
|
|
37
33
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
38
34
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
39
35
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
36
|
+
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
40
37
|
import {
|
|
41
38
|
type TriageContext,
|
|
42
39
|
useTriageContext,
|
|
43
40
|
useTriageLayer,
|
|
44
41
|
} from "@/hooks/useTriageLayer";
|
|
45
|
-
import {
|
|
46
|
-
buildConversationTarget,
|
|
47
|
-
type ConversationTarget,
|
|
48
|
-
} from "@/lib/conversation-target";
|
|
42
|
+
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
49
43
|
import { useMailContext } from "@/lib/mail-context";
|
|
44
|
+
import type { BriefThreadPath, BriefThreadTarget } from "@/routing";
|
|
50
45
|
|
|
51
46
|
/* ------------------------------------------------------------------ */
|
|
52
47
|
/* Context */
|
|
53
48
|
/* ------------------------------------------------------------------ */
|
|
54
49
|
|
|
55
50
|
interface BriefPaneContextValue {
|
|
51
|
+
/** The row the reader pointed at, which is the one the list highlights. */
|
|
56
52
|
selectedMessageId: string | undefined;
|
|
57
53
|
selectedThread: RemitImapThreadMessageResponse | undefined;
|
|
58
|
-
/** The conversation
|
|
54
|
+
/** The conversation the pane shows, or none when the address names no thread. */
|
|
59
55
|
conversation: ConversationTarget | undefined;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
result: SearchResult,
|
|
56
|
+
onOpenThread: (
|
|
57
|
+
target: BriefThreadTarget,
|
|
63
58
|
options?: OpenMessageOptions,
|
|
64
59
|
) => void;
|
|
65
60
|
onCloseThread: () => void;
|
|
61
|
+
/** The rows either side of the open one — the phone's swipe gestures. */
|
|
62
|
+
nextThread: BriefThreadTarget | undefined;
|
|
63
|
+
previousThread: BriefThreadTarget | undefined;
|
|
66
64
|
/**
|
|
67
65
|
* Toolbar verbs for the open thread, keyed by the thread's own mailbox and
|
|
68
66
|
* account — the brief spans accounts, so there is no route mailbox to key by.
|
|
@@ -71,8 +69,6 @@ interface BriefPaneContextValue {
|
|
|
71
69
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
72
70
|
triage: TriageContext;
|
|
73
71
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
74
|
-
nextMessageId: string | undefined;
|
|
75
|
-
previousMessageId: string | undefined;
|
|
76
72
|
/**
|
|
77
73
|
* Deselects the open message when it's the one a mutation just removed
|
|
78
74
|
* from view — wired into every mutation that can take the open message out
|
|
@@ -95,93 +91,79 @@ function useBriefPane(): BriefPaneContextValue {
|
|
|
95
91
|
/* ------------------------------------------------------------------ */
|
|
96
92
|
|
|
97
93
|
interface BriefPaneProps {
|
|
98
|
-
|
|
94
|
+
/** The open conversation, as the address states it. */
|
|
95
|
+
thread: BriefThreadPath | undefined;
|
|
99
96
|
children: ReactNode;
|
|
100
97
|
}
|
|
101
98
|
|
|
102
|
-
function BriefPaneProvider({
|
|
99
|
+
function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
103
100
|
const navigate = useNavigate();
|
|
104
101
|
const { searchInput } = useMailContext();
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
}) as { selectedThreadId?: string; selectedMailboxId?: string };
|
|
102
|
+
const threadId = thread?.threadId;
|
|
103
|
+
const pointedAtMessageId = thread?.messageId;
|
|
108
104
|
|
|
109
105
|
const { data: threadsData } = useQuery({
|
|
110
106
|
...unifiedThreadOperationsListAllThreadsOptions(),
|
|
111
107
|
staleTime: 60_000,
|
|
112
108
|
});
|
|
109
|
+
const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
|
|
113
110
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
},
|
|
145
|
-
[navigate],
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
const handleSelectSearchResult = useCallback(
|
|
149
|
-
(result: SearchResult, options?: OpenMessageOptions) => {
|
|
111
|
+
// The row the brief itself lists, preferred because a mutation patches it in
|
|
112
|
+
// place. A thread reached from a cross-folder search hit or a cold address is
|
|
113
|
+
// in no listing here, and answers for itself — which is where
|
|
114
|
+
// `selectedMailboxId` used to come in: the folder a thread is filed in is the
|
|
115
|
+
// thread's own data, so the brief spanning folders costs the URL nothing.
|
|
116
|
+
const listedThread = useMemo(() => {
|
|
117
|
+
if (!threadId) return undefined;
|
|
118
|
+
return (
|
|
119
|
+
briefThreads.find((t) => t.messageId === pointedAtMessageId) ??
|
|
120
|
+
briefThreads.find((t) => t.threadId === threadId)
|
|
121
|
+
);
|
|
122
|
+
}, [briefThreads, threadId, pointedAtMessageId]);
|
|
123
|
+
const ownRow = useThreadRow(threadId, pointedAtMessageId);
|
|
124
|
+
const selectedThread = listedThread ?? ownRow;
|
|
125
|
+
|
|
126
|
+
const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
|
|
127
|
+
|
|
128
|
+
const conversation = useMemo<ConversationTarget | undefined>(() => {
|
|
129
|
+
if (!threadId) return undefined;
|
|
130
|
+
return {
|
|
131
|
+
threadId,
|
|
132
|
+
mailboxId: selectedThread?.mailboxId ?? "",
|
|
133
|
+
subject: selectedThread?.subject,
|
|
134
|
+
messageId: selectedMessageId,
|
|
135
|
+
authenticity: selectedThread?.authenticity,
|
|
136
|
+
};
|
|
137
|
+
}, [threadId, selectedThread, selectedMessageId]);
|
|
138
|
+
|
|
139
|
+
const handleOpenThread = useCallback(
|
|
140
|
+
(target: BriefThreadTarget, options?: OpenMessageOptions) => {
|
|
150
141
|
navigate({
|
|
151
|
-
to: "/mail/brief",
|
|
142
|
+
to: "/mail/brief/$threadId/$messageId",
|
|
143
|
+
params: target,
|
|
152
144
|
replace: options?.replace,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
// the debounce settles, when the committed query is still empty.
|
|
160
|
-
q: searchInput || undefined,
|
|
161
|
-
selectedMessageId: result.id,
|
|
162
|
-
selectedThreadId: result.threadId,
|
|
163
|
-
selectedMailboxId: result.mailboxId,
|
|
164
|
-
}),
|
|
145
|
+
// Commit the active query with the open so the debounced q-mirror —
|
|
146
|
+
// which walks back up to the list when the query goes active — is
|
|
147
|
+
// already satisfied and leaves the conversation alone. The *live*
|
|
148
|
+
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
149
|
+
// the committed query is still empty.
|
|
150
|
+
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
165
151
|
});
|
|
166
152
|
},
|
|
167
153
|
[navigate, searchInput],
|
|
168
154
|
);
|
|
169
155
|
|
|
156
|
+
const handleCloseThread = useCallback(() => {
|
|
157
|
+
navigate({ to: "/mail/brief", search: (prev) => prev });
|
|
158
|
+
}, [navigate]);
|
|
159
|
+
|
|
170
160
|
const handleDeselectIfRemoved = useCallback(
|
|
171
161
|
(removedIds: string[]) => {
|
|
172
162
|
if (!selectedMessageId) return;
|
|
173
163
|
if (!removedIds.includes(selectedMessageId)) return;
|
|
174
|
-
|
|
175
|
-
to: "/mail/brief",
|
|
176
|
-
search: (prev) => ({
|
|
177
|
-
...prev,
|
|
178
|
-
selectedMessageId: undefined,
|
|
179
|
-
selectedThreadId: undefined,
|
|
180
|
-
selectedMailboxId: undefined,
|
|
181
|
-
}),
|
|
182
|
-
});
|
|
164
|
+
handleCloseThread();
|
|
183
165
|
},
|
|
184
|
-
[selectedMessageId,
|
|
166
|
+
[selectedMessageId, handleCloseThread],
|
|
185
167
|
);
|
|
186
168
|
|
|
187
169
|
const actions = useThreadActions({
|
|
@@ -189,24 +171,11 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
189
171
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
190
172
|
});
|
|
191
173
|
|
|
192
|
-
const handleCloseThread = useCallback(() => {
|
|
193
|
-
navigate({
|
|
194
|
-
to: "/mail/brief",
|
|
195
|
-
search: (prev) => ({
|
|
196
|
-
...prev,
|
|
197
|
-
selectedMessageId: undefined,
|
|
198
|
-
selectedThreadId: undefined,
|
|
199
|
-
selectedMailboxId: undefined,
|
|
200
|
-
}),
|
|
201
|
-
});
|
|
202
|
-
}, [navigate]);
|
|
203
|
-
|
|
204
174
|
const triage = useTriageContext();
|
|
205
175
|
|
|
206
176
|
// A brief selection spans accounts and mailboxes, so the listings these
|
|
207
177
|
// patch are resolved from each message's own mailbox — the open thread's is
|
|
208
178
|
// only the fallback.
|
|
209
|
-
const briefThreads = useMemo(() => threadsData?.items ?? [], [threadsData]);
|
|
210
179
|
const { deleteMessages } = useDeleteMessages({
|
|
211
180
|
mailboxId: selectedThread?.mailboxId ?? "",
|
|
212
181
|
messages: briefThreads,
|
|
@@ -219,8 +188,8 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
219
188
|
|
|
220
189
|
const focusedThreadId = triage.focusedMessageId;
|
|
221
190
|
const focusedThread = useMemo(
|
|
222
|
-
() =>
|
|
223
|
-
[
|
|
191
|
+
() => briefThreads.find((t) => t.messageId === focusedThreadId),
|
|
192
|
+
[briefThreads, focusedThreadId],
|
|
224
193
|
);
|
|
225
194
|
const triageTarget = focusedThread ?? selectedThread;
|
|
226
195
|
const triageActions = useThreadActions({ thread: triageTarget });
|
|
@@ -252,18 +221,29 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
252
221
|
},
|
|
253
222
|
});
|
|
254
223
|
|
|
224
|
+
// The swipe gestures open a whole conversation, so the adjacent row has to
|
|
225
|
+
// name its thread. The brief's own listing is where that is looked up, so a
|
|
226
|
+
// row it does not hold offers no gesture rather than a tap that goes nowhere.
|
|
227
|
+
const adjacentThread = useCallback(
|
|
228
|
+
(messageId: string | undefined): BriefThreadTarget | undefined => {
|
|
229
|
+
if (!messageId) return undefined;
|
|
230
|
+
const row = briefThreads.find((t) => t.messageId === messageId);
|
|
231
|
+
return row ? { threadId: row.threadId, messageId } : undefined;
|
|
232
|
+
},
|
|
233
|
+
[briefThreads],
|
|
234
|
+
);
|
|
235
|
+
|
|
255
236
|
const ctx: BriefPaneContextValue = {
|
|
256
237
|
selectedMessageId,
|
|
257
238
|
selectedThread,
|
|
258
239
|
conversation,
|
|
259
|
-
|
|
260
|
-
onSelectSearchResult: handleSelectSearchResult,
|
|
240
|
+
onOpenThread: handleOpenThread,
|
|
261
241
|
onCloseThread: handleCloseThread,
|
|
242
|
+
nextThread: adjacentThread(nextMessageId),
|
|
243
|
+
previousThread: adjacentThread(previousMessageId),
|
|
262
244
|
actions,
|
|
263
245
|
triage,
|
|
264
246
|
onDeleteMessages: deleteMessages,
|
|
265
|
-
nextMessageId,
|
|
266
|
-
previousMessageId,
|
|
267
247
|
handleDeselectIfRemoved,
|
|
268
248
|
};
|
|
269
249
|
|
|
@@ -278,21 +258,15 @@ function BriefPaneProvider({ selectedMessageId, children }: BriefPaneProps) {
|
|
|
278
258
|
* Daily brief list. Mount in the `list` slot of `AppShellSlotted`.
|
|
279
259
|
*/
|
|
280
260
|
function BriefList() {
|
|
281
|
-
const {
|
|
282
|
-
|
|
283
|
-
onSelectMessage,
|
|
284
|
-
onSelectSearchResult,
|
|
285
|
-
triage,
|
|
286
|
-
onDeleteMessages,
|
|
287
|
-
} = useBriefPane();
|
|
261
|
+
const { selectedMessageId, onOpenThread, triage, onDeleteMessages } =
|
|
262
|
+
useBriefPane();
|
|
288
263
|
const { accounts } = useMailContext();
|
|
289
264
|
|
|
290
265
|
return (
|
|
291
266
|
<DailyBrief
|
|
292
267
|
accounts={accounts}
|
|
293
268
|
selectedMessageId={selectedMessageId}
|
|
294
|
-
|
|
295
|
-
onSelectSearchResult={onSelectSearchResult}
|
|
269
|
+
onOpenThread={onOpenThread}
|
|
296
270
|
commandsRef={triage.listCommandsRef}
|
|
297
271
|
onTriageContextChange={triage.onTriageContextChange}
|
|
298
272
|
onDeleteMessages={onDeleteMessages}
|
|
@@ -386,10 +360,10 @@ function BriefPhone() {
|
|
|
386
360
|
const {
|
|
387
361
|
selectedThread,
|
|
388
362
|
conversation,
|
|
389
|
-
|
|
363
|
+
onOpenThread,
|
|
390
364
|
onCloseThread,
|
|
391
|
-
|
|
392
|
-
|
|
365
|
+
nextThread,
|
|
366
|
+
previousThread,
|
|
393
367
|
handleDeselectIfRemoved,
|
|
394
368
|
} = useBriefPane();
|
|
395
369
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
@@ -405,13 +379,9 @@ function BriefPhone() {
|
|
|
405
379
|
authenticity={conversation.authenticity}
|
|
406
380
|
onBack={onCloseThread}
|
|
407
381
|
onOpenIntelligence={onToggleIntelligence}
|
|
408
|
-
onSwipeNext={
|
|
409
|
-
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
410
|
-
}
|
|
382
|
+
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
411
383
|
onSwipePrevious={
|
|
412
|
-
|
|
413
|
-
? () => onSelectMessage(previousMessageId)
|
|
414
|
-
: undefined
|
|
384
|
+
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
415
385
|
}
|
|
416
386
|
mobileIntelligenceOpen={intelligenceOpen}
|
|
417
387
|
/>
|
|
@@ -105,6 +105,7 @@ import {
|
|
|
105
105
|
type SelectionWizardControl,
|
|
106
106
|
useSelectionWizard,
|
|
107
107
|
} from "@/lib/wizard-history";
|
|
108
|
+
import type { BriefThreadTarget } from "@/routing";
|
|
108
109
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
109
110
|
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
110
111
|
import type { MessageListCommands } from "./MessageList";
|
|
@@ -378,14 +379,13 @@ function BriefSelectionChrome({
|
|
|
378
379
|
interface DailyBriefProps {
|
|
379
380
|
accounts: RemitImapAccountResponse[];
|
|
380
381
|
selectedMessageId?: string;
|
|
381
|
-
/** Opens an in-list brief row (resolved by messageId against the loaded list). */
|
|
382
|
-
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
383
382
|
/**
|
|
384
|
-
* Opens a
|
|
385
|
-
*
|
|
383
|
+
* Opens a row. Every row the brief renders names its thread, so the row the
|
|
384
|
+
* search widened in from another folder opens by exactly the same route as one
|
|
385
|
+
* from the unified inbox.
|
|
386
386
|
*/
|
|
387
|
-
|
|
388
|
-
|
|
387
|
+
onOpenThread?: (
|
|
388
|
+
target: BriefThreadTarget,
|
|
389
389
|
options?: OpenMessageOptions,
|
|
390
390
|
) => void;
|
|
391
391
|
/** Where the list publishes the commands the keyboard layer drives. */
|
|
@@ -398,8 +398,7 @@ interface DailyBriefProps {
|
|
|
398
398
|
export function DailyBrief({
|
|
399
399
|
accounts,
|
|
400
400
|
selectedMessageId,
|
|
401
|
-
|
|
402
|
-
onSelectSearchResult,
|
|
401
|
+
onOpenThread,
|
|
403
402
|
commandsRef,
|
|
404
403
|
onTriageContextChange,
|
|
405
404
|
onDeleteMessages,
|
|
@@ -618,26 +617,29 @@ export function DailyBrief({
|
|
|
618
617
|
);
|
|
619
618
|
|
|
620
619
|
// A committed query puts rows the widened cross-folder search found into the
|
|
621
|
-
// body, and those messages are in folders the brief itself never loads.
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
// instead, which carries the thread and mailbox the conversation is fetched
|
|
625
|
-
// by (#635).
|
|
620
|
+
// body, and those messages are in folders the brief itself never loads. They
|
|
621
|
+
// open like any other row: what the address carries is the thread, which
|
|
622
|
+
// every row names, and the conversation is fetched by it (#635).
|
|
626
623
|
const openRow = useCallback(
|
|
627
624
|
(id: string, options?: OpenMessageOptions) => {
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
625
|
+
const threadId = filteredRows.find((row) => row.id === id)?.threadId;
|
|
626
|
+
if (!threadId) return;
|
|
627
|
+
onOpenThread?.({ threadId, messageId: id }, options);
|
|
628
|
+
},
|
|
629
|
+
[filteredRows, onOpenThread],
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// The two-engine results panel, whose semantic hits are in no list at all —
|
|
633
|
+
// each one carries the thread it belongs to.
|
|
634
|
+
const openResult = useCallback(
|
|
635
|
+
(result: SearchResult) => {
|
|
636
|
+
const threadId =
|
|
637
|
+
result.threadId ??
|
|
638
|
+
filteredRows.find((row) => row.id === result.id)?.threadId;
|
|
639
|
+
if (!threadId) return;
|
|
640
|
+
onOpenThread?.({ threadId, messageId: result.id });
|
|
639
641
|
},
|
|
640
|
-
[
|
|
642
|
+
[filteredRows, onOpenThread],
|
|
641
643
|
);
|
|
642
644
|
|
|
643
645
|
const accountSources = useMemo<FilterSheetSource[]>(() => {
|
|
@@ -903,7 +905,7 @@ export function DailyBrief({
|
|
|
903
905
|
searchLoading: isLoading || searchFetching,
|
|
904
906
|
relatedResults,
|
|
905
907
|
relatedLoading,
|
|
906
|
-
onSelectSearchResult,
|
|
908
|
+
onSelectSearchResult: openResult,
|
|
907
909
|
// The body already narrows to the committed query
|
|
908
910
|
// (`matchesBriefSearch` + `matchesSearchTokens` + the server `query`,
|
|
909
911
|
// above), so a committed search is a selectable list here exactly as
|
|
@@ -229,7 +229,7 @@ export function MailSidebarAdapter({
|
|
|
229
229
|
return (
|
|
230
230
|
<NavLink
|
|
231
231
|
to="/mail/brief"
|
|
232
|
-
search={{ q: undefined
|
|
232
|
+
search={{ q: undefined }}
|
|
233
233
|
activeOptions={{ includeSearch: false }}
|
|
234
234
|
onClick={() => onClick?.()}
|
|
235
235
|
className={className}
|
|
@@ -340,10 +340,7 @@ export function MailSidebarAdapter({
|
|
|
340
340
|
const handleSelectSavedSearch = useCallback(
|
|
341
341
|
(query: string) => {
|
|
342
342
|
onSearchChange(query);
|
|
343
|
-
navigate({
|
|
344
|
-
to: "/mail/brief",
|
|
345
|
-
search: { q: query, selectedMessageId: undefined },
|
|
346
|
-
});
|
|
343
|
+
navigate({ to: "/mail/brief", search: { q: query } });
|
|
347
344
|
onMailboxSelect?.();
|
|
348
345
|
},
|
|
349
346
|
[onSearchChange, navigate, onMailboxSelect],
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the search mirror writes, now that the brief's open thread is a path
|
|
3
|
+
* segment (#718).
|
|
4
|
+
*
|
|
5
|
+
* Mirroring `q` used to be one navigation with one destination, because the
|
|
6
|
+
* thread travelled in the query and `...prev` carried it along. It is now two:
|
|
7
|
+
* a query going active closes the reading pane, and that close is the address
|
|
8
|
+
* walking up to the list — while every other write has to keep the address it
|
|
9
|
+
* found, or clearing the search would shut the conversation being read.
|
|
10
|
+
*
|
|
11
|
+
* Driven through a real router over the brief's real route shape, because the
|
|
12
|
+
* whole question is what the router resolves a destination to from a thread
|
|
13
|
+
* route. Reasoning about `to: "."` is not evidence.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import {
|
|
19
|
+
type AnyRouter,
|
|
20
|
+
createMemoryHistory,
|
|
21
|
+
createRootRoute,
|
|
22
|
+
createRoute,
|
|
23
|
+
createRouter,
|
|
24
|
+
Outlet,
|
|
25
|
+
RouterProvider,
|
|
26
|
+
} from "@tanstack/react-router";
|
|
27
|
+
import { createElement } from "react";
|
|
28
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
29
|
+
import { mailViewKey } from "@/lib/mail-route";
|
|
30
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
31
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
32
|
+
import { useSearchMirror } from "./useSearchMirror";
|
|
33
|
+
|
|
34
|
+
let harness: DomHarness | undefined;
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
harness?.close();
|
|
38
|
+
harness = undefined;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
42
|
+
// `window`.
|
|
43
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
44
|
+
|
|
45
|
+
const THREAD_ID = "th-1";
|
|
46
|
+
const MESSAGE_ID = "msg-1";
|
|
47
|
+
|
|
48
|
+
const mailContext = (input: string, committed: string): MailContextValue => ({
|
|
49
|
+
accounts: [],
|
|
50
|
+
mailboxNameIndex: new Map(),
|
|
51
|
+
accountNameIndex: new Map(),
|
|
52
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
53
|
+
searchQuery: committed,
|
|
54
|
+
searchInput: input,
|
|
55
|
+
searchViewKey: "/mail/brief",
|
|
56
|
+
onSearchChange: () => {},
|
|
57
|
+
onSearchClear: () => {},
|
|
58
|
+
onSearchClearQuery: () => {},
|
|
59
|
+
intelligenceOpen: false,
|
|
60
|
+
onToggleIntelligence: () => {},
|
|
61
|
+
onSetIntelligenceOpen: () => {},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The brief's shape: the list is a layout route and the thread and message are
|
|
66
|
+
* segments under it, so a write from the list has a matched child to lose.
|
|
67
|
+
*/
|
|
68
|
+
const routerAt = (href: string): AnyRouter => {
|
|
69
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
70
|
+
const passthrough = (search: Record<string, unknown>) => search;
|
|
71
|
+
const mailRoute = createRoute({
|
|
72
|
+
getParentRoute: () => rootRoute,
|
|
73
|
+
path: "/mail",
|
|
74
|
+
validateSearch: passthrough,
|
|
75
|
+
component: Outlet,
|
|
76
|
+
});
|
|
77
|
+
const briefRoute = createRoute({
|
|
78
|
+
getParentRoute: () => mailRoute,
|
|
79
|
+
path: "/brief",
|
|
80
|
+
validateSearch: passthrough,
|
|
81
|
+
component: () => {
|
|
82
|
+
useSearchMirror({ to: "/mail/brief" });
|
|
83
|
+
return createElement(Outlet);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const threadRoute = createRoute({
|
|
87
|
+
getParentRoute: () => briefRoute,
|
|
88
|
+
path: "/$threadId",
|
|
89
|
+
component: Outlet,
|
|
90
|
+
});
|
|
91
|
+
const messageRoute = createRoute({
|
|
92
|
+
getParentRoute: () => threadRoute,
|
|
93
|
+
path: "/$messageId",
|
|
94
|
+
component: () => null,
|
|
95
|
+
});
|
|
96
|
+
const routeTree = rootRoute.addChildren([
|
|
97
|
+
mailRoute.addChildren([
|
|
98
|
+
briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
99
|
+
]),
|
|
100
|
+
]);
|
|
101
|
+
return createRouter({
|
|
102
|
+
routeTree,
|
|
103
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
104
|
+
}) as unknown as AnyRouter;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const mount = async (
|
|
108
|
+
router: AnyRouter,
|
|
109
|
+
input: string,
|
|
110
|
+
committed: string,
|
|
111
|
+
): Promise<DomHarness> => {
|
|
112
|
+
const created = createDomHarness();
|
|
113
|
+
harness = created;
|
|
114
|
+
await router.load();
|
|
115
|
+
created.renderApp(
|
|
116
|
+
createElement(
|
|
117
|
+
MailContext.Provider,
|
|
118
|
+
{ value: mailContext(input, committed) },
|
|
119
|
+
createElement(RouterProvider, { router }),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
await created.flush();
|
|
123
|
+
await created.wait(20);
|
|
124
|
+
return created;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const threadHref = `/mail/brief/${THREAD_ID}/${MESSAGE_ID}`;
|
|
128
|
+
|
|
129
|
+
describe("mirroring a query that goes active", () => {
|
|
130
|
+
it("walks up to the list, so no thread stays matched behind the results", async () => {
|
|
131
|
+
const router = routerAt(threadHref);
|
|
132
|
+
await mount(router, "invoice", "invoice");
|
|
133
|
+
|
|
134
|
+
assert.equal(router.history.location.pathname, "/mail/brief");
|
|
135
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("closes the pane from a bare thread address too", async () => {
|
|
139
|
+
const router = routerAt(`/mail/brief/${THREAD_ID}`);
|
|
140
|
+
await mount(router, "invoice", "invoice");
|
|
141
|
+
|
|
142
|
+
assert.equal(router.history.location.pathname, "/mail/brief");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("replaces rather than pushes, so Back is not a search step", async () => {
|
|
146
|
+
const router = routerAt(threadHref);
|
|
147
|
+
const before = router.history.length;
|
|
148
|
+
await mount(router, "invoice", "invoice");
|
|
149
|
+
|
|
150
|
+
assert.equal(router.history.length, before);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("mirroring a cleared query", () => {
|
|
155
|
+
it("keeps the conversation open — dropping the search is not closing it", async () => {
|
|
156
|
+
const router = routerAt(`${threadHref}?q=invoice`);
|
|
157
|
+
await mount(router, "", "");
|
|
158
|
+
|
|
159
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
160
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("leaves the address alone when there is nothing to write", async () => {
|
|
164
|
+
const router = routerAt(`${threadHref}?q=invoice`);
|
|
165
|
+
await mount(router, "invoice", "invoice");
|
|
166
|
+
|
|
167
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
168
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("waits for the debounce rather than writing the previous query", async () => {
|
|
172
|
+
const router = routerAt(threadHref);
|
|
173
|
+
await mount(router, "invo", "");
|
|
174
|
+
|
|
175
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
176
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The other half of why a typed query survives an opened thread: the field
|
|
182
|
+
* re-seeds on a view change, so the view key of the list and of the thread route
|
|
183
|
+
* under it must be equal.
|
|
184
|
+
*
|
|
185
|
+
* Read off the matches the router resolved for a real address, so the ids under
|
|
186
|
+
* test are the ones path segments produce rather than ids written out here.
|
|
187
|
+
*/
|
|
188
|
+
describe("the view key of an open brief thread", () => {
|
|
189
|
+
const viewKeyAt = async (href: string): Promise<string> => {
|
|
190
|
+
const router = routerAt(href);
|
|
191
|
+
await router.load();
|
|
192
|
+
return mailViewKey(
|
|
193
|
+
router.state.matches.map((match: { routeId: string }) => ({
|
|
194
|
+
routeId: match.routeId,
|
|
195
|
+
})),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it("is the brief's own, thread and message segments alike", async () => {
|
|
200
|
+
const list = await viewKeyAt("/mail/brief");
|
|
201
|
+
assert.notEqual(list, "", "the brief resolves to a view key at all");
|
|
202
|
+
assert.equal(await viewKeyAt(`/mail/brief/${THREAD_ID}`), list);
|
|
203
|
+
assert.equal(await viewKeyAt(threadHref), list);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -28,14 +28,19 @@ export type SearchMirrorTarget =
|
|
|
28
28
|
* flight and replacing the entry the reader had just pushed. They would click
|
|
29
29
|
* Inbox and land back on the brief.
|
|
30
30
|
*
|
|
31
|
-
* When a query *goes* active it also
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
31
|
+
* When a query *goes* active it also closes the reading pane (#539): an open
|
|
32
|
+
* message from the pre-search list is not meaningful in the search result set.
|
|
33
|
+
* The close is a navigation to the list route, which unmatches the thread that
|
|
34
|
+
* was open under it — plus, until the remaining lists move their selection into
|
|
35
|
+
* the path, dropping the params they still read it from. Any other write keeps
|
|
36
|
+
* the address it found: mirroring a query the reader is editing, or clearing
|
|
37
|
+
* one, must not shut the conversation they are reading.
|
|
38
|
+
*
|
|
39
|
+
* Only on that transition, though — tapping a search result commits the same `q`
|
|
40
|
+
* with the open thread, so when the URL already says the query the conversation
|
|
41
|
+
* was opened under it (not a pre-search leftover) and must survive. The close
|
|
42
|
+
* otherwise raced the tap: the row shows before the debounce settles, so this
|
|
43
|
+
* mirror can land just after the open and undo it.
|
|
39
44
|
*/
|
|
40
45
|
export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
41
46
|
const navigate = useNavigate();
|
|
@@ -61,20 +66,23 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
61
66
|
listPath,
|
|
62
67
|
});
|
|
63
68
|
if (!mayWrite) return;
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
const queryGoesActive =
|
|
70
|
+
Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
|
|
71
|
+
const search = (prev: Record<string, unknown>) => ({
|
|
72
|
+
...prev,
|
|
73
|
+
q: committedQuery || undefined,
|
|
74
|
+
...(queryGoesActive
|
|
75
|
+
? {
|
|
76
|
+
selectedMessageId: undefined,
|
|
77
|
+
selectedThreadId: undefined,
|
|
78
|
+
selectedMailboxId: undefined,
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
});
|
|
82
|
+
if (!queryGoesActive) {
|
|
83
|
+
navigate({ to: ".", search, replace: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
78
86
|
if (to === "/mail/$mailboxId") {
|
|
79
87
|
if (!mailboxId) return;
|
|
80
88
|
navigate({ to, params: { mailboxId }, search, replace: true });
|
|
@@ -39,7 +39,7 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
|
|
|
39
39
|
if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
|
|
40
40
|
navigate({
|
|
41
41
|
to: "/mail/brief",
|
|
42
|
-
search: { q: searchInput || undefined
|
|
42
|
+
search: { q: searchInput || undefined },
|
|
43
43
|
});
|
|
44
44
|
},
|
|
45
45
|
[navigate, searchInput],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A thread's own row for the message the reader pointed at.
|
|
7
|
+
*
|
|
8
|
+
* This is how a conversation answers for itself when no list holds it — a
|
|
9
|
+
* cross-folder search hit, or an address pasted into a fresh tab.
|
|
10
|
+
* `GET /threads/{threadId}/messages` returns the same row shape a listing does,
|
|
11
|
+
* `mailboxId` included, so the thread id is the whole address even though the
|
|
12
|
+
* mail is filed in some folder. The reading pane already makes that request, so
|
|
13
|
+
* this resolves off the same cache entry rather than a round trip of its own.
|
|
14
|
+
*
|
|
15
|
+
* A thread spans folders — the reader's own reply sits in Sent — so `messageId`
|
|
16
|
+
* picks the row they pointed at. With none, the newest message answers.
|
|
17
|
+
*/
|
|
18
|
+
export const useThreadRow = (
|
|
19
|
+
threadId: string | undefined,
|
|
20
|
+
messageId: string | undefined,
|
|
21
|
+
): RemitImapThreadMessageResponse | undefined => {
|
|
22
|
+
const { data } = useQuery({
|
|
23
|
+
...threadDetailOperationsListThreadMessagesOptions({
|
|
24
|
+
path: { threadId: threadId ?? "" },
|
|
25
|
+
}),
|
|
26
|
+
enabled: Boolean(threadId),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const items = data?.items ?? [];
|
|
30
|
+
const pointedAt = messageId
|
|
31
|
+
? items.find((item) => item.messageId === messageId)
|
|
32
|
+
: undefined;
|
|
33
|
+
const newest = items.length > 0 ? items[items.length - 1] : undefined;
|
|
34
|
+
return pointedAt ?? newest;
|
|
35
|
+
};
|
|
@@ -20,6 +20,7 @@ import assert from "node:assert/strict";
|
|
|
20
20
|
import { describe, it } from "node:test";
|
|
21
21
|
import {
|
|
22
22
|
locationIsOnList,
|
|
23
|
+
locationOpensDetail,
|
|
23
24
|
MAIL_BRIEF_ROUTE_ID,
|
|
24
25
|
MAIL_FLAGGED_ROUTE_ID,
|
|
25
26
|
MAIL_MAILBOX_ROUTE_ID,
|
|
@@ -178,3 +179,28 @@ describe("locationIsOnList", () => {
|
|
|
178
179
|
assert.equal(locationIsOnList("/mail/outbox-2024", "/mail/outbox"), false);
|
|
179
180
|
});
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What "a thread is open" used to be asked of the query. Everything sharing the
|
|
185
|
+
* single pane with the conversation reads it from the address instead.
|
|
186
|
+
*/
|
|
187
|
+
describe("locationOpensDetail", () => {
|
|
188
|
+
it("is true for a thread and for the message inside it", () => {
|
|
189
|
+
assert.equal(locationOpensDetail("/mail/brief/thread-1"), true);
|
|
190
|
+
assert.equal(locationOpensDetail("/mail/brief/thread-1/message-1"), true);
|
|
191
|
+
assert.equal(locationOpensDetail("/mail/inbox-1/thread-1"), true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("is false on a bare list, trailing slash and query included", () => {
|
|
195
|
+
assert.equal(locationOpensDetail("/mail/brief"), false);
|
|
196
|
+
assert.equal(locationOpensDetail("/mail/brief/"), false);
|
|
197
|
+
assert.equal(locationOpensDetail("/mail/brief?q=invoice"), false);
|
|
198
|
+
assert.equal(locationOpensDetail("/mail/inbox-1"), false);
|
|
199
|
+
assert.equal(locationOpensDetail("/mail"), false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("is false outside the mail shell", () => {
|
|
203
|
+
assert.equal(locationOpensDetail("/settings/accounts"), false);
|
|
204
|
+
assert.equal(locationOpensDetail("/onboarding"), false);
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/lib/mail-route.ts
CHANGED
|
@@ -92,3 +92,16 @@ export function locationIsOnList(pathname: string, listPath: string): boolean {
|
|
|
92
92
|
if (pathname === listPath) return true;
|
|
93
93
|
return pathname.startsWith(`${listPath}/`);
|
|
94
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Whether the address names something the list has open below it — a thread, a
|
|
98
|
+
* message. A list on its own, and its bare reading pane, do not.
|
|
99
|
+
*
|
|
100
|
+
* Answers what an open thread used to be asked of the query. Anything sharing
|
|
101
|
+
* the single pane with the conversation reads it from here rather than keeping a
|
|
102
|
+
* second opinion about what is on screen.
|
|
103
|
+
*/
|
|
104
|
+
export function locationOpensDetail(pathname: string): boolean {
|
|
105
|
+
const segments = pathname.split(/[?#]/)[0].split("/").filter(Boolean);
|
|
106
|
+
return segments[0] === "mail" && segments.length > 2;
|
|
107
|
+
}
|
package/src/lib/mail-search.ts
CHANGED
|
@@ -12,13 +12,13 @@ import { z } from "zod";
|
|
|
12
12
|
/** What every list carries, whatever else it carries. */
|
|
13
13
|
const listSearch = z.object({ q: z.string().optional() });
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
15
|
+
/**
|
|
16
|
+
* The brief's open thread and the message inside it are path segments
|
|
17
|
+
* (`/mail/brief/<thread>/<message>`), so the query carries nothing but the
|
|
18
|
+
* search. An old link's selection params are dropped here, which is what
|
|
19
|
+
* "tolerated and ignored" means until the other three lists follow.
|
|
20
|
+
*/
|
|
21
|
+
export const briefSearchSchema = listSearch.extend({});
|
|
22
22
|
|
|
23
23
|
export const flaggedSearchSchema = listSearch.extend({
|
|
24
24
|
selectedMessageId: z.string().optional(),
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -29,8 +29,11 @@ import { Route as SettingsSendersRouteImport } from './routes/settings/senders'
|
|
|
29
29
|
import { Route as SettingsSuggestedVipsRouteImport } from './routes/settings/suggested-vips'
|
|
30
30
|
import { Route as MailMailboxIdIndexRouteImport } from './routes/mail/$mailboxId/index'
|
|
31
31
|
import { Route as MailBriefIndexRouteImport } from './routes/mail/brief/index'
|
|
32
|
+
import { Route as MailBriefThreadIdRouteImport } from './routes/mail/brief/$threadId'
|
|
32
33
|
import { Route as MailFlaggedIndexRouteImport } from './routes/mail/flagged/index'
|
|
33
34
|
import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
|
|
35
|
+
import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
|
|
36
|
+
import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
|
|
34
37
|
|
|
35
38
|
const IndexRoute = IndexRouteImport.update({
|
|
36
39
|
id: '/',
|
|
@@ -132,6 +135,11 @@ const MailBriefIndexRoute = MailBriefIndexRouteImport.update({
|
|
|
132
135
|
path: '/',
|
|
133
136
|
getParentRoute: () => MailBriefRoute,
|
|
134
137
|
} as any)
|
|
138
|
+
const MailBriefThreadIdRoute = MailBriefThreadIdRouteImport.update({
|
|
139
|
+
id: '/$threadId',
|
|
140
|
+
path: '/$threadId',
|
|
141
|
+
getParentRoute: () => MailBriefRoute,
|
|
142
|
+
} as any)
|
|
135
143
|
const MailFlaggedIndexRoute = MailFlaggedIndexRouteImport.update({
|
|
136
144
|
id: '/',
|
|
137
145
|
path: '/',
|
|
@@ -142,6 +150,17 @@ const MailOutboxIndexRoute = MailOutboxIndexRouteImport.update({
|
|
|
142
150
|
path: '/',
|
|
143
151
|
getParentRoute: () => MailOutboxRoute,
|
|
144
152
|
} as any)
|
|
153
|
+
const MailBriefThreadIdIndexRoute = MailBriefThreadIdIndexRouteImport.update({
|
|
154
|
+
id: '/',
|
|
155
|
+
path: '/',
|
|
156
|
+
getParentRoute: () => MailBriefThreadIdRoute,
|
|
157
|
+
} as any)
|
|
158
|
+
const MailBriefThreadIdMessageIdRoute =
|
|
159
|
+
MailBriefThreadIdMessageIdRouteImport.update({
|
|
160
|
+
id: '/$messageId',
|
|
161
|
+
path: '/$messageId',
|
|
162
|
+
getParentRoute: () => MailBriefThreadIdRoute,
|
|
163
|
+
} as any)
|
|
145
164
|
|
|
146
165
|
export interface FileRoutesByFullPath {
|
|
147
166
|
'/': typeof IndexRoute
|
|
@@ -162,10 +181,13 @@ export interface FileRoutesByFullPath {
|
|
|
162
181
|
'/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
|
|
163
182
|
'/mail/': typeof MailIndexRoute
|
|
164
183
|
'/settings/': typeof SettingsIndexRoute
|
|
184
|
+
'/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
|
|
165
185
|
'/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
|
|
166
186
|
'/mail/brief/': typeof MailBriefIndexRoute
|
|
167
187
|
'/mail/flagged/': typeof MailFlaggedIndexRoute
|
|
168
188
|
'/mail/outbox/': typeof MailOutboxIndexRoute
|
|
189
|
+
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
190
|
+
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
169
191
|
}
|
|
170
192
|
export interface FileRoutesByTo {
|
|
171
193
|
'/': typeof IndexRoute
|
|
@@ -184,6 +206,8 @@ export interface FileRoutesByTo {
|
|
|
184
206
|
'/mail/brief': typeof MailBriefIndexRoute
|
|
185
207
|
'/mail/flagged': typeof MailFlaggedIndexRoute
|
|
186
208
|
'/mail/outbox': typeof MailOutboxIndexRoute
|
|
209
|
+
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
210
|
+
'/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
|
|
187
211
|
}
|
|
188
212
|
export interface FileRoutesById {
|
|
189
213
|
__root__: typeof rootRouteImport
|
|
@@ -205,10 +229,13 @@ export interface FileRoutesById {
|
|
|
205
229
|
'/settings/suggested-vips': typeof SettingsSuggestedVipsRoute
|
|
206
230
|
'/mail/': typeof MailIndexRoute
|
|
207
231
|
'/settings/': typeof SettingsIndexRoute
|
|
232
|
+
'/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
|
|
208
233
|
'/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
|
|
209
234
|
'/mail/brief/': typeof MailBriefIndexRoute
|
|
210
235
|
'/mail/flagged/': typeof MailFlaggedIndexRoute
|
|
211
236
|
'/mail/outbox/': typeof MailOutboxIndexRoute
|
|
237
|
+
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
238
|
+
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
212
239
|
}
|
|
213
240
|
export interface FileRouteTypes {
|
|
214
241
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
@@ -231,10 +258,13 @@ export interface FileRouteTypes {
|
|
|
231
258
|
| '/settings/suggested-vips'
|
|
232
259
|
| '/mail/'
|
|
233
260
|
| '/settings/'
|
|
261
|
+
| '/mail/brief/$threadId'
|
|
234
262
|
| '/mail/$mailboxId/'
|
|
235
263
|
| '/mail/brief/'
|
|
236
264
|
| '/mail/flagged/'
|
|
237
265
|
| '/mail/outbox/'
|
|
266
|
+
| '/mail/brief/$threadId/$messageId'
|
|
267
|
+
| '/mail/brief/$threadId/'
|
|
238
268
|
fileRoutesByTo: FileRoutesByTo
|
|
239
269
|
to:
|
|
240
270
|
| '/'
|
|
@@ -253,6 +283,8 @@ export interface FileRouteTypes {
|
|
|
253
283
|
| '/mail/brief'
|
|
254
284
|
| '/mail/flagged'
|
|
255
285
|
| '/mail/outbox'
|
|
286
|
+
| '/mail/brief/$threadId/$messageId'
|
|
287
|
+
| '/mail/brief/$threadId'
|
|
256
288
|
id:
|
|
257
289
|
| '__root__'
|
|
258
290
|
| '/'
|
|
@@ -273,10 +305,13 @@ export interface FileRouteTypes {
|
|
|
273
305
|
| '/settings/suggested-vips'
|
|
274
306
|
| '/mail/'
|
|
275
307
|
| '/settings/'
|
|
308
|
+
| '/mail/brief/$threadId'
|
|
276
309
|
| '/mail/$mailboxId/'
|
|
277
310
|
| '/mail/brief/'
|
|
278
311
|
| '/mail/flagged/'
|
|
279
312
|
| '/mail/outbox/'
|
|
313
|
+
| '/mail/brief/$threadId/$messageId'
|
|
314
|
+
| '/mail/brief/$threadId/'
|
|
280
315
|
fileRoutesById: FileRoutesById
|
|
281
316
|
}
|
|
282
317
|
export interface RootRouteChildren {
|
|
@@ -428,6 +463,13 @@ declare module '@tanstack/react-router' {
|
|
|
428
463
|
preLoaderRoute: typeof MailBriefIndexRouteImport
|
|
429
464
|
parentRoute: typeof MailBriefRoute
|
|
430
465
|
}
|
|
466
|
+
'/mail/brief/$threadId': {
|
|
467
|
+
id: '/mail/brief/$threadId'
|
|
468
|
+
path: '/$threadId'
|
|
469
|
+
fullPath: '/mail/brief/$threadId'
|
|
470
|
+
preLoaderRoute: typeof MailBriefThreadIdRouteImport
|
|
471
|
+
parentRoute: typeof MailBriefRoute
|
|
472
|
+
}
|
|
431
473
|
'/mail/flagged/': {
|
|
432
474
|
id: '/mail/flagged/'
|
|
433
475
|
path: '/'
|
|
@@ -442,6 +484,20 @@ declare module '@tanstack/react-router' {
|
|
|
442
484
|
preLoaderRoute: typeof MailOutboxIndexRouteImport
|
|
443
485
|
parentRoute: typeof MailOutboxRoute
|
|
444
486
|
}
|
|
487
|
+
'/mail/brief/$threadId/': {
|
|
488
|
+
id: '/mail/brief/$threadId/'
|
|
489
|
+
path: '/'
|
|
490
|
+
fullPath: '/mail/brief/$threadId/'
|
|
491
|
+
preLoaderRoute: typeof MailBriefThreadIdIndexRouteImport
|
|
492
|
+
parentRoute: typeof MailBriefThreadIdRoute
|
|
493
|
+
}
|
|
494
|
+
'/mail/brief/$threadId/$messageId': {
|
|
495
|
+
id: '/mail/brief/$threadId/$messageId'
|
|
496
|
+
path: '/$messageId'
|
|
497
|
+
fullPath: '/mail/brief/$threadId/$messageId'
|
|
498
|
+
preLoaderRoute: typeof MailBriefThreadIdMessageIdRouteImport
|
|
499
|
+
parentRoute: typeof MailBriefThreadIdRoute
|
|
500
|
+
}
|
|
445
501
|
}
|
|
446
502
|
}
|
|
447
503
|
|
|
@@ -457,11 +513,26 @@ const MailMailboxIdRouteWithChildren = MailMailboxIdRoute._addFileChildren(
|
|
|
457
513
|
MailMailboxIdRouteChildren,
|
|
458
514
|
)
|
|
459
515
|
|
|
516
|
+
interface MailBriefThreadIdRouteChildren {
|
|
517
|
+
MailBriefThreadIdMessageIdRoute: typeof MailBriefThreadIdMessageIdRoute
|
|
518
|
+
MailBriefThreadIdIndexRoute: typeof MailBriefThreadIdIndexRoute
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const MailBriefThreadIdRouteChildren: MailBriefThreadIdRouteChildren = {
|
|
522
|
+
MailBriefThreadIdMessageIdRoute: MailBriefThreadIdMessageIdRoute,
|
|
523
|
+
MailBriefThreadIdIndexRoute: MailBriefThreadIdIndexRoute,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const MailBriefThreadIdRouteWithChildren =
|
|
527
|
+
MailBriefThreadIdRoute._addFileChildren(MailBriefThreadIdRouteChildren)
|
|
528
|
+
|
|
460
529
|
interface MailBriefRouteChildren {
|
|
530
|
+
MailBriefThreadIdRoute: typeof MailBriefThreadIdRouteWithChildren
|
|
461
531
|
MailBriefIndexRoute: typeof MailBriefIndexRoute
|
|
462
532
|
}
|
|
463
533
|
|
|
464
534
|
const MailBriefRouteChildren: MailBriefRouteChildren = {
|
|
535
|
+
MailBriefThreadIdRoute: MailBriefThreadIdRouteWithChildren,
|
|
465
536
|
MailBriefIndexRoute: MailBriefIndexRoute,
|
|
466
537
|
}
|
|
467
538
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mail/brief/$threadId/$messageId — the same conversation, with one of its
|
|
3
|
+
* messages expanded and scrolled to.
|
|
4
|
+
*
|
|
5
|
+
* A message is not addressable on its own: `GET /messages/{messageId}` answers
|
|
6
|
+
* with no thread, so the pane has nothing to fetch by. The segment names which
|
|
7
|
+
* message inside the thread the reader pointed at, and the surface it renders is
|
|
8
|
+
* the thread's.
|
|
9
|
+
*/
|
|
10
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
11
|
+
import { BriefPane } from "@/components/mail/BriefPane";
|
|
12
|
+
|
|
13
|
+
function BriefMessagePane() {
|
|
14
|
+
return <BriefPane.Reading />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/mail/brief/$threadId/$messageId")({
|
|
18
|
+
component: BriefMessagePane,
|
|
19
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread with no message named. The pane opens on the newest message, the
|
|
3
|
+
* same as for a thread whose row the reader never pointed at.
|
|
4
|
+
*/
|
|
5
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
6
|
+
import { BriefPane } from "@/components/mail/BriefPane";
|
|
7
|
+
|
|
8
|
+
function BriefThreadPane() {
|
|
9
|
+
return <BriefPane.Reading />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Route = createFileRoute("/mail/brief/$threadId/")({
|
|
13
|
+
component: BriefThreadPane,
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mail/brief/$threadId — a conversation open in the brief's reading pane.
|
|
3
|
+
*
|
|
4
|
+
* The brief is cross-mailbox, and the thread is still the whole address: the
|
|
5
|
+
* folder its mail is filed in comes from the thread's own data, since
|
|
6
|
+
* `GET /threads/{threadId}/messages` answers with rows that each name their
|
|
7
|
+
* mailbox. A segment for the folder would be a second owner of a fact the
|
|
8
|
+
* thread already carries.
|
|
9
|
+
*
|
|
10
|
+
* A layout, because the message segment nests under it.
|
|
11
|
+
*/
|
|
12
|
+
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
13
|
+
|
|
14
|
+
export const Route = createFileRoute("/mail/brief/$threadId")({
|
|
15
|
+
component: Outlet,
|
|
16
|
+
});
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The route mounts the list and the shell around it, and the reading pane is
|
|
5
5
|
* the `Outlet`, so what the pane shows is whatever route is matched under this
|
|
6
|
-
* one rather than a decision made somewhere above.
|
|
6
|
+
* one rather than a decision made somewhere above. The open thread and the
|
|
7
|
+
* message inside it are the segments below, which is why nothing here reads a
|
|
8
|
+
* selection out of the query.
|
|
7
9
|
*/
|
|
8
10
|
import {
|
|
9
11
|
createFileRoute,
|
|
@@ -15,6 +17,7 @@ import { BriefPane } from "@/components/mail/BriefPane";
|
|
|
15
17
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
16
18
|
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
17
19
|
import { briefSearchSchema } from "@/lib/mail-search";
|
|
20
|
+
import { useBriefThreadPath } from "@/routing";
|
|
18
21
|
|
|
19
22
|
const BriefError = ({ error, reset }: ErrorComponentProps) => (
|
|
20
23
|
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
@@ -27,17 +30,17 @@ const BriefError = ({ error, reset }: ErrorComponentProps) => (
|
|
|
27
30
|
);
|
|
28
31
|
|
|
29
32
|
function BriefLayout() {
|
|
30
|
-
const
|
|
33
|
+
const thread = useBriefThreadPath();
|
|
31
34
|
useSearchMirror({ to: "/mail/brief" });
|
|
32
35
|
|
|
33
36
|
return (
|
|
34
|
-
<BriefPane
|
|
37
|
+
<BriefPane thread={thread}>
|
|
35
38
|
<MailShell
|
|
36
39
|
phone={<BriefPane.Phone />}
|
|
37
40
|
list={<BriefPane.List />}
|
|
38
41
|
reading={<Outlet />}
|
|
39
42
|
intelligence={<BriefPane.Intelligence />}
|
|
40
|
-
hasThread={Boolean(
|
|
43
|
+
hasThread={Boolean(thread)}
|
|
41
44
|
/>
|
|
42
45
|
</BriefPane>
|
|
43
46
|
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useParams } from "@tanstack/react-router";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
|
|
4
|
+
/** The conversation the brief has open, as the address states it. */
|
|
5
|
+
export interface BriefThreadPath {
|
|
6
|
+
threadId: string;
|
|
7
|
+
/**
|
|
8
|
+
* Which message inside the thread is expanded and scrolled to. Absent on a
|
|
9
|
+
* bare thread address, where the newest message answers for the conversation.
|
|
10
|
+
*/
|
|
11
|
+
messageId: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A conversation to open. The thread is what the pane fetches by; the message is
|
|
16
|
+
* the row the reader pointed at, so a list always knows both.
|
|
17
|
+
*/
|
|
18
|
+
export interface BriefThreadTarget {
|
|
19
|
+
threadId: string;
|
|
20
|
+
messageId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The thread the brief has open, read off the path.
|
|
25
|
+
*
|
|
26
|
+
* The thread is a child route of the list, and the list layout mounts the pane
|
|
27
|
+
* above the `Outlet` — so it asks the router which of its children matched
|
|
28
|
+
* rather than reading a param it does not own. Each `from` names a real route,
|
|
29
|
+
* so a segment that does not exist fails to compile.
|
|
30
|
+
*/
|
|
31
|
+
export function useBriefThreadPath(): BriefThreadPath | undefined {
|
|
32
|
+
const thread = useParams({
|
|
33
|
+
from: "/mail/brief/$threadId",
|
|
34
|
+
shouldThrow: false,
|
|
35
|
+
});
|
|
36
|
+
const message = useParams({
|
|
37
|
+
from: "/mail/brief/$threadId/$messageId",
|
|
38
|
+
shouldThrow: false,
|
|
39
|
+
});
|
|
40
|
+
const threadId = thread?.threadId;
|
|
41
|
+
const messageId = message?.messageId;
|
|
42
|
+
|
|
43
|
+
return useMemo(
|
|
44
|
+
() => (threadId ? { threadId, messageId } : undefined),
|
|
45
|
+
[threadId, messageId],
|
|
46
|
+
);
|
|
47
|
+
}
|