@remit/web-client 0.0.146 → 0.0.148
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/InlineCompose.tsx +6 -1
- package/src/components/layout/ComposeFab.tsx +7 -3
- package/src/components/mail/BriefPane.tsx +92 -122
- package/src/components/mail/ConversationView.tsx +19 -11
- package/src/components/mail/DailyBrief.tsx +28 -26
- package/src/components/mail/MailSidebarAdapter.tsx +2 -5
- package/src/components/mail/conversation-reply-reach.render.test.ts +223 -0
- 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.148",
|
|
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": {
|
|
@@ -21,7 +21,12 @@ export const InlineCompose = ({
|
|
|
21
21
|
// Capped against the viewport as well as in pixels: on a short one — a phone
|
|
22
22
|
// in landscape, or one with the keyboard up — a flat 400px is taller than
|
|
23
23
|
// the pane, and the surface's own header scrolls off before its verbs do.
|
|
24
|
-
|
|
24
|
+
//
|
|
25
|
+
// A desktop pane has room a phone does not, so the surface takes a height
|
|
26
|
+
// there rather than a ceiling. Left to size itself, the recipient rows and
|
|
27
|
+
// the verbs come first and the writing area gets its 120px floor — a couple
|
|
28
|
+
// of lines to answer a message in.
|
|
29
|
+
<div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col lg:h-[min(560px,55dvh)] lg:max-h-none">
|
|
25
30
|
<ComposeForm
|
|
26
31
|
mode={mode}
|
|
27
32
|
account={account}
|
|
@@ -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
|
/>
|
|
@@ -322,8 +322,8 @@ export const ConversationView = ({
|
|
|
322
322
|
onComposeClose?.();
|
|
323
323
|
}, [onComposeClose]);
|
|
324
324
|
|
|
325
|
-
//
|
|
326
|
-
//
|
|
325
|
+
// Compose opens below the message, which on a long one is several screens
|
|
326
|
+
// down: without this, replying to it looks like nothing happening.
|
|
327
327
|
//
|
|
328
328
|
// Aligned on its bottom edge, where the send and discard verbs are, and held
|
|
329
329
|
// there while it changes height. It mounts before the message it quotes has
|
|
@@ -483,15 +483,23 @@ export const ConversationView = ({
|
|
|
483
483
|
onOpenIntelligence={onOpenIntelligence}
|
|
484
484
|
/>
|
|
485
485
|
)}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
486
|
+
{/* The reply is part of the conversation, so it scrolls with it. Held
|
|
487
|
+
below the pane instead, it took whatever height the message left
|
|
488
|
+
over — nothing on a long one — and no amount of scrolling could
|
|
489
|
+
reach it. */}
|
|
490
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
491
|
+
{renderMessages(false)}
|
|
492
|
+
{composeMode !== null && (
|
|
493
|
+
<div ref={composeRef}>
|
|
494
|
+
<InlineCompose
|
|
495
|
+
mode={composeMode}
|
|
496
|
+
account={activeAccount}
|
|
497
|
+
sourceMessage={latestMessageData}
|
|
498
|
+
onClose={handleCloseCompose}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
</div>
|
|
495
503
|
</article>
|
|
496
504
|
);
|
|
497
505
|
};
|
|
@@ -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],
|