@remit/web-client 0.0.152 → 0.0.154
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 +2 -2
- package/src/components/compose/ComposeProvider.tsx +7 -29
- package/src/components/compose/compose-clears-open-thread.render.test.ts +32 -22
- package/src/components/layout/ComposeFab.tsx +2 -7
- package/src/components/mail/FlaggedList.tsx +45 -15
- package/src/components/mail/FlaggedPane.tsx +106 -54
- package/src/components/mail/MailSidebarAdapter.tsx +1 -1
- package/src/components/mail/MessageActionMenu.tsx +2 -10
- package/src/components/mail/MessageList.tsx +1 -1
- package/src/hooks/search-mirror-detail.render.test.ts +85 -61
- package/src/hooks/useSearchMirror.ts +3 -11
- package/src/lib/mail-search.ts +3 -4
- package/src/routeTree.gen.ts +72 -0
- package/src/routes/mail/flagged/$threadId/$messageId.tsx +19 -0
- package/src/routes/mail/flagged/$threadId/index.tsx +14 -0
- package/src/routes/mail/flagged/$threadId.tsx +16 -0
- package/src/routes/mail/flagged.tsx +7 -4
- package/src/routing/open-thread.ts +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.154",
|
|
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": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
|
|
30
30
|
"preview": "vite preview",
|
|
31
31
|
"test:typecheck": "npm run generate:routes && tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
|
|
32
|
-
"test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
32
|
+
"test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node $NODE_TEST_FLAGS --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
|
|
33
33
|
"test": "npm run test:typecheck && npm run test:run"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
@@ -115,15 +115,11 @@ export const ComposeProvider = ({
|
|
|
115
115
|
// mounts `FullCompose`, and only with no thread in the pane it takes over.
|
|
116
116
|
const openCompose = useCallback(
|
|
117
117
|
(params: Omit<ComposeState, "isOpen">) => {
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
// navigation up to the list; the lists still to move name it in the query.
|
|
118
|
+
// Every list names its open conversation in the path, so leaving one is a
|
|
119
|
+
// navigation up to the list.
|
|
121
120
|
const threadMailboxId = locationOpensDetail(location.pathname)
|
|
122
121
|
? routeParams?.mailboxId
|
|
123
122
|
: undefined;
|
|
124
|
-
const showsThread =
|
|
125
|
-
Boolean(threadMailboxId) ||
|
|
126
|
-
Boolean(search.selectedMessageId ?? search.selectedThreadId);
|
|
127
123
|
if (!hostsComposeSurface(location.pathname)) {
|
|
128
124
|
if (target.status === "loading") {
|
|
129
125
|
pushError({
|
|
@@ -150,33 +146,15 @@ export const ComposeProvider = ({
|
|
|
150
146
|
return;
|
|
151
147
|
}
|
|
152
148
|
setState({ ...params, isOpen: true });
|
|
153
|
-
if (!
|
|
149
|
+
if (!threadMailboxId) return;
|
|
154
150
|
// A push, so Back reopens the message.
|
|
155
|
-
if (threadMailboxId) {
|
|
156
|
-
navigate({
|
|
157
|
-
to: "/mail/$mailboxId",
|
|
158
|
-
params: { mailboxId: threadMailboxId },
|
|
159
|
-
search: (prev) => prev,
|
|
160
|
-
});
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
151
|
navigate({
|
|
164
|
-
to: "
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
selectedMessageId: undefined,
|
|
168
|
-
selectedThreadId: undefined,
|
|
169
|
-
}),
|
|
152
|
+
to: "/mail/$mailboxId",
|
|
153
|
+
params: { mailboxId: threadMailboxId },
|
|
154
|
+
search: (prev) => prev,
|
|
170
155
|
});
|
|
171
156
|
},
|
|
172
|
-
[
|
|
173
|
-
navigate,
|
|
174
|
-
location.pathname,
|
|
175
|
-
location.search,
|
|
176
|
-
routeParams?.mailboxId,
|
|
177
|
-
target,
|
|
178
|
-
pushError,
|
|
179
|
-
],
|
|
157
|
+
[navigate, location.pathname, routeParams?.mailboxId, target, pushError],
|
|
180
158
|
);
|
|
181
159
|
|
|
182
160
|
const closeCompose = useCallback(() => {
|
|
@@ -62,8 +62,29 @@ const RootLayout = () =>
|
|
|
62
62
|
createElement(Outlet),
|
|
63
63
|
);
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The folder's real shape: the thread and the message are segments under the
|
|
67
|
+
* list, so an open conversation is something the address holds and compose has
|
|
68
|
+
* to navigate out of.
|
|
69
|
+
*/
|
|
65
70
|
const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
66
71
|
const rootRoute = createRootRoute({ component: layout });
|
|
72
|
+
const mailboxRoute = createRoute({
|
|
73
|
+
getParentRoute: () => rootRoute,
|
|
74
|
+
path: "/mail/$mailboxId",
|
|
75
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
76
|
+
component: Outlet,
|
|
77
|
+
});
|
|
78
|
+
const threadRoute = createRoute({
|
|
79
|
+
getParentRoute: () => mailboxRoute,
|
|
80
|
+
path: "/$threadId",
|
|
81
|
+
component: Outlet,
|
|
82
|
+
});
|
|
83
|
+
const messageRoute = createRoute({
|
|
84
|
+
getParentRoute: () => threadRoute,
|
|
85
|
+
path: "/$messageId",
|
|
86
|
+
component: () => null,
|
|
87
|
+
});
|
|
67
88
|
const routeTree = rootRoute.addChildren([
|
|
68
89
|
createRoute({
|
|
69
90
|
getParentRoute: () => rootRoute,
|
|
@@ -71,12 +92,7 @@ const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
|
71
92
|
validateSearch: (search: Record<string, unknown>) => search,
|
|
72
93
|
component: () => null,
|
|
73
94
|
}),
|
|
74
|
-
|
|
75
|
-
getParentRoute: () => rootRoute,
|
|
76
|
-
path: "/mail/$mailboxId",
|
|
77
|
-
validateSearch: (search: Record<string, unknown>) => search,
|
|
78
|
-
component: () => null,
|
|
79
|
-
}),
|
|
95
|
+
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
80
96
|
createRoute({
|
|
81
97
|
getParentRoute: () => rootRoute,
|
|
82
98
|
path: "/settings",
|
|
@@ -145,11 +161,11 @@ const press = async (mounted: DomHarness): Promise<HTMLElement> => {
|
|
|
145
161
|
return button;
|
|
146
162
|
};
|
|
147
163
|
|
|
164
|
+
const THREAD_HREF = `/mail/${INBOX_ID}/th-1/msg-1`;
|
|
165
|
+
|
|
148
166
|
describe("opening compose over an open message (#703)", () => {
|
|
149
|
-
it("
|
|
150
|
-
const router = routerAt(
|
|
151
|
-
`/mail/${INBOX_ID}?selectedMessageId=msg-1&selectedThreadId=th-1`,
|
|
152
|
-
);
|
|
167
|
+
it("walks up to the list so the pane can render the surface", async () => {
|
|
168
|
+
const router = routerAt(THREAD_HREF);
|
|
153
169
|
const mounted = await mount(router);
|
|
154
170
|
|
|
155
171
|
const button = mounted.byText("button", "Compose");
|
|
@@ -159,33 +175,27 @@ describe("opening compose over an open message (#703)", () => {
|
|
|
159
175
|
|
|
160
176
|
assert.equal(button.getAttribute("data-open"), "true");
|
|
161
177
|
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
162
|
-
const search = router.history.location.search;
|
|
163
|
-
assert.equal(search.includes("selectedMessageId"), false);
|
|
164
|
-
assert.equal(search.includes("selectedThreadId"), false);
|
|
165
178
|
});
|
|
166
179
|
|
|
167
|
-
it("keeps the
|
|
168
|
-
const router = routerAt(
|
|
169
|
-
`/mail/${INBOX_ID}?q=invoice&selectedMessageId=msg-1`,
|
|
170
|
-
);
|
|
180
|
+
it("keeps the query, so the search the user typed survives", async () => {
|
|
181
|
+
const router = routerAt(`${THREAD_HREF}?q=invoice`);
|
|
171
182
|
const mounted = await mount(router);
|
|
172
183
|
|
|
173
184
|
await press(mounted);
|
|
174
185
|
|
|
175
|
-
|
|
176
|
-
assert.
|
|
177
|
-
assert.match(search, /q=invoice/);
|
|
186
|
+
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
187
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
178
188
|
});
|
|
179
189
|
|
|
180
190
|
it("leaves the message one Back away rather than erasing it", async () => {
|
|
181
|
-
const router = routerAt(
|
|
191
|
+
const router = routerAt(THREAD_HREF);
|
|
182
192
|
const mounted = await mount(router);
|
|
183
193
|
|
|
184
194
|
await press(mounted);
|
|
185
195
|
router.history.back();
|
|
186
196
|
await mounted.flush();
|
|
187
197
|
|
|
188
|
-
assert.
|
|
198
|
+
assert.equal(router.history.location.pathname, THREAD_HREF);
|
|
189
199
|
});
|
|
190
200
|
|
|
191
201
|
it("adds no history entry when the pane had nothing open", async () => {
|
|
@@ -14,9 +14,7 @@ import { locationOpensDetail } from "@/lib/mail-route";
|
|
|
14
14
|
* `lg:hidden` class covers the pre-hydration frame.
|
|
15
15
|
* - The compose surface is already open.
|
|
16
16
|
* - The user is reading a thread — the single pane is the conversation, and
|
|
17
|
-
* its reply bar is under this corner.
|
|
18
|
-
* their path; the flagged list, still to move, says so in
|
|
19
|
-
* `?selectedMessageId=…`.
|
|
17
|
+
* its reply bar is under this corner. Every list says so in its path.
|
|
20
18
|
* - The user is off `/mail`, which is every route with no mail in it.
|
|
21
19
|
*/
|
|
22
20
|
export const ComposeFab = () => {
|
|
@@ -26,10 +24,7 @@ export const ComposeFab = () => {
|
|
|
26
24
|
openCompose({ mode: "new" });
|
|
27
25
|
}, [openCompose]);
|
|
28
26
|
|
|
29
|
-
const
|
|
30
|
-
const isReadingThread =
|
|
31
|
-
Boolean(search?.selectedMessageId) ||
|
|
32
|
-
locationOpensDetail(location.pathname);
|
|
27
|
+
const isReadingThread = locationOpensDetail(location.pathname);
|
|
33
28
|
|
|
34
29
|
if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
|
|
35
30
|
return null;
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reads the starred listing through `useStarredThreads` — GET /threads with
|
|
5
5
|
* `starred=true`, served by the `byStarred` index — which returns every starred
|
|
6
|
-
* thread in the config across all non-muted mailboxes, paged.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* neither re-filters nor caps the set,
|
|
10
|
-
* inbox page still appears. Rendered as
|
|
11
|
-
* sections). The shared `MailViewChrome` owns
|
|
12
|
-
* expando; the kit `MessageListPane` (flat, no
|
|
13
|
-
* / empty / error chrome and keyboard hints,
|
|
14
|
-
* `listBody` so the real rows render at every width.
|
|
6
|
+
* thread in the config across all non-muted mailboxes, paged. Every row names
|
|
7
|
+
* its thread, which is the whole address a conversation opens by, so a starred
|
|
8
|
+
* message filed outside the inbox opens like any other. Starredness is decided
|
|
9
|
+
* server-side from `hasStars`; the client neither re-filters nor caps the set,
|
|
10
|
+
* so a starred thread outside the newest inbox page still appears. Rendered as
|
|
11
|
+
* one continuous list (no category sections). The shared `MailViewChrome` owns
|
|
12
|
+
* the `MailHeader` + filter expando; the kit `MessageListPane` (flat, no
|
|
13
|
+
* `briefFilters`) owns the loading / empty / error chrome and keyboard hints,
|
|
14
|
+
* with a consumer-supplied `listBody` so the real rows render at every width.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
flaggedFilterConfig,
|
|
18
18
|
MessageListPane,
|
|
19
|
+
type SearchResult,
|
|
19
20
|
type ThreadRowData,
|
|
20
21
|
type Verb,
|
|
21
22
|
} from "@remit/ui";
|
|
@@ -37,6 +38,7 @@ import { rowToSearchResult } from "@/lib/search-result";
|
|
|
37
38
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
38
39
|
import { dedupeByThread } from "@/lib/starred-rows";
|
|
39
40
|
import { useSelectionWizard } from "@/lib/wizard-history";
|
|
41
|
+
import type { OpenThreadTarget } from "@/routing";
|
|
40
42
|
import { MailViewChrome } from "./MailViewChrome";
|
|
41
43
|
import type { MessageListCommands } from "./MessageList";
|
|
42
44
|
import { MessageRow } from "./MessageRow";
|
|
@@ -101,7 +103,14 @@ function StarredWizardHost({
|
|
|
101
103
|
|
|
102
104
|
interface FlaggedListProps {
|
|
103
105
|
selectedMessageId?: string;
|
|
104
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Opens a row. Every starred row names its thread, so a message filed outside
|
|
108
|
+
* the inbox opens by exactly the same route as one inside it.
|
|
109
|
+
*/
|
|
110
|
+
onOpenThread?: (
|
|
111
|
+
target: OpenThreadTarget,
|
|
112
|
+
options?: OpenMessageOptions,
|
|
113
|
+
) => void;
|
|
105
114
|
/** Where the list publishes the commands the keyboard layer drives. */
|
|
106
115
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
107
116
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
@@ -111,7 +120,7 @@ interface FlaggedListProps {
|
|
|
111
120
|
|
|
112
121
|
export function FlaggedList({
|
|
113
122
|
selectedMessageId,
|
|
114
|
-
|
|
123
|
+
onOpenThread,
|
|
115
124
|
commandsRef,
|
|
116
125
|
onTriageContextChange,
|
|
117
126
|
onDeleteMessages,
|
|
@@ -171,6 +180,27 @@ export function FlaggedList({
|
|
|
171
180
|
);
|
|
172
181
|
}, [threads, selectedCategory, activeFilters, sq, queryTokens]);
|
|
173
182
|
|
|
183
|
+
const openRow = useCallback(
|
|
184
|
+
(id: string, options?: OpenMessageOptions) => {
|
|
185
|
+
const threadId = rows.find((row) => row.id === id)?.threadId;
|
|
186
|
+
if (!threadId) return;
|
|
187
|
+
onOpenThread?.({ threadId, messageId: id }, options);
|
|
188
|
+
},
|
|
189
|
+
[rows, onOpenThread],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// The two-engine results panel, whose semantic hits are in no list at all —
|
|
193
|
+
// each one carries the thread it belongs to.
|
|
194
|
+
const openResult = useCallback(
|
|
195
|
+
(result: SearchResult) => {
|
|
196
|
+
const threadId =
|
|
197
|
+
result.threadId ?? rows.find((row) => row.id === result.id)?.threadId;
|
|
198
|
+
if (!threadId) return;
|
|
199
|
+
onOpenThread?.({ threadId, messageId: result.id });
|
|
200
|
+
},
|
|
201
|
+
[rows, onOpenThread],
|
|
202
|
+
);
|
|
203
|
+
|
|
174
204
|
const preset = useMemo(() => flaggedFilterConfig(), []);
|
|
175
205
|
|
|
176
206
|
const searchResults = useMemo(
|
|
@@ -206,7 +236,7 @@ export function FlaggedList({
|
|
|
206
236
|
key={thread.id}
|
|
207
237
|
thread={thread}
|
|
208
238
|
active={thread.id === selectedMessageId}
|
|
209
|
-
onClick={() =>
|
|
239
|
+
onClick={() => openRow(thread.id)}
|
|
210
240
|
/>
|
|
211
241
|
))}
|
|
212
242
|
</div>
|
|
@@ -236,7 +266,7 @@ export function FlaggedList({
|
|
|
236
266
|
onClearFilters={clearFilters}
|
|
237
267
|
searchResults={searchResults}
|
|
238
268
|
searchLoading={isLoading}
|
|
239
|
-
onSelectSearchResult={
|
|
269
|
+
onSelectSearchResult={openResult}
|
|
240
270
|
// A committed search renders in this view's own rows (`rows` already
|
|
241
271
|
// narrows to the query above), so the multi-select toolbar stays
|
|
242
272
|
// reachable exactly as it does on the mailbox route (#212). The
|
|
@@ -245,7 +275,7 @@ export function FlaggedList({
|
|
|
245
275
|
>
|
|
246
276
|
<ThreadListInteraction
|
|
247
277
|
selectedMessageId={selectedMessageId}
|
|
248
|
-
onOpen={
|
|
278
|
+
onOpen={openRow}
|
|
249
279
|
onDeleteMessages={onDeleteMessages}
|
|
250
280
|
onSelectionVerb={wizard.start}
|
|
251
281
|
wizardOpen={wizard.isOpen}
|
|
@@ -263,7 +293,7 @@ export function FlaggedList({
|
|
|
263
293
|
onRetry={() => refetch()}
|
|
264
294
|
onReportError={handleReportError}
|
|
265
295
|
selectedThreadId={selectedMessageId}
|
|
266
|
-
onSelectThread={
|
|
296
|
+
onSelectThread={openRow}
|
|
267
297
|
isDesktop={isDesktop}
|
|
268
298
|
selectionBar={<ThreadListSelectionBar title="Starred" />}
|
|
269
299
|
listBody={
|
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
* reading / phone slots. The list itself is a FLAT inbox of starred mail (see
|
|
7
7
|
* `FlaggedList`), not the sectioned brief.
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The row prefers the starred listing, the same query that produced the rows,
|
|
10
|
+
* and falls back to resolving the thread on its own when the address names
|
|
11
|
+
* one the listing doesn't hold. The unified listing is INBOX-scoped, so
|
|
12
|
+
* resolving against it left every starred thread filed elsewhere — Sent, an
|
|
13
|
+
* archive folder, anything past the inbox window — visible in the list but
|
|
14
|
+
* impossible to open (issue #70).
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* Usage in the list layout route:
|
|
17
|
+
*
|
|
18
|
+
* <FlaggedPane thread={useOpenThreadPath()}>
|
|
19
|
+
* <MailShell
|
|
16
20
|
* list={<FlaggedPane.List />}
|
|
17
|
-
* reading={<
|
|
21
|
+
* reading={<Outlet />}
|
|
18
22
|
* />
|
|
19
23
|
* </FlaggedPane>
|
|
20
24
|
*
|
|
@@ -40,22 +44,34 @@ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
|
40
44
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
41
45
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
42
46
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
47
|
+
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
43
48
|
import {
|
|
44
49
|
type TriageContext,
|
|
45
50
|
useTriageContext,
|
|
46
51
|
useTriageLayer,
|
|
47
52
|
} from "@/hooks/useTriageLayer";
|
|
53
|
+
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
48
54
|
import { useMailContext } from "@/lib/mail-context";
|
|
55
|
+
import type { OpenThreadPath, OpenThreadTarget } from "@/routing";
|
|
49
56
|
|
|
50
57
|
/* ------------------------------------------------------------------ */
|
|
51
58
|
/* Context */
|
|
52
59
|
/* ------------------------------------------------------------------ */
|
|
53
60
|
|
|
54
61
|
interface FlaggedPaneContextValue {
|
|
62
|
+
/** The row the reader pointed at, which is the one the list highlights. */
|
|
55
63
|
selectedMessageId: string | undefined;
|
|
56
64
|
selectedThread: RemitImapThreadMessageResponse | undefined;
|
|
57
|
-
|
|
65
|
+
/** The conversation the pane shows, or none when the address names no thread. */
|
|
66
|
+
conversation: ConversationTarget | undefined;
|
|
67
|
+
onOpenThread: (
|
|
68
|
+
target: OpenThreadTarget,
|
|
69
|
+
options?: OpenMessageOptions,
|
|
70
|
+
) => void;
|
|
58
71
|
onCloseThread: () => void;
|
|
72
|
+
/** The rows either side of the open one — the phone's swipe gestures. */
|
|
73
|
+
nextThread: OpenThreadTarget | undefined;
|
|
74
|
+
previousThread: OpenThreadTarget | undefined;
|
|
59
75
|
/**
|
|
60
76
|
* Toolbar verbs for the open thread, keyed by the thread's own mailbox and
|
|
61
77
|
* account — Flagged spans accounts, so there is no route mailbox to key by.
|
|
@@ -64,8 +80,6 @@ interface FlaggedPaneContextValue {
|
|
|
64
80
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
65
81
|
triage: TriageContext;
|
|
66
82
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
67
|
-
nextMessageId: string | undefined;
|
|
68
|
-
previousMessageId: string | undefined;
|
|
69
83
|
/**
|
|
70
84
|
* Deselects the open message when it's the one a mutation just removed
|
|
71
85
|
* from view — wired into every mutation that can take the open message out
|
|
@@ -88,39 +102,65 @@ function useFlaggedPane(): FlaggedPaneContextValue {
|
|
|
88
102
|
/* ------------------------------------------------------------------ */
|
|
89
103
|
|
|
90
104
|
interface FlaggedPaneProps {
|
|
91
|
-
|
|
105
|
+
/** The open conversation, as the address states it. */
|
|
106
|
+
thread: OpenThreadPath | undefined;
|
|
92
107
|
children: ReactNode;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
function FlaggedPaneProvider({
|
|
96
|
-
selectedMessageId,
|
|
97
|
-
children,
|
|
98
|
-
}: FlaggedPaneProps) {
|
|
110
|
+
function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
99
111
|
const navigate = useNavigate();
|
|
112
|
+
const { searchInput } = useMailContext();
|
|
113
|
+
const threadId = thread?.threadId;
|
|
114
|
+
const pointedAtMessageId = thread?.messageId;
|
|
100
115
|
|
|
101
116
|
const { threads } = useStarredThreads();
|
|
102
117
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
// The row the starred listing itself holds, preferred because a mutation
|
|
119
|
+
// patches it in place. A thread reached from a cold address is in no listing
|
|
120
|
+
// here and answers for itself — the folder it is filed in is the thread's own
|
|
121
|
+
// data, so Starred spanning folders costs the URL nothing.
|
|
122
|
+
const listedThread = useMemo(() => {
|
|
123
|
+
if (!threadId) return undefined;
|
|
124
|
+
return (
|
|
125
|
+
threads.find((t) => t.messageId === pointedAtMessageId) ??
|
|
126
|
+
threads.find((t) => t.threadId === threadId)
|
|
127
|
+
);
|
|
128
|
+
}, [threads, threadId, pointedAtMessageId]);
|
|
129
|
+
const ownRow = useThreadRow(threadId, pointedAtMessageId);
|
|
130
|
+
const selectedThread = listedThread ?? ownRow;
|
|
131
|
+
|
|
132
|
+
const selectedMessageId = pointedAtMessageId ?? selectedThread?.messageId;
|
|
107
133
|
|
|
108
|
-
const
|
|
109
|
-
(
|
|
134
|
+
const conversation = useMemo<ConversationTarget | undefined>(() => {
|
|
135
|
+
if (!threadId) return undefined;
|
|
136
|
+
return {
|
|
137
|
+
threadId,
|
|
138
|
+
mailboxId: selectedThread?.mailboxId ?? "",
|
|
139
|
+
subject: selectedThread?.subject,
|
|
140
|
+
messageId: selectedMessageId,
|
|
141
|
+
authenticity: selectedThread?.authenticity,
|
|
142
|
+
};
|
|
143
|
+
}, [threadId, selectedThread, selectedMessageId]);
|
|
144
|
+
|
|
145
|
+
const handleOpenThread = useCallback(
|
|
146
|
+
(target: OpenThreadTarget, options?: OpenMessageOptions) => {
|
|
110
147
|
navigate({
|
|
111
|
-
to: "/mail/flagged",
|
|
112
|
-
|
|
148
|
+
to: "/mail/flagged/$threadId/$messageId",
|
|
149
|
+
params: target,
|
|
113
150
|
replace: options?.replace,
|
|
151
|
+
// Commit the active query with the open so the debounced q-mirror —
|
|
152
|
+
// which walks back up to the list when the query goes active — is
|
|
153
|
+
// already satisfied and leaves the conversation alone. The *live*
|
|
154
|
+
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
155
|
+
// the committed query is still empty.
|
|
156
|
+
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
114
157
|
});
|
|
115
158
|
},
|
|
116
|
-
[navigate],
|
|
159
|
+
[navigate, searchInput],
|
|
117
160
|
);
|
|
118
161
|
|
|
119
162
|
const handleCloseThread = useCallback(() => {
|
|
120
|
-
navigate({
|
|
121
|
-
to: "/mail/flagged",
|
|
122
|
-
search: (prev) => ({ ...prev, selectedMessageId: undefined }),
|
|
123
|
-
});
|
|
163
|
+
navigate({ to: "/mail/flagged", search: (prev) => prev });
|
|
124
164
|
}, [navigate]);
|
|
125
165
|
|
|
126
166
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -187,16 +227,29 @@ function FlaggedPaneProvider({
|
|
|
187
227
|
},
|
|
188
228
|
});
|
|
189
229
|
|
|
230
|
+
// The swipe gestures open a whole conversation, so the adjacent row has to
|
|
231
|
+
// name its thread. The starred listing is where that is looked up, so a row
|
|
232
|
+
// it does not hold offers no gesture rather than a tap that goes nowhere.
|
|
233
|
+
const adjacentThread = useCallback(
|
|
234
|
+
(messageId: string | undefined): OpenThreadTarget | undefined => {
|
|
235
|
+
if (!messageId) return undefined;
|
|
236
|
+
const row = threads.find((t) => t.messageId === messageId);
|
|
237
|
+
return row ? { threadId: row.threadId, messageId } : undefined;
|
|
238
|
+
},
|
|
239
|
+
[threads],
|
|
240
|
+
);
|
|
241
|
+
|
|
190
242
|
const ctx: FlaggedPaneContextValue = {
|
|
191
243
|
selectedMessageId,
|
|
192
244
|
selectedThread,
|
|
193
|
-
|
|
245
|
+
conversation,
|
|
246
|
+
onOpenThread: handleOpenThread,
|
|
194
247
|
onCloseThread: handleCloseThread,
|
|
248
|
+
nextThread: adjacentThread(nextMessageId),
|
|
249
|
+
previousThread: adjacentThread(previousMessageId),
|
|
195
250
|
actions,
|
|
196
251
|
triage,
|
|
197
252
|
onDeleteMessages: deleteMessages,
|
|
198
|
-
nextMessageId,
|
|
199
|
-
previousMessageId,
|
|
200
253
|
handleDeselectIfRemoved,
|
|
201
254
|
};
|
|
202
255
|
|
|
@@ -211,12 +264,12 @@ function FlaggedPaneProvider({
|
|
|
211
264
|
|
|
212
265
|
/** Flat starred list. Mount in the `list` slot of `AppShellSlotted`. */
|
|
213
266
|
function FlaggedListSlot() {
|
|
214
|
-
const { selectedMessageId,
|
|
267
|
+
const { selectedMessageId, onOpenThread, triage, onDeleteMessages } =
|
|
215
268
|
useFlaggedPane();
|
|
216
269
|
return (
|
|
217
270
|
<FlaggedList
|
|
218
271
|
selectedMessageId={selectedMessageId}
|
|
219
|
-
|
|
272
|
+
onOpenThread={onOpenThread}
|
|
220
273
|
commandsRef={triage.listCommandsRef}
|
|
221
274
|
onTriageContextChange={triage.onTriageContextChange}
|
|
222
275
|
onDeleteMessages={onDeleteMessages}
|
|
@@ -229,13 +282,13 @@ function FlaggedListSlot() {
|
|
|
229
282
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
230
283
|
*/
|
|
231
284
|
function FlaggedReading() {
|
|
232
|
-
const {
|
|
285
|
+
const { conversation, actions } = useFlaggedPane();
|
|
233
286
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
234
287
|
// The rail's own width gate, not the shell tier: between 1024 and 1280 the
|
|
235
288
|
// reading pane is mounted but the rail is not, so "enabled" would promise an
|
|
236
289
|
// open that cannot happen.
|
|
237
290
|
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
238
|
-
const hasThread = Boolean(
|
|
291
|
+
const hasThread = Boolean(conversation);
|
|
239
292
|
const canToggleIntelligence = railFits && hasThread;
|
|
240
293
|
|
|
241
294
|
return (
|
|
@@ -266,12 +319,13 @@ function FlaggedReading() {
|
|
|
266
319
|
}
|
|
267
320
|
/>
|
|
268
321
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
269
|
-
{
|
|
322
|
+
{conversation ? (
|
|
270
323
|
<ConversationView
|
|
271
|
-
threadId={
|
|
272
|
-
mailboxId={
|
|
273
|
-
subject={
|
|
274
|
-
|
|
324
|
+
threadId={conversation.threadId}
|
|
325
|
+
mailboxId={conversation.mailboxId}
|
|
326
|
+
subject={conversation.subject}
|
|
327
|
+
selectedMessageId={conversation.messageId}
|
|
328
|
+
authenticity={conversation.authenticity}
|
|
275
329
|
composeRequest={actions.composeRequest}
|
|
276
330
|
onComposeClose={actions.clearComposeRequest}
|
|
277
331
|
/>
|
|
@@ -306,31 +360,29 @@ function FlaggedIntelligence() {
|
|
|
306
360
|
function FlaggedPhone() {
|
|
307
361
|
const {
|
|
308
362
|
selectedThread,
|
|
363
|
+
conversation,
|
|
309
364
|
onCloseThread,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
365
|
+
onOpenThread,
|
|
366
|
+
nextThread,
|
|
367
|
+
previousThread,
|
|
313
368
|
handleDeselectIfRemoved,
|
|
314
369
|
} = useFlaggedPane();
|
|
315
370
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
316
371
|
|
|
317
|
-
if (
|
|
372
|
+
if (conversation) {
|
|
318
373
|
return (
|
|
319
374
|
<>
|
|
320
375
|
<ConversationView
|
|
321
|
-
threadId={
|
|
322
|
-
mailboxId={
|
|
323
|
-
subject={
|
|
324
|
-
|
|
376
|
+
threadId={conversation.threadId}
|
|
377
|
+
mailboxId={conversation.mailboxId}
|
|
378
|
+
subject={conversation.subject}
|
|
379
|
+
selectedMessageId={conversation.messageId}
|
|
380
|
+
authenticity={conversation.authenticity}
|
|
325
381
|
onBack={onCloseThread}
|
|
326
382
|
onOpenIntelligence={onToggleIntelligence}
|
|
327
|
-
onSwipeNext={
|
|
328
|
-
nextMessageId ? () => onSelectMessage(nextMessageId) : undefined
|
|
329
|
-
}
|
|
383
|
+
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
330
384
|
onSwipePrevious={
|
|
331
|
-
|
|
332
|
-
? () => onSelectMessage(previousMessageId)
|
|
333
|
-
: undefined
|
|
385
|
+
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
334
386
|
}
|
|
335
387
|
mobileIntelligenceOpen={intelligenceOpen}
|
|
336
388
|
/>
|
|
@@ -258,7 +258,7 @@ export function MailSidebarAdapter({
|
|
|
258
258
|
return (
|
|
259
259
|
<NavLink
|
|
260
260
|
to="/mail/flagged"
|
|
261
|
-
search={{ q: undefined
|
|
261
|
+
search={{ q: undefined }}
|
|
262
262
|
onClick={() => onClick?.()}
|
|
263
263
|
className={className}
|
|
264
264
|
aria-label={ariaLabel}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
6
6
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
-
import { useNavigate
|
|
7
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
8
8
|
import {
|
|
9
9
|
BadgeCheck,
|
|
10
10
|
Check,
|
|
@@ -104,15 +104,7 @@ export const MessageActionMenu = ({
|
|
|
104
104
|
const queryClient = useQueryClient();
|
|
105
105
|
const navigate = useNavigate();
|
|
106
106
|
const { pushError } = useErrorBanners();
|
|
107
|
-
|
|
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 {
|
|
113
|
-
selectedMessageId?: string;
|
|
114
|
-
};
|
|
115
|
-
const selectedMessageId = openThread?.messageId ?? selectedFromQuery;
|
|
107
|
+
const selectedMessageId = useOpenThreadPath()?.messageId;
|
|
116
108
|
|
|
117
109
|
const { mutate: updateFlags, isPending: isUpdatingFlags } = useMutation({
|
|
118
110
|
...messageBulkOperationsUpdateFlagsMutation(),
|
|
@@ -243,7 +243,7 @@ export const MessageList = ({
|
|
|
243
243
|
const { pushError } = useErrorBanners();
|
|
244
244
|
|
|
245
245
|
// Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
|
|
246
|
-
// from the open thread (
|
|
246
|
+
// from the open thread (the message segment in the path). j/k move this
|
|
247
247
|
// cursor; Enter opens the focused row → sets selected, and on desktop the
|
|
248
248
|
// reading pane follows the cursor of its own accord (see the follow-focus
|
|
249
249
|
// wiring below). It seeds from the open thread so opening a message also
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where the search mirror writes, now that
|
|
2
|
+
* Where the search mirror writes, now that a list's open thread is a path
|
|
3
3
|
* segment (#718).
|
|
4
4
|
*
|
|
5
5
|
* Mirroring `q` used to be one navigation with one destination, because the
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* walking up to the list — while every other write has to keep the address it
|
|
9
9
|
* found, or clearing the search would shut the conversation being read.
|
|
10
10
|
*
|
|
11
|
-
* Driven through a real router over
|
|
11
|
+
* Driven through a real router over each list's real route shape, because the
|
|
12
12
|
* whole question is what the router resolves a destination to from a thread
|
|
13
|
-
* route. Reasoning about `to: "."` is not evidence.
|
|
13
|
+
* route under it. Reasoning about `to: "."` is not evidence. The brief and the
|
|
14
|
+
* flagged list share this exact shape — a flat/sectioned list with a
|
|
15
|
+
* `$threadId/$messageId` pair below it — so the two are driven through the
|
|
16
|
+
* same cases rather than one covering for the other.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import assert from "node:assert/strict";
|
|
@@ -45,14 +48,18 @@ afterEach(() => {
|
|
|
45
48
|
const THREAD_ID = "th-1";
|
|
46
49
|
const MESSAGE_ID = "msg-1";
|
|
47
50
|
|
|
48
|
-
const mailContext = (
|
|
51
|
+
const mailContext = (
|
|
52
|
+
input: string,
|
|
53
|
+
committed: string,
|
|
54
|
+
searchViewKey: string,
|
|
55
|
+
): MailContextValue => ({
|
|
49
56
|
accounts: [],
|
|
50
57
|
mailboxNameIndex: new Map(),
|
|
51
58
|
accountNameIndex: new Map(),
|
|
52
59
|
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
53
60
|
searchQuery: committed,
|
|
54
61
|
searchInput: input,
|
|
55
|
-
searchViewKey
|
|
62
|
+
searchViewKey,
|
|
56
63
|
onSearchChange: () => {},
|
|
57
64
|
onSearchClear: () => {},
|
|
58
65
|
onSearchClearQuery: () => {},
|
|
@@ -61,11 +68,15 @@ const mailContext = (input: string, committed: string): MailContextValue => ({
|
|
|
61
68
|
onSetIntelligenceOpen: () => {},
|
|
62
69
|
});
|
|
63
70
|
|
|
71
|
+
type ListPath = "/mail/brief" | "/mail/flagged";
|
|
72
|
+
|
|
64
73
|
/**
|
|
65
|
-
*
|
|
66
|
-
* segments under it, so a write from the list has a matched child to
|
|
74
|
+
* Both lists' shape: the list is a layout route and the thread and message
|
|
75
|
+
* are segments under it, so a write from the list has a matched child to
|
|
76
|
+
* lose.
|
|
67
77
|
*/
|
|
68
|
-
const routerAt = (href: string): AnyRouter => {
|
|
78
|
+
const routerAt = (listPath: ListPath, href: string): AnyRouter => {
|
|
79
|
+
const listSegment = listPath.slice("/mail".length);
|
|
69
80
|
const rootRoute = createRootRoute({ component: Outlet });
|
|
70
81
|
const passthrough = (search: Record<string, unknown>) => search;
|
|
71
82
|
const mailRoute = createRoute({
|
|
@@ -74,17 +85,17 @@ const routerAt = (href: string): AnyRouter => {
|
|
|
74
85
|
validateSearch: passthrough,
|
|
75
86
|
component: Outlet,
|
|
76
87
|
});
|
|
77
|
-
const
|
|
88
|
+
const listRoute = createRoute({
|
|
78
89
|
getParentRoute: () => mailRoute,
|
|
79
|
-
path:
|
|
90
|
+
path: listSegment,
|
|
80
91
|
validateSearch: passthrough,
|
|
81
92
|
component: () => {
|
|
82
|
-
useSearchMirror({ to:
|
|
93
|
+
useSearchMirror({ to: listPath });
|
|
83
94
|
return createElement(Outlet);
|
|
84
95
|
},
|
|
85
96
|
});
|
|
86
97
|
const threadRoute = createRoute({
|
|
87
|
-
getParentRoute: () =>
|
|
98
|
+
getParentRoute: () => listRoute,
|
|
88
99
|
path: "/$threadId",
|
|
89
100
|
component: Outlet,
|
|
90
101
|
});
|
|
@@ -95,7 +106,7 @@ const routerAt = (href: string): AnyRouter => {
|
|
|
95
106
|
});
|
|
96
107
|
const routeTree = rootRoute.addChildren([
|
|
97
108
|
mailRoute.addChildren([
|
|
98
|
-
|
|
109
|
+
listRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
99
110
|
]),
|
|
100
111
|
]);
|
|
101
112
|
return createRouter({
|
|
@@ -108,6 +119,7 @@ const mount = async (
|
|
|
108
119
|
router: AnyRouter,
|
|
109
120
|
input: string,
|
|
110
121
|
committed: string,
|
|
122
|
+
searchViewKey: string,
|
|
111
123
|
): Promise<DomHarness> => {
|
|
112
124
|
const created = createDomHarness();
|
|
113
125
|
harness = created;
|
|
@@ -115,7 +127,7 @@ const mount = async (
|
|
|
115
127
|
created.renderApp(
|
|
116
128
|
createElement(
|
|
117
129
|
MailContext.Provider,
|
|
118
|
-
{ value: mailContext(input, committed) },
|
|
130
|
+
{ value: mailContext(input, committed, searchViewKey) },
|
|
119
131
|
createElement(RouterProvider, { router }),
|
|
120
132
|
),
|
|
121
133
|
);
|
|
@@ -124,58 +136,62 @@ const mount = async (
|
|
|
124
136
|
return created;
|
|
125
137
|
};
|
|
126
138
|
|
|
127
|
-
const
|
|
139
|
+
const LIST_PATHS: ListPath[] = ["/mail/brief", "/mail/flagged"];
|
|
128
140
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const router = routerAt(threadHref);
|
|
132
|
-
await mount(router, "invoice", "invoice");
|
|
141
|
+
for (const listPath of LIST_PATHS) {
|
|
142
|
+
const threadHref = `${listPath}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
133
143
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
describe(`mirroring a query that goes active (${listPath})`, () => {
|
|
145
|
+
it("walks up to the list, so no thread stays matched behind the results", async () => {
|
|
146
|
+
const router = routerAt(listPath, threadHref);
|
|
147
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
137
148
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
assert.equal(router.history.location.pathname, listPath);
|
|
150
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
151
|
+
});
|
|
141
152
|
|
|
142
|
-
|
|
143
|
-
|
|
153
|
+
it("closes the pane from a bare thread address too", async () => {
|
|
154
|
+
const router = routerAt(listPath, `${listPath}/${THREAD_ID}`);
|
|
155
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
156
|
+
|
|
157
|
+
assert.equal(router.history.location.pathname, listPath);
|
|
158
|
+
});
|
|
144
159
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
it("replaces rather than pushes, so Back is not a search step", async () => {
|
|
161
|
+
const router = routerAt(listPath, threadHref);
|
|
162
|
+
const before = router.history.length;
|
|
163
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
149
164
|
|
|
150
|
-
|
|
165
|
+
assert.equal(router.history.length, before);
|
|
166
|
+
});
|
|
151
167
|
});
|
|
152
|
-
});
|
|
153
168
|
|
|
154
|
-
describe(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
describe(`mirroring a cleared query (${listPath})`, () => {
|
|
170
|
+
it("keeps the conversation open — dropping the search is not closing it", async () => {
|
|
171
|
+
const router = routerAt(listPath, `${threadHref}?q=invoice`);
|
|
172
|
+
await mount(router, "", "", listPath);
|
|
158
173
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
174
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
175
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
176
|
+
});
|
|
162
177
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
178
|
+
it("leaves the address alone when there is nothing to write", async () => {
|
|
179
|
+
const router = routerAt(listPath, `${threadHref}?q=invoice`);
|
|
180
|
+
await mount(router, "invoice", "invoice", listPath);
|
|
166
181
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
183
|
+
assert.match(router.history.location.search, /q=invoice/);
|
|
184
|
+
});
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
it("waits for the debounce rather than writing the previous query", async () => {
|
|
187
|
+
const router = routerAt(listPath, threadHref);
|
|
188
|
+
await mount(router, "invo", "", listPath);
|
|
174
189
|
|
|
175
|
-
|
|
176
|
-
|
|
190
|
+
assert.equal(router.history.location.pathname, threadHref);
|
|
191
|
+
assert.equal(router.history.location.search.includes("q="), false);
|
|
192
|
+
});
|
|
177
193
|
});
|
|
178
|
-
}
|
|
194
|
+
}
|
|
179
195
|
|
|
180
196
|
/**
|
|
181
197
|
* The other half of why a typed query survives an opened thread: the field
|
|
@@ -185,9 +201,12 @@ describe("mirroring a cleared query", () => {
|
|
|
185
201
|
* Read off the matches the router resolved for a real address, so the ids under
|
|
186
202
|
* test are the ones path segments produce rather than ids written out here.
|
|
187
203
|
*/
|
|
188
|
-
describe("the view key of an open
|
|
189
|
-
const viewKeyAt = async (
|
|
190
|
-
|
|
204
|
+
describe("the view key of an open thread", () => {
|
|
205
|
+
const viewKeyAt = async (
|
|
206
|
+
listPath: ListPath,
|
|
207
|
+
href: string,
|
|
208
|
+
): Promise<string> => {
|
|
209
|
+
const router = routerAt(listPath, href);
|
|
191
210
|
await router.load();
|
|
192
211
|
return mailViewKey(
|
|
193
212
|
router.state.matches.map((match: { routeId: string }) => ({
|
|
@@ -196,10 +215,15 @@ describe("the view key of an open brief thread", () => {
|
|
|
196
215
|
);
|
|
197
216
|
};
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
for (const listPath of LIST_PATHS) {
|
|
219
|
+
it(`is the list's own, thread and message segments alike (${listPath})`, async () => {
|
|
220
|
+
const list = await viewKeyAt(listPath, listPath);
|
|
221
|
+
assert.notEqual(list, "", "the list resolves to a view key at all");
|
|
222
|
+
assert.equal(await viewKeyAt(listPath, `${listPath}/${THREAD_ID}`), list);
|
|
223
|
+
assert.equal(
|
|
224
|
+
await viewKeyAt(listPath, `${listPath}/${THREAD_ID}/${MESSAGE_ID}`),
|
|
225
|
+
list,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
205
229
|
});
|
|
@@ -31,10 +31,9 @@ export type SearchMirrorTarget =
|
|
|
31
31
|
* When a query *goes* active it also closes the reading pane (#539): an open
|
|
32
32
|
* message from the pre-search list is not meaningful in the search result set.
|
|
33
33
|
* The close is a navigation to the list route, which unmatches the thread that
|
|
34
|
-
* was open under it
|
|
35
|
-
* the
|
|
36
|
-
*
|
|
37
|
-
* one, must not shut the conversation they are reading.
|
|
34
|
+
* was open under it. Any other write keeps the address it found: mirroring a
|
|
35
|
+
* query the reader is editing, or clearing one, must not shut the conversation
|
|
36
|
+
* they are reading.
|
|
38
37
|
*
|
|
39
38
|
* Only on that transition, though — tapping a search result commits the same `q`
|
|
40
39
|
* with the open thread, so when the URL already says the query the conversation
|
|
@@ -71,13 +70,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
71
70
|
const search = (prev: Record<string, unknown>) => ({
|
|
72
71
|
...prev,
|
|
73
72
|
q: committedQuery || undefined,
|
|
74
|
-
...(queryGoesActive
|
|
75
|
-
? {
|
|
76
|
-
selectedMessageId: undefined,
|
|
77
|
-
selectedThreadId: undefined,
|
|
78
|
-
selectedMailboxId: undefined,
|
|
79
|
-
}
|
|
80
|
-
: {}),
|
|
81
73
|
});
|
|
82
74
|
if (!queryGoesActive) {
|
|
83
75
|
navigate({ to: ".", search, replace: true });
|
package/src/lib/mail-search.ts
CHANGED
|
@@ -16,13 +16,12 @@ const listSearch = z.object({ q: z.string().optional() });
|
|
|
16
16
|
* The brief's open thread and the message inside it are path segments
|
|
17
17
|
* (`/mail/brief/<thread>/<message>`), so the query carries nothing but the
|
|
18
18
|
* search. An old link's selection params are dropped here, which is what
|
|
19
|
-
* "tolerated and ignored" means
|
|
19
|
+
* "tolerated and ignored" means.
|
|
20
20
|
*/
|
|
21
21
|
export const briefSearchSchema = listSearch.extend({});
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
23
|
+
/** The same, for `/mail/flagged/<thread>/<message>`. */
|
|
24
|
+
export const flaggedSearchSchema = listSearch.extend({});
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
* The outbox's open message is a path segment
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -32,11 +32,14 @@ import { Route as MailMailboxIdThreadIdRouteImport } from './routes/mail/$mailbo
|
|
|
32
32
|
import { Route as MailBriefIndexRouteImport } from './routes/mail/brief/index'
|
|
33
33
|
import { Route as MailBriefThreadIdRouteImport } from './routes/mail/brief/$threadId'
|
|
34
34
|
import { Route as MailFlaggedIndexRouteImport } from './routes/mail/flagged/index'
|
|
35
|
+
import { Route as MailFlaggedThreadIdRouteImport } from './routes/mail/flagged/$threadId'
|
|
35
36
|
import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
|
|
36
37
|
import { Route as MailMailboxIdThreadIdIndexRouteImport } from './routes/mail/$mailboxId/$threadId/index'
|
|
37
38
|
import { Route as MailMailboxIdThreadIdMessageIdRouteImport } from './routes/mail/$mailboxId/$threadId/$messageId'
|
|
38
39
|
import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
|
|
39
40
|
import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
|
|
41
|
+
import { Route as MailFlaggedThreadIdIndexRouteImport } from './routes/mail/flagged/$threadId/index'
|
|
42
|
+
import { Route as MailFlaggedThreadIdMessageIdRouteImport } from './routes/mail/flagged/$threadId/$messageId'
|
|
40
43
|
import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
|
|
41
44
|
|
|
42
45
|
const IndexRoute = IndexRouteImport.update({
|
|
@@ -154,6 +157,11 @@ const MailFlaggedIndexRoute = MailFlaggedIndexRouteImport.update({
|
|
|
154
157
|
path: '/',
|
|
155
158
|
getParentRoute: () => MailFlaggedRoute,
|
|
156
159
|
} as any)
|
|
160
|
+
const MailFlaggedThreadIdRoute = MailFlaggedThreadIdRouteImport.update({
|
|
161
|
+
id: '/$threadId',
|
|
162
|
+
path: '/$threadId',
|
|
163
|
+
getParentRoute: () => MailFlaggedRoute,
|
|
164
|
+
} as any)
|
|
157
165
|
const MailOutboxIndexRoute = MailOutboxIndexRouteImport.update({
|
|
158
166
|
id: '/',
|
|
159
167
|
path: '/',
|
|
@@ -182,6 +190,18 @@ const MailBriefThreadIdMessageIdRoute =
|
|
|
182
190
|
path: '/$messageId',
|
|
183
191
|
getParentRoute: () => MailBriefThreadIdRoute,
|
|
184
192
|
} as any)
|
|
193
|
+
const MailFlaggedThreadIdIndexRoute =
|
|
194
|
+
MailFlaggedThreadIdIndexRouteImport.update({
|
|
195
|
+
id: '/',
|
|
196
|
+
path: '/',
|
|
197
|
+
getParentRoute: () => MailFlaggedThreadIdRoute,
|
|
198
|
+
} as any)
|
|
199
|
+
const MailFlaggedThreadIdMessageIdRoute =
|
|
200
|
+
MailFlaggedThreadIdMessageIdRouteImport.update({
|
|
201
|
+
id: '/$messageId',
|
|
202
|
+
path: '/$messageId',
|
|
203
|
+
getParentRoute: () => MailFlaggedThreadIdRoute,
|
|
204
|
+
} as any)
|
|
185
205
|
const MailOutboxDraftOutboxMessageIdRoute =
|
|
186
206
|
MailOutboxDraftOutboxMessageIdRouteImport.update({
|
|
187
207
|
id: '/draft/$outboxMessageId',
|
|
@@ -210,15 +230,18 @@ export interface FileRoutesByFullPath {
|
|
|
210
230
|
'/settings/': typeof SettingsIndexRoute
|
|
211
231
|
'/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
|
|
212
232
|
'/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
|
|
233
|
+
'/mail/flagged/$threadId': typeof MailFlaggedThreadIdRouteWithChildren
|
|
213
234
|
'/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
|
|
214
235
|
'/mail/brief/': typeof MailBriefIndexRoute
|
|
215
236
|
'/mail/flagged/': typeof MailFlaggedIndexRoute
|
|
216
237
|
'/mail/outbox/': typeof MailOutboxIndexRoute
|
|
217
238
|
'/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
|
|
218
239
|
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
240
|
+
'/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
|
|
219
241
|
'/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
|
|
220
242
|
'/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
|
|
221
243
|
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
244
|
+
'/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
|
|
222
245
|
}
|
|
223
246
|
export interface FileRoutesByTo {
|
|
224
247
|
'/': typeof IndexRoute
|
|
@@ -239,9 +262,11 @@ export interface FileRoutesByTo {
|
|
|
239
262
|
'/mail/outbox': typeof MailOutboxIndexRoute
|
|
240
263
|
'/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
|
|
241
264
|
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
265
|
+
'/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
|
|
242
266
|
'/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
|
|
243
267
|
'/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdIndexRoute
|
|
244
268
|
'/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
|
|
269
|
+
'/mail/flagged/$threadId': typeof MailFlaggedThreadIdIndexRoute
|
|
245
270
|
}
|
|
246
271
|
export interface FileRoutesById {
|
|
247
272
|
__root__: typeof rootRouteImport
|
|
@@ -265,15 +290,18 @@ export interface FileRoutesById {
|
|
|
265
290
|
'/settings/': typeof SettingsIndexRoute
|
|
266
291
|
'/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdRouteWithChildren
|
|
267
292
|
'/mail/brief/$threadId': typeof MailBriefThreadIdRouteWithChildren
|
|
293
|
+
'/mail/flagged/$threadId': typeof MailFlaggedThreadIdRouteWithChildren
|
|
268
294
|
'/mail/$mailboxId/': typeof MailMailboxIdIndexRoute
|
|
269
295
|
'/mail/brief/': typeof MailBriefIndexRoute
|
|
270
296
|
'/mail/flagged/': typeof MailFlaggedIndexRoute
|
|
271
297
|
'/mail/outbox/': typeof MailOutboxIndexRoute
|
|
272
298
|
'/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
|
|
273
299
|
'/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
|
|
300
|
+
'/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
|
|
274
301
|
'/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
|
|
275
302
|
'/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
|
|
276
303
|
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
304
|
+
'/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
|
|
277
305
|
}
|
|
278
306
|
export interface FileRouteTypes {
|
|
279
307
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
@@ -298,15 +326,18 @@ export interface FileRouteTypes {
|
|
|
298
326
|
| '/settings/'
|
|
299
327
|
| '/mail/$mailboxId/$threadId'
|
|
300
328
|
| '/mail/brief/$threadId'
|
|
329
|
+
| '/mail/flagged/$threadId'
|
|
301
330
|
| '/mail/$mailboxId/'
|
|
302
331
|
| '/mail/brief/'
|
|
303
332
|
| '/mail/flagged/'
|
|
304
333
|
| '/mail/outbox/'
|
|
305
334
|
| '/mail/$mailboxId/$threadId/$messageId'
|
|
306
335
|
| '/mail/brief/$threadId/$messageId'
|
|
336
|
+
| '/mail/flagged/$threadId/$messageId'
|
|
307
337
|
| '/mail/outbox/draft/$outboxMessageId'
|
|
308
338
|
| '/mail/$mailboxId/$threadId/'
|
|
309
339
|
| '/mail/brief/$threadId/'
|
|
340
|
+
| '/mail/flagged/$threadId/'
|
|
310
341
|
fileRoutesByTo: FileRoutesByTo
|
|
311
342
|
to:
|
|
312
343
|
| '/'
|
|
@@ -327,9 +358,11 @@ export interface FileRouteTypes {
|
|
|
327
358
|
| '/mail/outbox'
|
|
328
359
|
| '/mail/$mailboxId/$threadId/$messageId'
|
|
329
360
|
| '/mail/brief/$threadId/$messageId'
|
|
361
|
+
| '/mail/flagged/$threadId/$messageId'
|
|
330
362
|
| '/mail/outbox/draft/$outboxMessageId'
|
|
331
363
|
| '/mail/$mailboxId/$threadId'
|
|
332
364
|
| '/mail/brief/$threadId'
|
|
365
|
+
| '/mail/flagged/$threadId'
|
|
333
366
|
id:
|
|
334
367
|
| '__root__'
|
|
335
368
|
| '/'
|
|
@@ -352,15 +385,18 @@ export interface FileRouteTypes {
|
|
|
352
385
|
| '/settings/'
|
|
353
386
|
| '/mail/$mailboxId/$threadId'
|
|
354
387
|
| '/mail/brief/$threadId'
|
|
388
|
+
| '/mail/flagged/$threadId'
|
|
355
389
|
| '/mail/$mailboxId/'
|
|
356
390
|
| '/mail/brief/'
|
|
357
391
|
| '/mail/flagged/'
|
|
358
392
|
| '/mail/outbox/'
|
|
359
393
|
| '/mail/$mailboxId/$threadId/$messageId'
|
|
360
394
|
| '/mail/brief/$threadId/$messageId'
|
|
395
|
+
| '/mail/flagged/$threadId/$messageId'
|
|
361
396
|
| '/mail/outbox/draft/$outboxMessageId'
|
|
362
397
|
| '/mail/$mailboxId/$threadId/'
|
|
363
398
|
| '/mail/brief/$threadId/'
|
|
399
|
+
| '/mail/flagged/$threadId/'
|
|
364
400
|
fileRoutesById: FileRoutesById
|
|
365
401
|
}
|
|
366
402
|
export interface RootRouteChildren {
|
|
@@ -533,6 +569,13 @@ declare module '@tanstack/react-router' {
|
|
|
533
569
|
preLoaderRoute: typeof MailFlaggedIndexRouteImport
|
|
534
570
|
parentRoute: typeof MailFlaggedRoute
|
|
535
571
|
}
|
|
572
|
+
'/mail/flagged/$threadId': {
|
|
573
|
+
id: '/mail/flagged/$threadId'
|
|
574
|
+
path: '/$threadId'
|
|
575
|
+
fullPath: '/mail/flagged/$threadId'
|
|
576
|
+
preLoaderRoute: typeof MailFlaggedThreadIdRouteImport
|
|
577
|
+
parentRoute: typeof MailFlaggedRoute
|
|
578
|
+
}
|
|
536
579
|
'/mail/outbox/': {
|
|
537
580
|
id: '/mail/outbox/'
|
|
538
581
|
path: '/'
|
|
@@ -568,6 +611,20 @@ declare module '@tanstack/react-router' {
|
|
|
568
611
|
preLoaderRoute: typeof MailBriefThreadIdMessageIdRouteImport
|
|
569
612
|
parentRoute: typeof MailBriefThreadIdRoute
|
|
570
613
|
}
|
|
614
|
+
'/mail/flagged/$threadId/': {
|
|
615
|
+
id: '/mail/flagged/$threadId/'
|
|
616
|
+
path: '/'
|
|
617
|
+
fullPath: '/mail/flagged/$threadId/'
|
|
618
|
+
preLoaderRoute: typeof MailFlaggedThreadIdIndexRouteImport
|
|
619
|
+
parentRoute: typeof MailFlaggedThreadIdRoute
|
|
620
|
+
}
|
|
621
|
+
'/mail/flagged/$threadId/$messageId': {
|
|
622
|
+
id: '/mail/flagged/$threadId/$messageId'
|
|
623
|
+
path: '/$messageId'
|
|
624
|
+
fullPath: '/mail/flagged/$threadId/$messageId'
|
|
625
|
+
preLoaderRoute: typeof MailFlaggedThreadIdMessageIdRouteImport
|
|
626
|
+
parentRoute: typeof MailFlaggedThreadIdRoute
|
|
627
|
+
}
|
|
571
628
|
'/mail/outbox/draft/$outboxMessageId': {
|
|
572
629
|
id: '/mail/outbox/draft/$outboxMessageId'
|
|
573
630
|
path: '/draft/$outboxMessageId'
|
|
@@ -634,11 +691,26 @@ const MailBriefRouteWithChildren = MailBriefRoute._addFileChildren(
|
|
|
634
691
|
MailBriefRouteChildren,
|
|
635
692
|
)
|
|
636
693
|
|
|
694
|
+
interface MailFlaggedThreadIdRouteChildren {
|
|
695
|
+
MailFlaggedThreadIdMessageIdRoute: typeof MailFlaggedThreadIdMessageIdRoute
|
|
696
|
+
MailFlaggedThreadIdIndexRoute: typeof MailFlaggedThreadIdIndexRoute
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const MailFlaggedThreadIdRouteChildren: MailFlaggedThreadIdRouteChildren = {
|
|
700
|
+
MailFlaggedThreadIdMessageIdRoute: MailFlaggedThreadIdMessageIdRoute,
|
|
701
|
+
MailFlaggedThreadIdIndexRoute: MailFlaggedThreadIdIndexRoute,
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const MailFlaggedThreadIdRouteWithChildren =
|
|
705
|
+
MailFlaggedThreadIdRoute._addFileChildren(MailFlaggedThreadIdRouteChildren)
|
|
706
|
+
|
|
637
707
|
interface MailFlaggedRouteChildren {
|
|
708
|
+
MailFlaggedThreadIdRoute: typeof MailFlaggedThreadIdRouteWithChildren
|
|
638
709
|
MailFlaggedIndexRoute: typeof MailFlaggedIndexRoute
|
|
639
710
|
}
|
|
640
711
|
|
|
641
712
|
const MailFlaggedRouteChildren: MailFlaggedRouteChildren = {
|
|
713
|
+
MailFlaggedThreadIdRoute: MailFlaggedThreadIdRouteWithChildren,
|
|
642
714
|
MailFlaggedIndexRoute: MailFlaggedIndexRoute,
|
|
643
715
|
}
|
|
644
716
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mail/flagged/$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 { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
12
|
+
|
|
13
|
+
function FlaggedMessagePane() {
|
|
14
|
+
return <FlaggedPane.Reading />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const Route = createFileRoute("/mail/flagged/$threadId/$messageId")({
|
|
18
|
+
component: FlaggedMessagePane,
|
|
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 { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
7
|
+
|
|
8
|
+
function FlaggedThreadPane() {
|
|
9
|
+
return <FlaggedPane.Reading />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Route = createFileRoute("/mail/flagged/$threadId/")({
|
|
13
|
+
component: FlaggedThreadPane,
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mail/flagged/$threadId — a conversation open in the flagged list's reading
|
|
3
|
+
* pane.
|
|
4
|
+
*
|
|
5
|
+
* Starred mail spans accounts and folders, and the thread is still the whole
|
|
6
|
+
* address: the folder its mail is filed in comes from the thread's own data,
|
|
7
|
+
* since `GET /threads/{threadId}/messages` answers with rows that each name
|
|
8
|
+
* their mailbox.
|
|
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/flagged/$threadId")({
|
|
15
|
+
component: Outlet,
|
|
16
|
+
});
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* /mail/flagged — the Flagged virtual mailbox, one of the four list layouts.
|
|
3
3
|
*
|
|
4
4
|
* A flat starred list across accounts; same slots as the brief, intelligence
|
|
5
|
-
* rail included.
|
|
5
|
+
* rail included. The reading pane is the `Outlet`, so what it shows is whatever
|
|
6
|
+
* route matched below this one, and the open thread and the message inside it
|
|
7
|
+
* are the segments there rather than a selection read out of the query.
|
|
6
8
|
*/
|
|
7
9
|
import {
|
|
8
10
|
createFileRoute,
|
|
@@ -14,6 +16,7 @@ import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
|
14
16
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
15
17
|
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
16
18
|
import { flaggedSearchSchema } from "@/lib/mail-search";
|
|
19
|
+
import { useOpenThreadPath } from "@/routing";
|
|
17
20
|
|
|
18
21
|
const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
|
|
19
22
|
<div className="flex h-full items-center justify-center bg-canvas p-4">
|
|
@@ -26,17 +29,17 @@ const FlaggedError = ({ error, reset }: ErrorComponentProps) => (
|
|
|
26
29
|
);
|
|
27
30
|
|
|
28
31
|
function FlaggedLayout() {
|
|
29
|
-
const
|
|
32
|
+
const thread = useOpenThreadPath();
|
|
30
33
|
useSearchMirror({ to: "/mail/flagged" });
|
|
31
34
|
|
|
32
35
|
return (
|
|
33
|
-
<FlaggedPane
|
|
36
|
+
<FlaggedPane thread={thread}>
|
|
34
37
|
<MailShell
|
|
35
38
|
phone={<FlaggedPane.Phone />}
|
|
36
39
|
list={<FlaggedPane.List />}
|
|
37
40
|
reading={<Outlet />}
|
|
38
41
|
intelligence={<FlaggedPane.Intelligence />}
|
|
39
|
-
hasThread={Boolean(
|
|
42
|
+
hasThread={Boolean(thread)}
|
|
40
43
|
/>
|
|
41
44
|
</FlaggedPane>
|
|
42
45
|
);
|
|
@@ -39,6 +39,14 @@ export function useOpenThreadPath(): OpenThreadPath | undefined {
|
|
|
39
39
|
from: "/mail/brief/$threadId/$messageId",
|
|
40
40
|
shouldThrow: false,
|
|
41
41
|
});
|
|
42
|
+
const flaggedThread = useParams({
|
|
43
|
+
from: "/mail/flagged/$threadId",
|
|
44
|
+
shouldThrow: false,
|
|
45
|
+
});
|
|
46
|
+
const flaggedMessage = useParams({
|
|
47
|
+
from: "/mail/flagged/$threadId/$messageId",
|
|
48
|
+
shouldThrow: false,
|
|
49
|
+
});
|
|
42
50
|
const mailboxThread = useParams({
|
|
43
51
|
from: "/mail/$mailboxId/$threadId",
|
|
44
52
|
shouldThrow: false,
|
|
@@ -48,8 +56,12 @@ export function useOpenThreadPath(): OpenThreadPath | undefined {
|
|
|
48
56
|
shouldThrow: false,
|
|
49
57
|
});
|
|
50
58
|
|
|
51
|
-
const threadId =
|
|
52
|
-
|
|
59
|
+
const threadId =
|
|
60
|
+
briefThread?.threadId ?? flaggedThread?.threadId ?? mailboxThread?.threadId;
|
|
61
|
+
const messageId =
|
|
62
|
+
briefMessage?.messageId ??
|
|
63
|
+
flaggedMessage?.messageId ??
|
|
64
|
+
mailboxMessage?.messageId;
|
|
53
65
|
|
|
54
66
|
return useMemo(
|
|
55
67
|
() => (threadId ? { threadId, messageId } : undefined),
|