@remit/web-client 0.0.166 → 0.0.167

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +77 -25
  3. package/src/components/compose/ComposeProvider.tsx +11 -122
  4. package/src/components/compose/FullCompose.tsx +29 -18
  5. package/src/components/compose/InlineCompose.tsx +21 -11
  6. package/src/components/compose/MobileComposeSheet.tsx +14 -18
  7. package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
  8. package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
  9. package/src/components/compose/compose-title.ts +10 -0
  10. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
  11. package/src/components/layout/ComposeFab.tsx +6 -15
  12. package/src/components/layout/MailShell.tsx +9 -1
  13. package/src/components/layout/MailTopBar.tsx +3 -6
  14. package/src/components/mail/DraftsView.tsx +12 -14
  15. package/src/components/mail/MailboxPane.tsx +27 -117
  16. package/src/components/mail/OutboxPane.tsx +3 -9
  17. package/src/hooks/useSaveDraft.ts +11 -0
  18. package/src/hooks/useSearchMirror.ts +12 -1
  19. package/src/lib/mail-route.test.ts +35 -1
  20. package/src/lib/mail-route.ts +2 -1
  21. package/src/routeTree.gen.ts +92 -0
  22. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  23. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  24. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  25. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  26. package/src/routes/mail.tsx +12 -8
  27. package/src/routing/compose-press-opens.render.test.ts +179 -0
  28. package/src/routing/compose.ts +220 -0
  29. package/src/routing/index.ts +8 -0
  30. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  31. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  32. package/src/lib/compose-routes.test.ts +0 -46
  33. package/src/lib/compose-routes.ts +0 -25
