@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
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Every Compose press opens a composer (#719).
3
+ *
4
+ * The press used to resolve a folder to carry the reader to, and could refuse:
5
+ * a folder list still loading, or an account with no folders at all, left the
6
+ * button doing nothing but complaining. A message needs no folder to be written
7
+ * in now, so there is nothing left to refuse — including from `/mail` itself,
8
+ * which names no list because it is on its way to the brief.
9
+ *
10
+ * Closing has the same shape from the other side. Leaving is what the reader
11
+ * asked for, so every branch leaves rather than stranding them inside a surface
12
+ * they have just dismissed.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import {
18
+ type AnyRouter,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ Outlet,
24
+ RouterProvider,
25
+ } from "@tanstack/react-router";
26
+ import { createElement } from "react";
27
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
28
+ import { useCloseCompose, useOpenCompose } from "./compose";
29
+
30
+ let harness: DomHarness | undefined;
31
+
32
+ afterEach(() => {
33
+ harness?.close();
34
+ harness = undefined;
35
+ });
36
+
37
+ // The router reads `self` at construction; the shared jsdom globals stop at
38
+ // `window`.
39
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
40
+
41
+ const MAILBOX_ID = "mbx-inbox";
42
+
43
+ const Press = () => {
44
+ const openCompose = useOpenCompose();
45
+ const closeCompose = useCloseCompose();
46
+ return createElement(
47
+ "div",
48
+ null,
49
+ createElement(
50
+ "button",
51
+ { type: "button", onClick: () => openCompose() },
52
+ "Compose",
53
+ ),
54
+ createElement(
55
+ "button",
56
+ { type: "button", onClick: () => closeCompose() },
57
+ "Close",
58
+ ),
59
+ );
60
+ };
61
+
62
+ const RootLayout = () =>
63
+ createElement("div", null, createElement(Press), createElement(Outlet));
64
+
65
+ /**
66
+ * The real shape: the lists are siblings under `/mail`, and compose is a child
67
+ * of each. `/mail` itself matches no list, which is the address the redirect to
68
+ * the brief passes through.
69
+ */
70
+ const routerAt = (href: string): AnyRouter => {
71
+ const rootRoute = createRootRoute({ component: RootLayout });
72
+ const mailRoute = createRoute({
73
+ getParentRoute: () => rootRoute,
74
+ path: "/mail",
75
+ validateSearch: (search: Record<string, unknown>) => search,
76
+ component: Outlet,
77
+ });
78
+ const briefRoute = createRoute({
79
+ getParentRoute: () => mailRoute,
80
+ path: "/brief",
81
+ component: Outlet,
82
+ });
83
+ const mailboxRoute = createRoute({
84
+ getParentRoute: () => mailRoute,
85
+ path: "/$mailboxId",
86
+ component: Outlet,
87
+ });
88
+ const routeTree = rootRoute.addChildren([
89
+ mailRoute.addChildren([
90
+ briefRoute.addChildren([
91
+ createRoute({
92
+ getParentRoute: () => briefRoute,
93
+ path: "/compose/{-$outboxMessageId}",
94
+ component: () => null,
95
+ }),
96
+ ]),
97
+ mailboxRoute.addChildren([
98
+ createRoute({
99
+ getParentRoute: () => mailboxRoute,
100
+ path: "/compose/{-$outboxMessageId}",
101
+ component: () => null,
102
+ }),
103
+ ]),
104
+ ]),
105
+ ]);
106
+ return createRouter({
107
+ routeTree,
108
+ history: createMemoryHistory({ initialEntries: [href] }),
109
+ }) as unknown as AnyRouter;
110
+ };
111
+
112
+ const mount = async (router: AnyRouter): Promise<DomHarness> => {
113
+ const created = createDomHarness();
114
+ harness = created;
115
+ // Resolve the first match before mounting: `RouterProvider` renders its
116
+ // pending state until the router has loaded, and nothing here waits for it.
117
+ await router.load();
118
+ created.renderApp(createElement(RouterProvider, { router }));
119
+ await created.flush();
120
+ await created.wait(20);
121
+ return created;
122
+ };
123
+
124
+ const press = async (mounted: DomHarness, label: string): Promise<void> => {
125
+ mounted.click(mounted.byText("button", label));
126
+ await mounted.flush();
127
+ await mounted.wait(20);
128
+ };
129
+
130
+ describe("a compose press always opens a composer", () => {
131
+ it("opens on the list being browsed", async () => {
132
+ const router = routerAt("/mail/brief");
133
+ const mounted = await mount(router);
134
+
135
+ await press(mounted, "Compose");
136
+
137
+ assert.equal(router.state.location.pathname, "/mail/brief/compose");
138
+ });
139
+
140
+ it("opens on the folder being browsed", async () => {
141
+ const router = routerAt(`/mail/${MAILBOX_ID}`);
142
+ const mounted = await mount(router);
143
+
144
+ await press(mounted, "Compose");
145
+
146
+ assert.equal(router.state.location.pathname, `/mail/${MAILBOX_ID}/compose`);
147
+ });
148
+
149
+ // The address the redirect to the brief passes through. It names no list, and
150
+ // the press still has to write a message rather than report that it cannot.
151
+ it("opens from an address that names no list at all", async () => {
152
+ const router = routerAt("/mail");
153
+ const mounted = await mount(router);
154
+
155
+ await press(mounted, "Compose");
156
+
157
+ assert.equal(router.state.location.pathname, "/mail/brief/compose");
158
+ });
159
+ });
160
+
161
+ describe("closing compose always leaves", () => {
162
+ it("walks up to the list the surface was opened on", async () => {
163
+ const router = routerAt(`/mail/${MAILBOX_ID}/compose`);
164
+ const mounted = await mount(router);
165
+
166
+ await press(mounted, "Close");
167
+
168
+ assert.equal(router.state.location.pathname, `/mail/${MAILBOX_ID}`);
169
+ });
170
+
171
+ it("lands somewhere real from an address that names no list", async () => {
172
+ const router = routerAt("/mail");
173
+ const mounted = await mount(router);
174
+
175
+ await press(mounted, "Close");
176
+
177
+ assert.equal(router.state.location.pathname, "/mail/brief");
178
+ });
179
+ });
@@ -0,0 +1,220 @@
1
+ import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
2
+ import { useCallback } from "react";
3
+ import { mailListRoute } from "@/lib/mail-route";
4
+ import { useRetainOpenPanels } from "./fragment";
5
+
6
+ /**
7
+ * The compose route under each list. The draft is an optional segment of the
8
+ * same route rather than a child of it, so adopting the id the first autosave
9
+ * creates rewrites the address without unmounting the composer.
10
+ */
11
+ const BRIEF_COMPOSE = "/mail/brief/compose/{-$outboxMessageId}" as const;
12
+ const FLAGGED_COMPOSE = "/mail/flagged/compose/{-$outboxMessageId}" as const;
13
+ const OUTBOX_COMPOSE = "/mail/outbox/compose/{-$outboxMessageId}" as const;
14
+ const MAILBOX_COMPOSE = "/mail/$mailboxId/compose/{-$outboxMessageId}" as const;
15
+
16
+ /**
17
+ * The compose match, if the address has one. Each `from` names a real route, so
18
+ * a segment that does not exist fails to compile.
19
+ *
20
+ * A path matches one list at a time, so at most one of these answers.
21
+ */
22
+ function useComposeParams(): { outboxMessageId?: string } | undefined {
23
+ const brief = useParams({ from: BRIEF_COMPOSE, shouldThrow: false });
24
+ const flagged = useParams({ from: FLAGGED_COMPOSE, shouldThrow: false });
25
+ const outbox = useParams({ from: OUTBOX_COMPOSE, shouldThrow: false });
26
+ const mailbox = useParams({ from: MAILBOX_COMPOSE, shouldThrow: false });
27
+ return brief ?? flagged ?? outbox ?? mailbox;
28
+ }
29
+
30
+ /**
31
+ * Whether the compose surface is showing.
32
+ *
33
+ * Compose is a child route of the list it was started from, so "is compose
34
+ * showing" is a question about the path rather than a flag somebody else has to
35
+ * keep in step with it.
36
+ */
37
+ export function useIsComposing(): boolean {
38
+ return useComposeParams() !== undefined;
39
+ }
40
+
41
+ /**
42
+ * The draft the open composer is writing to, or `undefined` while it is still a
43
+ * message with nothing saved behind it.
44
+ *
45
+ * One owner: the address. A copy in React state disagrees with it the first
46
+ * time the reader presses Back, and the composer comes back empty over a draft
47
+ * row that is still listed.
48
+ */
49
+ export function useComposeDraftId(): string | undefined {
50
+ return useComposeParams()?.outboxMessageId;
51
+ }
52
+
53
+ /**
54
+ * The list the address is browsing, as the two values a navigation to it needs.
55
+ * Both are primitives, so a caller holding them in a dependency array settles.
56
+ */
57
+ function useBrowsedList(): {
58
+ list: "brief" | "flagged" | "outbox" | "mailbox" | undefined;
59
+ mailboxId: string | undefined;
60
+ } {
61
+ const list = useRouterState({
62
+ select: (state) => mailListRoute(state.matches)?.list,
63
+ });
64
+ const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
65
+ return { list, mailboxId: mailbox?.mailboxId };
66
+ }
67
+
68
+ interface ComposeNavigation {
69
+ /** Which list's compose route, and the draft segment under it. */
70
+ to: typeof BRIEF_COMPOSE | typeof FLAGGED_COMPOSE | typeof OUTBOX_COMPOSE;
71
+ params: { outboxMessageId: string | undefined };
72
+ }
73
+
74
+ interface MailboxComposeNavigation {
75
+ to: typeof MAILBOX_COMPOSE;
76
+ params: { mailboxId: string; outboxMessageId: string | undefined };
77
+ }
78
+
79
+ /**
80
+ * Where compose opens: the list being browsed, or the brief.
81
+ *
82
+ * Every case lands on a composer. The brief is the fallback rather than a
83
+ * refusal because it always exists and always mounts the surface, so the two
84
+ * addresses that name no folder — `/mail` on its way to the brief, and a folder
85
+ * route in the frame before its id resolves — open a composer instead of
86
+ * explaining why they cannot. A press that reports rather than writes is the
87
+ * dead button this change deletes, and neither case has anything to report:
88
+ * a message needs no folder to be written in.
89
+ */
90
+ function composeTarget(
91
+ list: "brief" | "flagged" | "outbox" | "mailbox" | undefined,
92
+ mailboxId: string | undefined,
93
+ outboxMessageId: string | undefined,
94
+ ): ComposeNavigation | MailboxComposeNavigation {
95
+ if (list === "flagged")
96
+ return { to: FLAGGED_COMPOSE, params: { outboxMessageId } };
97
+ if (list === "outbox")
98
+ return { to: OUTBOX_COMPOSE, params: { outboxMessageId } };
99
+ if (list === "mailbox" && mailboxId)
100
+ return { to: MAILBOX_COMPOSE, params: { mailboxId, outboxMessageId } };
101
+ return { to: BRIEF_COMPOSE, params: { outboxMessageId } };
102
+ }
103
+
104
+ /**
105
+ * Navigate to compose on the list being browsed, on the draft named or on none.
106
+ *
107
+ * The draft travels as an argument rather than being set somewhere first: an
108
+ * opener that had to do both could do only one, and the composer would come up
109
+ * on whatever the last one left behind.
110
+ */
111
+ function useComposeNavigate(): (
112
+ outboxMessageId: string | undefined,
113
+ options?: { replace?: boolean; keepPanels?: boolean },
114
+ ) => void {
115
+ const navigate = useNavigate();
116
+ const retainPanels = useRetainOpenPanels();
117
+ const { list, mailboxId } = useBrowsedList();
118
+
119
+ return useCallback(
120
+ (
121
+ outboxMessageId: string | undefined,
122
+ options?: { replace?: boolean; keepPanels?: boolean },
123
+ ) => {
124
+ navigate({
125
+ ...composeTarget(list, mailboxId, outboxMessageId),
126
+ search: (prev: Record<string, unknown>) => prev,
127
+ // Opening or closing the surface is going somewhere, so the panes the
128
+ // reader keeps up travel and the overlays they were reading over do
129
+ // not. Recording the draft is not going anywhere — the address is
130
+ // rewritten under a composer they are still typing in — so whatever is
131
+ // up stays up, sheet included.
132
+ hash: options?.keepPanels ? true : retainPanels,
133
+ replace: options?.replace ?? false,
134
+ });
135
+ },
136
+ [navigate, retainPanels, list, mailboxId],
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Start a new message.
142
+ *
143
+ * Takes nothing, because it is wired straight to buttons and key handlers: an
144
+ * optional draft argument here would quietly receive a click event instead.
145
+ * Resuming a draft is `useEditDraft`, which says so.
146
+ *
147
+ * A push, so Back leaves the surface and returns whatever it was opened over,
148
+ * and the query travels with it — a compose started mid-search is still inside
149
+ * that search.
150
+ */
151
+ export function useOpenCompose(): () => void {
152
+ const openCompose = useComposeNavigate();
153
+ return useCallback(() => openCompose(undefined), [openCompose]);
154
+ }
155
+
156
+ /** Resume a draft: the same surface, addressed at what it is editing. */
157
+ export function useEditDraft(): (outboxMessageId: string) => void {
158
+ const openCompose = useComposeNavigate();
159
+ return useCallback(
160
+ (outboxMessageId: string) => openCompose(outboxMessageId),
161
+ [openCompose],
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Record the draft the composer just created, in the address.
167
+ *
168
+ * A replace, and every panel left alone: the reader started one message, so the
169
+ * draft arriving under it is neither another step of history nor a move away
170
+ * from whatever they have open over the composer.
171
+ */
172
+ export function useAdoptComposeDraft(): (outboxMessageId: string) => void {
173
+ const openCompose = useComposeNavigate();
174
+ return useCallback(
175
+ (outboxMessageId: string) =>
176
+ openCompose(outboxMessageId, { replace: true, keepPanels: true }),
177
+ [openCompose],
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Close compose, landing back on the list it was opened from.
183
+ *
184
+ * A push, like opening it: closing a surface moves the reader on, and Back is
185
+ * how they undo that. Replacing the entry instead makes the press after a close
186
+ * do nothing at all, because the entry behind it is the list they are already
187
+ * looking at.
188
+ *
189
+ * Every branch leaves, the brief included. Leaving is what the reader asked
190
+ * for, so a case this cannot name has to put them somewhere real rather than
191
+ * return and strand them inside a surface they just dismissed.
192
+ */
193
+ export function useCloseCompose(): () => void {
194
+ const navigate = useNavigate();
195
+ const retainPanels = useRetainOpenPanels();
196
+ const { list, mailboxId } = useBrowsedList();
197
+
198
+ return useCallback(() => {
199
+ const search = (prev: Record<string, unknown>) => prev;
200
+ const hash = retainPanels;
201
+ if (list === "flagged") {
202
+ navigate({ to: "/mail/flagged", search, hash });
203
+ return;
204
+ }
205
+ if (list === "outbox") {
206
+ navigate({ to: "/mail/outbox", search, hash });
207
+ return;
208
+ }
209
+ if (list === "mailbox" && mailboxId) {
210
+ navigate({
211
+ to: "/mail/$mailboxId",
212
+ params: { mailboxId },
213
+ search,
214
+ hash,
215
+ });
216
+ return;
217
+ }
218
+ navigate({ to: "/mail/brief", search, hash });
219
+ }, [navigate, retainPanels, list, mailboxId]);
220
+ }
@@ -1,3 +1,11 @@
1
+ export {
2
+ useAdoptComposeDraft,
3
+ useCloseCompose,
4
+ useComposeDraftId,
5
+ useEditDraft,
6
+ useIsComposing,
7
+ useOpenCompose,
8
+ } from "./compose";
1
9
  export {
2
10
  formatOpenPanels,
3
11
  isOverlayPanel,