@remit/web-client 0.0.143 → 0.0.144
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/compose/ComposeProvider.tsx +79 -3
- package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
- package/src/components/layout/ComposeFab.tsx +11 -36
- package/src/components/layout/MailTopBar.tsx +6 -3
- package/src/components/mail/DraftsView.tsx +0 -11
- package/src/components/mail/MailboxPane.tsx +10 -0
- package/src/hooks/useComposeTargetMailbox.ts +75 -0
- package/src/lib/compose-routes.ts +5 -5
- package/src/routes/mail.tsx +19 -6
- package/src/hooks/useComposeTarget.ts +0 -92
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.144",
|
|
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": {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
configOperationsGetConfigOptions,
|
|
2
3
|
outboxDetailOperationsGetOutboxMessageOptions,
|
|
3
4
|
outboxOperationsListOutboxMessagesQueryKey,
|
|
4
5
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
@@ -7,6 +8,7 @@ import type {
|
|
|
7
8
|
RemitImapDescribeMessageResponse,
|
|
8
9
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
10
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
10
12
|
import {
|
|
11
13
|
createContext,
|
|
12
14
|
useCallback,
|
|
@@ -16,6 +18,9 @@ import {
|
|
|
16
18
|
useRef,
|
|
17
19
|
useState,
|
|
18
20
|
} from "react";
|
|
21
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
22
|
+
import { useComposeTarget } from "@/hooks/useComposeTargetMailbox";
|
|
23
|
+
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
19
24
|
export type ComposeMode = "reply" | "reply_all" | "forward" | "new";
|
|
20
25
|
|
|
21
26
|
export interface ComposeState {
|
|
@@ -59,6 +64,19 @@ export const ComposeProvider = ({
|
|
|
59
64
|
>();
|
|
60
65
|
const startedAtRef = useRef(0);
|
|
61
66
|
const queryClient = useQueryClient();
|
|
67
|
+
const navigate = useNavigate();
|
|
68
|
+
const location = useLocation();
|
|
69
|
+
const { pushError } = useErrorBanners();
|
|
70
|
+
// Compose is a mail action, so its target only has to be known under `/mail`.
|
|
71
|
+
// Resolving it from the root otherwise costs `/settings` and `/onboarding` a
|
|
72
|
+
// config fetch plus a folder list per account for a button they never show.
|
|
73
|
+
const underMail = location.pathname.startsWith("/mail");
|
|
74
|
+
const { data: config } = useQuery({
|
|
75
|
+
...configOperationsGetConfigOptions(),
|
|
76
|
+
staleTime: Infinity,
|
|
77
|
+
enabled: underMail,
|
|
78
|
+
});
|
|
79
|
+
const target = useComposeTarget(underMail ? (config?.accounts ?? []) : []);
|
|
62
80
|
|
|
63
81
|
const { data: polledMessage } = useQuery({
|
|
64
82
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -91,14 +109,72 @@ export const ComposeProvider = ({
|
|
|
91
109
|
}
|
|
92
110
|
}, [polledMessage, pollingMessageId, queryClient]);
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
// Opening compose also puts the surface on screen: only a mailbox route
|
|
113
|
+
// mounts `FullCompose`, and only with no thread in the pane it takes over.
|
|
114
|
+
const openCompose = useCallback(
|
|
115
|
+
(params: Omit<ComposeState, "isOpen">) => {
|
|
116
|
+
const search = location.search as Record<string, unknown>;
|
|
117
|
+
const showsThread = Boolean(
|
|
118
|
+
search.selectedMessageId ?? search.selectedThreadId,
|
|
119
|
+
);
|
|
120
|
+
if (!hostsComposeSurface(location.pathname)) {
|
|
121
|
+
if (target.status === "loading") {
|
|
122
|
+
pushError({
|
|
123
|
+
severity: "info",
|
|
124
|
+
title: "Not ready to write yet",
|
|
125
|
+
detail:
|
|
126
|
+
"Your folders are still loading. Try Compose again in a moment.",
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (target.status === "none") {
|
|
131
|
+
pushError({
|
|
132
|
+
title: "Nowhere to write from",
|
|
133
|
+
detail:
|
|
134
|
+
"No account has a folder to open the message in. Check the account's folders in Settings, then try again.",
|
|
135
|
+
});
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setState({ ...params, isOpen: true });
|
|
139
|
+
navigate({
|
|
140
|
+
to: "/mail/$mailboxId",
|
|
141
|
+
params: { mailboxId: target.mailboxId },
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setState({ ...params, isOpen: true });
|
|
146
|
+
if (!showsThread) return;
|
|
147
|
+
// A push, so Back reopens the message.
|
|
148
|
+
navigate({
|
|
149
|
+
to: ".",
|
|
150
|
+
search: (prev: Record<string, unknown>) => ({
|
|
151
|
+
...prev,
|
|
152
|
+
selectedMessageId: undefined,
|
|
153
|
+
selectedThreadId: undefined,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
[navigate, location.pathname, location.search, target, pushError],
|
|
158
|
+
);
|
|
97
159
|
|
|
98
160
|
const closeCompose = useCallback(() => {
|
|
99
161
|
setState(INITIAL_STATE);
|
|
100
162
|
}, []);
|
|
101
163
|
|
|
164
|
+
// Leaving the routes that mount the surface closes it, so nothing is left
|
|
165
|
+
// open behind a view that cannot show it — which is how it used to reappear
|
|
166
|
+
// unannounced on the next mailbox. Only a *change* of route counts: opening
|
|
167
|
+
// compose navigates, and on the frame before that navigation lands the
|
|
168
|
+
// pathname is still the one compose was started from.
|
|
169
|
+
const previousPathnameRef = useRef(location.pathname);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const previous = previousPathnameRef.current;
|
|
172
|
+
previousPathnameRef.current = location.pathname;
|
|
173
|
+
if (location.pathname === previous) return;
|
|
174
|
+
if (hostsComposeSurface(location.pathname)) return;
|
|
175
|
+
setState((current) => (current.isOpen ? INITIAL_STATE : current));
|
|
176
|
+
}, [location.pathname]);
|
|
177
|
+
|
|
102
178
|
const setOutboxMessageId = useCallback((id: string) => {
|
|
103
179
|
setState((prev) => ({ ...prev, outboxMessageId: id }));
|
|
104
180
|
}, []);
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #703: compose state opened with nothing mounting the surface, so the
|
|
3
|
+
* button looked dead and the window turned up on the next navigation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { afterEach, describe, it } from "node:test";
|
|
8
|
+
import {
|
|
9
|
+
type AnyRouter,
|
|
10
|
+
createMemoryHistory,
|
|
11
|
+
createRootRoute,
|
|
12
|
+
createRoute,
|
|
13
|
+
createRouter,
|
|
14
|
+
Outlet,
|
|
15
|
+
RouterProvider,
|
|
16
|
+
} from "@tanstack/react-router";
|
|
17
|
+
import { createElement, useEffect, useState } from "react";
|
|
18
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
19
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
20
|
+
import { ComposeProvider, useCompose } from "./ComposeProvider";
|
|
21
|
+
|
|
22
|
+
let harness: DomHarness | undefined;
|
|
23
|
+
let http: HttpMock | undefined;
|
|
24
|
+
let releaseMailboxes: (() => void) | undefined;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
releaseMailboxes?.();
|
|
28
|
+
releaseMailboxes = undefined;
|
|
29
|
+
harness?.close();
|
|
30
|
+
harness = undefined;
|
|
31
|
+
http?.restore();
|
|
32
|
+
http = undefined;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
36
|
+
// `window`.
|
|
37
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
38
|
+
|
|
39
|
+
const ACCOUNT_ID = "acc-1";
|
|
40
|
+
const INBOX_ID = "mbx-inbox";
|
|
41
|
+
|
|
42
|
+
const ComposeProbe = () => {
|
|
43
|
+
const { state, openCompose } = useCompose();
|
|
44
|
+
return createElement(
|
|
45
|
+
"button",
|
|
46
|
+
{
|
|
47
|
+
type: "button",
|
|
48
|
+
"data-open": String(state.isOpen),
|
|
49
|
+
onClick: () => openCompose({ mode: "new" }),
|
|
50
|
+
},
|
|
51
|
+
"Compose",
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// The provider sits at the root the way `__root.tsx` mounts it, so navigation
|
|
56
|
+
// reaches it the way it does in the app.
|
|
57
|
+
const RootLayout = () =>
|
|
58
|
+
createElement(
|
|
59
|
+
ComposeProvider,
|
|
60
|
+
null,
|
|
61
|
+
createElement(ComposeProbe),
|
|
62
|
+
createElement(Outlet),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
66
|
+
const rootRoute = createRootRoute({ component: layout });
|
|
67
|
+
const routeTree = rootRoute.addChildren([
|
|
68
|
+
createRoute({
|
|
69
|
+
getParentRoute: () => rootRoute,
|
|
70
|
+
path: "/mail/outbox",
|
|
71
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
72
|
+
component: () => null,
|
|
73
|
+
}),
|
|
74
|
+
createRoute({
|
|
75
|
+
getParentRoute: () => rootRoute,
|
|
76
|
+
path: "/mail/$mailboxId",
|
|
77
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
78
|
+
component: () => null,
|
|
79
|
+
}),
|
|
80
|
+
createRoute({
|
|
81
|
+
getParentRoute: () => rootRoute,
|
|
82
|
+
path: "/settings",
|
|
83
|
+
component: () => null,
|
|
84
|
+
}),
|
|
85
|
+
]);
|
|
86
|
+
return createRouter({
|
|
87
|
+
routeTree,
|
|
88
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
89
|
+
}) as unknown as AnyRouter;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface MountOptions {
|
|
93
|
+
/** Hold the folder list open, so no target has resolved at press time. */
|
|
94
|
+
holdMailboxes?: boolean;
|
|
95
|
+
/** Answer with an account that has no folders at all. */
|
|
96
|
+
noMailboxes?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mount = async (
|
|
100
|
+
router: AnyRouter,
|
|
101
|
+
options: MountOptions = {},
|
|
102
|
+
): Promise<DomHarness> => {
|
|
103
|
+
const held = options.holdMailboxes
|
|
104
|
+
? new Promise<void>((resolve) => {
|
|
105
|
+
releaseMailboxes = resolve;
|
|
106
|
+
})
|
|
107
|
+
: undefined;
|
|
108
|
+
|
|
109
|
+
http = mockFetch(async (call) => {
|
|
110
|
+
if (call.path.endsWith("/config")) {
|
|
111
|
+
return {
|
|
112
|
+
accounts: [
|
|
113
|
+
{
|
|
114
|
+
accountId: ACCOUNT_ID,
|
|
115
|
+
email: "me@example.com",
|
|
116
|
+
folderAppointments: [{ role: "Inbox", mailboxId: INBOX_ID }],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (call.path.endsWith("/mailboxes")) {
|
|
122
|
+
if (held) await held;
|
|
123
|
+
if (options.noMailboxes) return { items: [] };
|
|
124
|
+
return { items: [{ mailboxId: INBOX_ID, fullPath: "INBOX" }] };
|
|
125
|
+
}
|
|
126
|
+
return {};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const created = createDomHarness();
|
|
130
|
+
harness = created;
|
|
131
|
+
// Resolve the first match before mounting: `RouterProvider` renders its
|
|
132
|
+
// pending state until the router has loaded, and nothing here waits for it.
|
|
133
|
+
await router.load();
|
|
134
|
+
created.renderApp(createElement(RouterProvider, { router }));
|
|
135
|
+
await created.flush();
|
|
136
|
+
await created.wait(20);
|
|
137
|
+
return created;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const press = async (mounted: DomHarness): Promise<HTMLElement> => {
|
|
141
|
+
const button = mounted.byText("button", "Compose");
|
|
142
|
+
mounted.click(button);
|
|
143
|
+
await mounted.flush();
|
|
144
|
+
await mounted.wait(20);
|
|
145
|
+
return button;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
describe("opening compose over an open message (#703)", () => {
|
|
149
|
+
it("drops the selected message so the pane can render the surface", async () => {
|
|
150
|
+
const router = routerAt(
|
|
151
|
+
`/mail/${INBOX_ID}?selectedMessageId=msg-1&selectedThreadId=th-1`,
|
|
152
|
+
);
|
|
153
|
+
const mounted = await mount(router);
|
|
154
|
+
|
|
155
|
+
const button = mounted.byText("button", "Compose");
|
|
156
|
+
assert.equal(button.getAttribute("data-open"), "false");
|
|
157
|
+
|
|
158
|
+
await press(mounted);
|
|
159
|
+
|
|
160
|
+
assert.equal(button.getAttribute("data-open"), "true");
|
|
161
|
+
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
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps the rest of the query, so the search the user typed survives", async () => {
|
|
168
|
+
const router = routerAt(
|
|
169
|
+
`/mail/${INBOX_ID}?q=invoice&selectedMessageId=msg-1`,
|
|
170
|
+
);
|
|
171
|
+
const mounted = await mount(router);
|
|
172
|
+
|
|
173
|
+
await press(mounted);
|
|
174
|
+
|
|
175
|
+
const search = router.history.location.search;
|
|
176
|
+
assert.equal(search.includes("selectedMessageId"), false);
|
|
177
|
+
assert.match(search, /q=invoice/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("leaves the message one Back away rather than erasing it", async () => {
|
|
181
|
+
const router = routerAt(`/mail/${INBOX_ID}?selectedMessageId=msg-1`);
|
|
182
|
+
const mounted = await mount(router);
|
|
183
|
+
|
|
184
|
+
await press(mounted);
|
|
185
|
+
router.history.back();
|
|
186
|
+
await mounted.flush();
|
|
187
|
+
|
|
188
|
+
assert.match(router.history.location.search, /selectedMessageId=msg-1/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("adds no history entry when the pane had nothing open", async () => {
|
|
192
|
+
const router = routerAt(`/mail/${INBOX_ID}`);
|
|
193
|
+
const mounted = await mount(router);
|
|
194
|
+
const entries = router.history.length;
|
|
195
|
+
|
|
196
|
+
await press(mounted);
|
|
197
|
+
|
|
198
|
+
assert.equal(router.history.length, entries);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("carries a compose started off the outbox to a route that mounts it", async () => {
|
|
202
|
+
const router = routerAt("/mail/outbox");
|
|
203
|
+
const mounted = await mount(router);
|
|
204
|
+
|
|
205
|
+
const button = await press(mounted);
|
|
206
|
+
|
|
207
|
+
assert.equal(button.getAttribute("data-open"), "true");
|
|
208
|
+
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Walking off the routes that mount the surface closes it. That rule reads the
|
|
213
|
+
// live location, which this harness does not advance — `RouterProvider` here
|
|
214
|
+
// serves its first match and stays there — so it is pinned in the e2e suite
|
|
215
|
+
// (`compose-over-open-message.spec.ts`) instead. What is checked here is the
|
|
216
|
+
// half that would break it: opening compose navigates, and the surface has to
|
|
217
|
+
// survive its own navigation.
|
|
218
|
+
describe("compose survives the navigation that opens it (#703)", () => {
|
|
219
|
+
it("stays open when the press carried the user to another route", async () => {
|
|
220
|
+
const router = routerAt("/mail/outbox");
|
|
221
|
+
const mounted = await mount(router);
|
|
222
|
+
|
|
223
|
+
const button = await press(mounted);
|
|
224
|
+
|
|
225
|
+
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
226
|
+
assert.equal(button.getAttribute("data-open"), "true");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("the open call is stable", () => {
|
|
231
|
+
// A fresh `openCompose` on every render is a loop, not a nuisance: callers
|
|
232
|
+
// hold it in dependency arrays, and one of them opens compose from an effect.
|
|
233
|
+
it("hands back the same function across a re-render", async () => {
|
|
234
|
+
const identities = new Set<unknown>();
|
|
235
|
+
const CountingProbe = () => {
|
|
236
|
+
const { openCompose } = useCompose();
|
|
237
|
+
const [bumped, setBumped] = useState(0);
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
identities.add(openCompose);
|
|
240
|
+
}, [openCompose]);
|
|
241
|
+
return createElement(
|
|
242
|
+
"button",
|
|
243
|
+
{ type: "button", onClick: () => setBumped(bumped + 1) },
|
|
244
|
+
`Bump ${bumped}`,
|
|
245
|
+
);
|
|
246
|
+
};
|
|
247
|
+
const CountingLayout = () =>
|
|
248
|
+
createElement(
|
|
249
|
+
ComposeProvider,
|
|
250
|
+
null,
|
|
251
|
+
createElement(CountingProbe),
|
|
252
|
+
createElement(Outlet),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const mounted = await mount(routerAt(`/mail/${INBOX_ID}`, CountingLayout));
|
|
256
|
+
// The target legitimately settles as config and the folder list land. What
|
|
257
|
+
// must not happen is another identity after that, on a render that has
|
|
258
|
+
// nothing to do with compose.
|
|
259
|
+
const settled = identities.size;
|
|
260
|
+
|
|
261
|
+
mounted.click(mounted.byText("button", "Bump"));
|
|
262
|
+
await mounted.flush();
|
|
263
|
+
await mounted.wait(20);
|
|
264
|
+
|
|
265
|
+
assert.equal(mounted.byText("button", "Bump").textContent, "Bump 1");
|
|
266
|
+
assert.equal(identities.size, settled);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("a compose with nowhere to land says so", () => {
|
|
271
|
+
it("opens nothing and reports it while the folder list is in flight", async () => {
|
|
272
|
+
const router = routerAt("/mail/outbox");
|
|
273
|
+
const mounted = await mount(router, { holdMailboxes: true });
|
|
274
|
+
|
|
275
|
+
const button = await press(mounted);
|
|
276
|
+
|
|
277
|
+
assert.equal(button.getAttribute("data-open"), "false");
|
|
278
|
+
assert.equal(router.history.location.pathname, "/mail/outbox");
|
|
279
|
+
assert.match(mounted.text(), /Not ready to write yet/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("names the fix when no account has a folder", async () => {
|
|
283
|
+
const router = routerAt("/mail/outbox");
|
|
284
|
+
const mounted = await mount(router, { noMailboxes: true });
|
|
285
|
+
|
|
286
|
+
const button = await press(mounted);
|
|
287
|
+
|
|
288
|
+
assert.equal(button.getAttribute("data-open"), "false");
|
|
289
|
+
assert.match(mounted.text(), /Nowhere to write from/);
|
|
290
|
+
assert.match(mounted.text(), /Settings/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("asks the API for nothing off the mail routes", async () => {
|
|
294
|
+
const router = routerAt("/settings");
|
|
295
|
+
await mount(router);
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(
|
|
298
|
+
(http?.calls ?? []).map((call) => call.path),
|
|
299
|
+
[],
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -29,6 +29,14 @@ import type {
|
|
|
29
29
|
RemitImapAccountResponse,
|
|
30
30
|
RemitImapDescribeMessageResponse,
|
|
31
31
|
} from "@remit/api-http-client/types.gen.ts";
|
|
32
|
+
import {
|
|
33
|
+
type AnyRouter,
|
|
34
|
+
createMemoryHistory,
|
|
35
|
+
createRootRoute,
|
|
36
|
+
createRoute,
|
|
37
|
+
createRouter,
|
|
38
|
+
RouterContextProvider,
|
|
39
|
+
} from "@tanstack/react-router";
|
|
32
40
|
import { createElement, useEffect } from "react";
|
|
33
41
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
34
42
|
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
@@ -128,6 +136,23 @@ interface MountOptions {
|
|
|
128
136
|
failPatch?: boolean;
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
// Opening compose closes whatever the reading pane had open, so the provider
|
|
140
|
+
// navigates (#703) and needs a router under it.
|
|
141
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
142
|
+
|
|
143
|
+
const rootRoute = createRootRoute();
|
|
144
|
+
const mailboxRoute = createRoute({
|
|
145
|
+
getParentRoute: () => rootRoute,
|
|
146
|
+
path: "/mail/$mailboxId",
|
|
147
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const testRouter = (): AnyRouter =>
|
|
151
|
+
createRouter({
|
|
152
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
153
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
154
|
+
}) as unknown as AnyRouter;
|
|
155
|
+
|
|
131
156
|
const mount = async (
|
|
132
157
|
options: MountOptions = {},
|
|
133
158
|
): Promise<{ releasePatch: () => void }> => {
|
|
@@ -167,11 +192,15 @@ const mount = async (
|
|
|
167
192
|
|
|
168
193
|
harness = createDomHarness();
|
|
169
194
|
harness.renderApp(
|
|
170
|
-
createElement(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
createElement(
|
|
174
|
-
|
|
195
|
+
createElement(RouterContextProvider, {
|
|
196
|
+
router: testRouter(),
|
|
197
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
198
|
+
children: createElement(
|
|
199
|
+
ComposeProvider,
|
|
200
|
+
null,
|
|
201
|
+
createElement(Opened, { outboxMessageId: options.outboxMessageId }),
|
|
202
|
+
),
|
|
203
|
+
}),
|
|
175
204
|
);
|
|
176
205
|
await harness.flush();
|
|
177
206
|
await harness.wait(50);
|
|
@@ -1,21 +1,7 @@
|
|
|
1
|
-
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
1
|
import { useLocation } from "@tanstack/react-router";
|
|
3
2
|
import { Pencil } from "lucide-react";
|
|
3
|
+
import { useCallback } from "react";
|
|
4
4
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
|
-
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Primary mobile surfaces where the FAB belongs: anywhere under
|
|
9
|
-
* `/mail` or under `/settings`. The bare `/` route (sign-in / OAuth
|
|
10
|
-
* landing) is intentionally excluded — compose has no useful target
|
|
11
|
-
* before the user has an account.
|
|
12
|
-
*/
|
|
13
|
-
const isOnPrimaryMobileRoute = (pathname: string): boolean =>
|
|
14
|
-
pathname.startsWith("/mail") || pathname.startsWith("/settings");
|
|
15
|
-
|
|
16
|
-
interface ComposeFabProps {
|
|
17
|
-
accounts: RemitImapAccountResponse[];
|
|
18
|
-
}
|
|
19
5
|
|
|
20
6
|
/**
|
|
21
7
|
* Floating Action Button for composing a new message. Mobile-only.
|
|
@@ -26,32 +12,21 @@ interface ComposeFabProps {
|
|
|
26
12
|
* `/mail` shell also stops mounting the FAB above that width; the
|
|
27
13
|
* `lg:hidden` class covers the pre-hydration frame.
|
|
28
14
|
* - The compose surface is already open.
|
|
29
|
-
* - The user is reading a thread (`?selectedMessageId=…`) — the
|
|
30
|
-
* conversation
|
|
31
|
-
* - The user is
|
|
32
|
-
* `/settings`).
|
|
33
|
-
*
|
|
34
|
-
* The tap itself is `useGlobalCompose`, shared with the desktop top bar:
|
|
35
|
-
* it opens compose in place on routes that mount `FullCompose` and
|
|
36
|
-
* otherwise carries the user to a real mailbox that does. Compose state
|
|
37
|
-
* survives that transition because `ComposeProvider` lives in
|
|
38
|
-
* `__root.tsx`.
|
|
15
|
+
* - The user is reading a thread (`?selectedMessageId=…`) — the single
|
|
16
|
+
* pane is the conversation, and its reply bar is under this corner.
|
|
17
|
+
* - The user is off `/mail`, which is every route with no mail in it.
|
|
39
18
|
*/
|
|
40
|
-
export const ComposeFab = (
|
|
41
|
-
const { state } = useCompose();
|
|
19
|
+
export const ComposeFab = () => {
|
|
20
|
+
const { state, openCompose } = useCompose();
|
|
42
21
|
const location = useLocation();
|
|
43
|
-
const compose =
|
|
22
|
+
const compose = useCallback(() => {
|
|
23
|
+
openCompose({ mode: "new" });
|
|
24
|
+
}, [openCompose]);
|
|
44
25
|
|
|
45
26
|
const search = location.search as Record<string, unknown> | undefined;
|
|
46
|
-
const isReadingThread =
|
|
47
|
-
typeof search?.selectedMessageId === "string" &&
|
|
48
|
-
search.selectedMessageId.length > 0;
|
|
27
|
+
const isReadingThread = Boolean(search?.selectedMessageId);
|
|
49
28
|
|
|
50
|
-
if (
|
|
51
|
-
!isOnPrimaryMobileRoute(location.pathname) ||
|
|
52
|
-
state.isOpen ||
|
|
53
|
-
isReadingThread
|
|
54
|
-
)
|
|
29
|
+
if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
|
|
55
30
|
return null;
|
|
56
31
|
|
|
57
32
|
return (
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
10
|
import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
|
|
11
11
|
import { useNavigate } from "@tanstack/react-router";
|
|
12
|
-
import { useMemo } from "react";
|
|
12
|
+
import { useCallback, useMemo } from "react";
|
|
13
13
|
import { AccountMenu } from "@/auth/AccountMenu";
|
|
14
|
-
import {
|
|
14
|
+
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
15
15
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
16
16
|
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
17
17
|
import { openBugReport } from "@/lib/bug-report";
|
|
@@ -26,7 +26,10 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
26
26
|
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
27
27
|
useMailContext();
|
|
28
28
|
const navigate = useNavigate();
|
|
29
|
-
const
|
|
29
|
+
const { openCompose } = useCompose();
|
|
30
|
+
const compose = useCallback(() => {
|
|
31
|
+
openCompose({ mode: "new" });
|
|
32
|
+
}, [openCompose]);
|
|
30
33
|
const { scope, clearScope } = useSearchScope(accounts);
|
|
31
34
|
const chips =
|
|
32
35
|
scope.kind === "scoped"
|
|
@@ -185,17 +185,6 @@ export function DraftsView({
|
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
const handleRemitDraftOpen = (outboxMessageId: string) => {
|
|
188
|
-
// Clear any open IMAP draft first. The route's detailPane only renders
|
|
189
|
-
// FullCompose when `composeState.isOpen && !selectedThread`; if an IMAP
|
|
190
|
-
// draft is open (selectedMessageId set) the reading pane would keep
|
|
191
|
-
// showing ConversationView and compose would never surface (#505).
|
|
192
|
-
if (selectedMessageId) {
|
|
193
|
-
navigate({
|
|
194
|
-
to: "/mail/$mailboxId",
|
|
195
|
-
params: { mailboxId },
|
|
196
|
-
search: { selectedMessageId: undefined },
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
188
|
openCompose({ mode: "new", outboxMessageId });
|
|
200
189
|
};
|
|
201
190
|
|
|
@@ -598,6 +598,16 @@ function MailboxPaneProvider({
|
|
|
598
598
|
openCompose({ mode: "new" });
|
|
599
599
|
}, [openCompose]);
|
|
600
600
|
|
|
601
|
+
// A thread opening closes compose. Only a selection arriving counts, so this
|
|
602
|
+
// cannot close the compose that just cleared one.
|
|
603
|
+
const previousSelectionRef = useRef(selectedMessageId);
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
const previous = previousSelectionRef.current;
|
|
606
|
+
previousSelectionRef.current = selectedMessageId;
|
|
607
|
+
if (!selectedMessageId || selectedMessageId === previous) return;
|
|
608
|
+
closeCompose();
|
|
609
|
+
}, [selectedMessageId, closeCompose]);
|
|
610
|
+
|
|
601
611
|
const deleteOutboxMutation = useMutation({
|
|
602
612
|
...outboxDetailOperationsDeleteOutboxMessageMutation(),
|
|
603
613
|
onError: (mutationError) => {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a compose started off a mailbox route has to land.
|
|
3
|
+
*
|
|
4
|
+
* `FullCompose` is mounted by the mailbox route only, so compose started from
|
|
5
|
+
* the daily brief, flagged or the outbox has to carry the user to a mailbox
|
|
6
|
+
* first. The target is the first account's inbox, falling back to its first
|
|
7
|
+
* mailbox.
|
|
8
|
+
*/
|
|
9
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { useQueries } from "@tanstack/react-query";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { buildMailboxRoleMap } from "@/lib/folder-roles";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The mailbox to land on, or why there is none: a folder list still in flight
|
|
17
|
+
* is a different answer to the user than an account with no folders at all.
|
|
18
|
+
*/
|
|
19
|
+
export type ComposeTarget =
|
|
20
|
+
| { status: "ready"; mailboxId: string }
|
|
21
|
+
| { status: "loading" }
|
|
22
|
+
| { status: "none" };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Accounts resolve in order and an account whose mailbox query is still in
|
|
26
|
+
* flight blocks rather than being skipped: skipping hands back a later
|
|
27
|
+
* account's inbox and then silently swaps the target once the earlier query
|
|
28
|
+
* settles.
|
|
29
|
+
*
|
|
30
|
+
* The answer is memoised on the two values it is made of. A fresh object every
|
|
31
|
+
* render would rebuild `openCompose` on every render of the provider, and a
|
|
32
|
+
* caller with it in a dependency array then never settles.
|
|
33
|
+
*/
|
|
34
|
+
export function useComposeTarget(
|
|
35
|
+
accounts: RemitImapAccountResponse[],
|
|
36
|
+
): ComposeTarget {
|
|
37
|
+
const mailboxQueries = useQueries({
|
|
38
|
+
queries: accounts.map((account) => ({
|
|
39
|
+
...mailboxOperationsListMailboxesOptions({
|
|
40
|
+
path: { accountId: account.accountId },
|
|
41
|
+
}),
|
|
42
|
+
staleTime: Infinity,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let status: ComposeTarget["status"] = "none";
|
|
47
|
+
let readyMailboxId: string | undefined;
|
|
48
|
+
for (const [index, account] of accounts.entries()) {
|
|
49
|
+
const query = mailboxQueries[index];
|
|
50
|
+
if (!query || query.isPending) {
|
|
51
|
+
status = "loading";
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
const mailboxes = query.data?.items ?? [];
|
|
55
|
+
if (mailboxes.length === 0) continue;
|
|
56
|
+
const roleMap = buildMailboxRoleMap(account.folderAppointments);
|
|
57
|
+
const inbox = mailboxes.find(
|
|
58
|
+
(mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
|
|
59
|
+
);
|
|
60
|
+
const mailboxId = (inbox ?? mailboxes[0])?.mailboxId;
|
|
61
|
+
if (mailboxId) {
|
|
62
|
+
status = "ready";
|
|
63
|
+
readyMailboxId = mailboxId;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return useMemo(
|
|
69
|
+
() =>
|
|
70
|
+
status === "ready" && readyMailboxId
|
|
71
|
+
? { status: "ready", mailboxId: readyMailboxId }
|
|
72
|
+
: { status: status === "ready" ? "none" : status },
|
|
73
|
+
[status, readyMailboxId],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Which routes mount the compose surface.
|
|
3
3
|
*
|
|
4
4
|
* `FullCompose` is mounted by the mailbox route only, so compose started from
|
|
5
|
-
* anywhere else has to carry the user to a mailbox first
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `/mail/flagged` and on the brief.
|
|
5
|
+
* anywhere else has to carry the user to a mailbox first — and compose left
|
|
6
|
+
* open when the user walks off those routes has to close. `ComposeProvider`
|
|
7
|
+
* decides both from here, and the mail layout binds `c` off the same answer.
|
|
8
|
+
* A plain function with no React or API dependencies: a divergent second copy
|
|
9
|
+
* is what left the mobile FAB dead on `/mail/flagged` and on the brief.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** `/mail/<segment>` values that name a view rather than a mailbox. */
|
package/src/routes/mail.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
configOperationsGetConfigOptions,
|
|
3
3
|
unifiedThreadOperationsListAllThreadsOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
-
import { AppShellSlotted } from "@remit/ui";
|
|
5
|
+
import { AppShellSlotted, useTriageKeyboard } from "@remit/ui";
|
|
6
6
|
import { useQuery } from "@tanstack/react-query";
|
|
7
7
|
import {
|
|
8
8
|
createFileRoute,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@tanstack/react-router";
|
|
14
14
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
+
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
16
17
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
17
18
|
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
19
|
import { MailTopBar } from "@/components/layout/MailTopBar";
|
|
@@ -29,6 +30,7 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
|
29
30
|
import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
30
31
|
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
31
32
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
33
|
+
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
32
34
|
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
33
35
|
import { MailContext } from "@/lib/mail-context";
|
|
34
36
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
@@ -132,6 +134,7 @@ function MailLayout() {
|
|
|
132
134
|
// the render before committing and nothing is painted with the stale query.
|
|
133
135
|
// An effect would commit one frame carrying the previous view's text, which
|
|
134
136
|
// the mirror below then has to be defended against.
|
|
137
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
135
138
|
const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
|
|
136
139
|
const [searchViewKey, setSearchViewKey] = useState(viewKey);
|
|
137
140
|
if (searchViewKey !== viewKey) {
|
|
@@ -215,6 +218,19 @@ function MailLayout() {
|
|
|
215
218
|
],
|
|
216
219
|
});
|
|
217
220
|
|
|
221
|
+
// `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
|
|
222
|
+
// mailbox the pane's own triage layer owns the key, so this is disabled there
|
|
223
|
+
// rather than firing a second compose alongside it.
|
|
224
|
+
const { openCompose } = useCompose();
|
|
225
|
+
const composeHandlers = useMemo(
|
|
226
|
+
() => ({ compose: () => openCompose({ mode: "new" }) }),
|
|
227
|
+
[openCompose],
|
|
228
|
+
);
|
|
229
|
+
useTriageKeyboard({
|
|
230
|
+
enabled: !hostsComposeSurface(pathname),
|
|
231
|
+
handlers: composeHandlers,
|
|
232
|
+
});
|
|
233
|
+
|
|
218
234
|
const handleSearchChange = useCallback((query: string) => {
|
|
219
235
|
setSearchInput(query);
|
|
220
236
|
}, []);
|
|
@@ -325,11 +341,8 @@ function MailLayout() {
|
|
|
325
341
|
<MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
|
|
326
342
|
);
|
|
327
343
|
// Single-pane only, where the FAB is the compose entry point. Above it the
|
|
328
|
-
// top bar owns compose
|
|
329
|
-
|
|
330
|
-
const overlayContent = isSinglePane ? (
|
|
331
|
-
<ComposeFab accounts={accounts} />
|
|
332
|
-
) : undefined;
|
|
344
|
+
// top bar owns compose.
|
|
345
|
+
const overlayContent = isSinglePane ? <ComposeFab /> : undefined;
|
|
333
346
|
// Desktop only. Below 1024px the single pane keeps its own header search and
|
|
334
347
|
// the phone takeover; there is no room for a bar spanning panes that do not
|
|
335
348
|
// exist side by side.
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a global compose has to land.
|
|
3
|
-
*
|
|
4
|
-
* The compose surface (`FullCompose`) is mounted by the mailbox route only, so
|
|
5
|
-
* compose started from anywhere else — the daily brief, flagged, outbox — has
|
|
6
|
-
* to carry the user to a mailbox first. The target is the first account's
|
|
7
|
-
* inbox, falling back to its first mailbox.
|
|
8
|
-
*
|
|
9
|
-
* Both compose entry points use this: the top bar's button on desktop and the
|
|
10
|
-
* mobile `ComposeFab`. One resolver, so the two surfaces cannot disagree about
|
|
11
|
-
* which routes host the compose surface.
|
|
12
|
-
*/
|
|
13
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
-
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
-
import { useQueries } from "@tanstack/react-query";
|
|
16
|
-
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
17
|
-
import { startTransition, useCallback } from "react";
|
|
18
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
19
|
-
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
20
|
-
import { buildMailboxRoleMap } from "@/lib/folder-roles";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The mailbox id a global compose should navigate to, or undefined if none is
|
|
24
|
-
* known yet.
|
|
25
|
-
*
|
|
26
|
-
* Accounts resolve in order and an account whose mailbox query is still in
|
|
27
|
-
* flight blocks rather than being skipped: skipping hands back a later
|
|
28
|
-
* account's inbox and then silently swaps the target once the earlier query
|
|
29
|
-
* settles.
|
|
30
|
-
*/
|
|
31
|
-
export function useComposeTargetMailboxId(
|
|
32
|
-
accounts: RemitImapAccountResponse[],
|
|
33
|
-
): string | undefined {
|
|
34
|
-
const mailboxQueries = useQueries({
|
|
35
|
-
queries: accounts.map((account) => ({
|
|
36
|
-
...mailboxOperationsListMailboxesOptions({
|
|
37
|
-
path: { accountId: account.accountId },
|
|
38
|
-
}),
|
|
39
|
-
staleTime: Infinity,
|
|
40
|
-
})),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
for (const [index, account] of accounts.entries()) {
|
|
44
|
-
const query = mailboxQueries[index];
|
|
45
|
-
if (!query || query.isPending) return undefined;
|
|
46
|
-
const mailboxes = query.data?.items ?? [];
|
|
47
|
-
if (mailboxes.length === 0) continue;
|
|
48
|
-
const roleMap = buildMailboxRoleMap(account.folderAppointments);
|
|
49
|
-
const inbox = mailboxes.find(
|
|
50
|
-
(mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
|
|
51
|
-
);
|
|
52
|
-
return (inbox ?? mailboxes[0])?.mailboxId;
|
|
53
|
-
}
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* A compose action that works from every view: opens compose in place when the
|
|
59
|
-
* current route already hosts the surface, otherwise navigates to the target
|
|
60
|
-
* mailbox — compose state lives in `ComposeProvider` (mounted at the root), so
|
|
61
|
-
* it survives the transition and the destination mounts straight into it.
|
|
62
|
-
*
|
|
63
|
-
* With no target resolved yet — a cold load whose mailbox queries have not
|
|
64
|
-
* settled, or accounts with no mailboxes — the action does nothing. Opening
|
|
65
|
-
* compose state first would leave it open with nothing rendering it, and it
|
|
66
|
-
* would then pop up unprompted on the next navigation.
|
|
67
|
-
*/
|
|
68
|
-
export function useGlobalCompose(
|
|
69
|
-
accounts: RemitImapAccountResponse[],
|
|
70
|
-
): () => void {
|
|
71
|
-
const { openCompose } = useCompose();
|
|
72
|
-
const navigate = useNavigate();
|
|
73
|
-
const location = useLocation();
|
|
74
|
-
const targetMailboxId = useComposeTargetMailboxId(accounts);
|
|
75
|
-
|
|
76
|
-
return useCallback(() => {
|
|
77
|
-
if (hostsComposeSurface(location.pathname)) {
|
|
78
|
-
startTransition(() => {
|
|
79
|
-
openCompose({ mode: "new" });
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (!targetMailboxId) return;
|
|
84
|
-
startTransition(() => {
|
|
85
|
-
openCompose({ mode: "new" });
|
|
86
|
-
});
|
|
87
|
-
navigate({
|
|
88
|
-
to: "/mail/$mailboxId",
|
|
89
|
-
params: { mailboxId: targetMailboxId },
|
|
90
|
-
});
|
|
91
|
-
}, [openCompose, navigate, location.pathname, targetMailboxId]);
|
|
92
|
-
}
|