@@ -1,312 +0,0 @@
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
- /**
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
- */
70
- const routerAt = (href: string, layout = RootLayout): AnyRouter => {
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
- });
88
- const routeTree = rootRoute.addChildren([
89
- createRoute({
90
- getParentRoute: () => rootRoute,
91
- path: "/mail/outbox",
92
- validateSearch: (search: Record<string, unknown>) => search,
93
- component: () => null,
94
- }),
95
- mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
96
- createRoute({
97
- getParentRoute: () => rootRoute,
98
- path: "/settings",
99
- component: () => null,
100
- }),
101
- ]);
102
- return createRouter({
103
- routeTree,
104
- history: createMemoryHistory({ initialEntries: [href] }),
105
- }) as unknown as AnyRouter;
106
- };
107
-
108
- interface MountOptions {
109
- /** Hold the folder list open, so no target has resolved at press time. */
110
- holdMailboxes?: boolean;
111
- /** Answer with an account that has no folders at all. */
112
- noMailboxes?: boolean;
113
- }
114
-
115
- const mount = async (
116
- router: AnyRouter,
117
- options: MountOptions = {},
118
- ): Promise<DomHarness> => {
119
- const held = options.holdMailboxes
120
- ? new Promise<void>((resolve) => {
121
- releaseMailboxes = resolve;
122
- })
123
- : undefined;
124
-
125
- http = mockFetch(async (call) => {
126
- if (call.path.endsWith("/config")) {
127
- return {
128
- accounts: [
129
- {
130
- accountId: ACCOUNT_ID,
131
- email: "me@example.com",
132
- folderAppointments: [{ role: "Inbox", mailboxId: INBOX_ID }],
133
- },
134
- ],
135
- };
136
- }
137
- if (call.path.endsWith("/mailboxes")) {
138
- if (held) await held;
139
- if (options.noMailboxes) return { items: [] };
140
- return { items: [{ mailboxId: INBOX_ID, fullPath: "INBOX" }] };
141
- }
142
- return {};
143
- });
144
-
145
- const created = createDomHarness();
146
- harness = created;
147
- // Resolve the first match before mounting: `RouterProvider` renders its
148
- // pending state until the router has loaded, and nothing here waits for it.
149
- await router.load();
150
- created.renderApp(createElement(RouterProvider, { router }));
151
- await created.flush();
152
- await created.wait(20);
153
- return created;
154
- };
155
-
156
- const press = async (mounted: DomHarness): Promise<HTMLElement> => {
157
- const button = mounted.byText("button", "Compose");
158
- mounted.click(button);
159
- await mounted.flush();
160
- await mounted.wait(20);
161
- return button;
162
- };
163
-
164
- const THREAD_HREF = `/mail/${INBOX_ID}/th-1/msg-1`;
165
-
166
- describe("opening compose over an open message (#703)", () => {
167
- it("walks up to the list so the pane can render the surface", async () => {
168
- const router = routerAt(THREAD_HREF);
169
- const mounted = await mount(router);
170
-
171
- const button = mounted.byText("button", "Compose");
172
- assert.equal(button.getAttribute("data-open"), "false");
173
-
174
- await press(mounted);
175
-
176
- assert.equal(button.getAttribute("data-open"), "true");
177
- assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
178
- });
179
-
180
- it("keeps the query, so the search the user typed survives", async () => {
181
- const router = routerAt(`${THREAD_HREF}?q=invoice`);
182
- const mounted = await mount(router);
183
-
184
- await press(mounted);
185
-
186
- assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
187
- assert.match(router.history.location.search, /q=invoice/);
188
- });
189
-
190
- it("leaves the message one Back away rather than erasing it", async () => {
191
- const router = routerAt(THREAD_HREF);
192
- const mounted = await mount(router);
193
-
194
- await press(mounted);
195
- router.history.back();
196
- await mounted.flush();
197
-
198
- assert.equal(router.history.location.pathname, THREAD_HREF);
199
- });
200
-
201
- it("adds no history entry when the pane had nothing open", async () => {
202
- const router = routerAt(`/mail/${INBOX_ID}`);
203
- const mounted = await mount(router);
204
- const entries = router.history.length;
205
-
206
- await press(mounted);
207
-
208
- assert.equal(router.history.length, entries);
209
- });
210
-
211
- it("carries a compose started off the outbox to a route that mounts it", async () => {
212
- const router = routerAt("/mail/outbox");
213
- const mounted = await mount(router);
214
-
215
- const button = await press(mounted);
216
-
217
- assert.equal(button.getAttribute("data-open"), "true");
218
- assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
219
- });
220
- });
221
-
222
- // Walking off the routes that mount the surface closes it. That rule reads the
223
- // live location, which this harness does not advance — `RouterProvider` here
224
- // serves its first match and stays there — so it is pinned in the e2e suite
225
- // (`compose-over-open-message.spec.ts`) instead. What is checked here is the
226
- // half that would break it: opening compose navigates, and the surface has to
227
- // survive its own navigation.
228
- describe("compose survives the navigation that opens it (#703)", () => {
229
- it("stays open when the press carried the user to another route", async () => {
230
- const router = routerAt("/mail/outbox");
231
- const mounted = await mount(router);
232
-
233
- const button = await press(mounted);
234
-
235
- assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
236
- assert.equal(button.getAttribute("data-open"), "true");
237
- });
238
- });
239
-
240
- describe("the open call is stable", () => {
241
- // A fresh `openCompose` on every render is a loop, not a nuisance: callers
242
- // hold it in dependency arrays, and one of them opens compose from an effect.
243
- it("hands back the same function across a re-render", async () => {
244
- const identities = new Set<unknown>();
245
- const CountingProbe = () => {
246
- const { openCompose } = useCompose();
247
- const [bumped, setBumped] = useState(0);
248
- useEffect(() => {
249
- identities.add(openCompose);
250
- }, [openCompose]);
251
- return createElement(
252
- "button",
253
- { type: "button", onClick: () => setBumped(bumped + 1) },
254
- `Bump ${bumped}`,
255
- );
256
- };
257
- const CountingLayout = () =>
258
- createElement(
259
- ComposeProvider,
260
- null,
261
- createElement(CountingProbe),
262
- createElement(Outlet),
263
- );
264
-
265
- const mounted = await mount(routerAt(`/mail/${INBOX_ID}`, CountingLayout));
266
- // The target legitimately settles as config and the folder list land. What
267
- // must not happen is another identity after that, on a render that has
268
- // nothing to do with compose.
269
- const settled = identities.size;
270
-
271
- mounted.click(mounted.byText("button", "Bump"));
272
- await mounted.flush();
273
- await mounted.wait(20);
274
-
275
- assert.equal(mounted.byText("button", "Bump").textContent, "Bump 1");
276
- assert.equal(identities.size, settled);
277
- });
278
- });
279
-
280
- describe("a compose with nowhere to land says so", () => {
281
- it("opens nothing and reports it while the folder list is in flight", async () => {
282
- const router = routerAt("/mail/outbox");
283
- const mounted = await mount(router, { holdMailboxes: true });
284
-
285
- const button = await press(mounted);
286
-
287
- assert.equal(button.getAttribute("data-open"), "false");
288
- assert.equal(router.history.location.pathname, "/mail/outbox");
289
- assert.match(mounted.text(), /Not ready to write yet/);
290
- });
291
-
292
- it("names the fix when no account has a folder", async () => {
293
- const router = routerAt("/mail/outbox");
294
- const mounted = await mount(router, { noMailboxes: true });
295
-
296
- const button = await press(mounted);
297
-
298
- assert.equal(button.getAttribute("data-open"), "false");
299
- assert.match(mounted.text(), /Nowhere to write from/);
300
- assert.match(mounted.text(), /Settings/);
301
- });
302
-
303
- it("asks the API for nothing off the mail routes", async () => {
304
- const router = routerAt("/settings");
305
- await mount(router);
306
-
307
- assert.deepEqual(
308
- (http?.calls ?? []).map((call) => call.path),
309
- [],
310
- );
311
- });
312
- });
@@ -1,75 +0,0 @@
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
- }
@@ -1,46 +0,0 @@
1
- /**
2
- * The one route resolver behind both compose entry points — the desktop top
3
- * bar's button and the mobile `ComposeFab`. When they each carried their own
4
- * copy the two disagreed, and the FAB opened compose state on routes that
5
- * mount no surface: a dead button.
6
- */
7
- import assert from "node:assert/strict";
8
- import { describe, it } from "node:test";
9
- import { hostsComposeSurface } from "./compose-routes";
10
-
11
- describe("hostsComposeSurface", () => {
12
- it("is true for a mailbox route, which mounts FullCompose", () => {
13
- assert.equal(hostsComposeSurface("/mail/INBOX"), true);
14
- assert.equal(hostsComposeSurface("/mail/abc-123"), true);
15
- });
16
-
17
- it("is false for the virtual views, which mount no surface", () => {
18
- assert.equal(hostsComposeSurface("/mail/brief"), false);
19
- assert.equal(hostsComposeSurface("/mail/outbox"), false);
20
- assert.equal(hostsComposeSurface("/mail/flagged"), false);
21
- });
22
-
23
- it("is false for /mail itself, which only redirects to the brief", () => {
24
- assert.equal(hostsComposeSurface("/mail"), false);
25
- assert.equal(hostsComposeSurface("/mail/"), false);
26
- });
27
-
28
- it("is false outside the mail shell", () => {
29
- assert.equal(hostsComposeSurface("/settings/accounts"), false);
30
- assert.equal(hostsComposeSurface("/"), false);
31
- assert.equal(hostsComposeSurface("/mailroom/x"), false);
32
- });
33
-
34
- it("matches whole segments, so a mailbox may be named after a view", () => {
35
- assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
36
- assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
37
- assert.equal(hostsComposeSurface("/mail/outboxes"), true);
38
- assert.equal(hostsComposeSurface("/mail/briefing"), true);
39
- });
40
-
41
- it("ignores a query string or hash on the path", () => {
42
- assert.equal(hostsComposeSurface("/mail/INBOX?q=invoice"), true);
43
- assert.equal(hostsComposeSurface("/mail/outbox?q=invoice"), false);
44
- assert.equal(hostsComposeSurface("/mail/INBOX#top"), true);
45
- });
46
- });
@@ -1,25 +0,0 @@
1
- /**
2
- * Which routes mount the compose surface.
3
- *
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 — 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
- */
11
-
12
- /** `/mail/<segment>` values that name a view rather than a mailbox. */
13
- const VIRTUAL_MAIL_VIEWS = new Set(["brief", "outbox", "flagged"]);
14
-
15
- /**
16
- * True for `/mail/<id>` where `<id>` is a real mailbox.
17
- *
18
- * Compares whole path segments: a mailbox genuinely named `outbox-2024` hosts
19
- * the surface and must not be read as the virtual outbox.
20
- */
21
- export const hostsComposeSurface = (pathname: string): boolean => {
22
- const [, root, view] = pathname.split(/[?#]/)[0].split("/");
23
- if (root !== "mail" || !view) return false;
24
- return !VIRTUAL_MAIL_VIEWS.has(view);
25
- };