@remit/web-client 0.0.166 → 0.0.168

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 (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +91 -29
  3. package/src/components/compose/ComposeProvider.tsx +19 -124
  4. package/src/components/compose/ConversationCompose.tsx +57 -0
  5. package/src/components/compose/FullCompose.tsx +29 -18
  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/BriefPane.tsx +49 -13
  15. package/src/components/mail/ConversationView.tsx +82 -77
  16. package/src/components/mail/DraftsView.tsx +12 -14
  17. package/src/components/mail/FlaggedPane.tsx +49 -13
  18. package/src/components/mail/MailboxPane.tsx +77 -167
  19. package/src/components/mail/OutboxPane.tsx +3 -9
  20. package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
  21. package/src/hooks/useSaveDraft.ts +11 -0
  22. package/src/hooks/useSearchMirror.ts +12 -1
  23. package/src/hooks/useThreadActions.ts +8 -17
  24. package/src/lib/mail-route.test.ts +35 -1
  25. package/src/lib/mail-route.ts +2 -1
  26. package/src/routeTree.gen.ts +220 -15
  27. package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
  28. package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +4 -0
  29. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  30. package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +26 -0
  31. package/src/routes/mail/brief/$threadId/$messageId.tsx +4 -0
  32. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  33. package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
  34. package/src/routes/mail/flagged/$threadId/$messageId.tsx +4 -0
  35. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  36. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  37. package/src/routes/mail.tsx +12 -8
  38. package/src/routing/browsed-list.ts +22 -0
  39. package/src/routing/compose-press-opens.render.test.ts +179 -0
  40. package/src/routing/compose.ts +205 -0
  41. package/src/routing/index.ts +20 -0
  42. package/src/routing/reply.ts +282 -0
  43. package/src/components/compose/InlineCompose.tsx +0 -37
  44. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  45. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  46. package/src/lib/compose-routes.test.ts +0 -46
  47. package/src/lib/compose-routes.ts +0 -25
@@ -0,0 +1,22 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/brief/compose — the compose surface, open over the daily brief, and
4
+ * /mail/brief/compose/<outboxMessageId> once it has a draft to write to.
5
+ *
6
+ * A sibling of the thread route, so navigating here unmatches whatever the list
7
+ * had open in the same transition: nothing has to be closed first, and "opened"
8
+ * and "rendered" are one fact.
9
+ *
10
+ * The draft is an optional segment of this one route rather than a child route,
11
+ * because the first autosave adopts the id it just created while the reader is
12
+ * still typing. Two routes would unmount the composer mid-sentence; one route
13
+ * with a param that arrives is a rewritten address and nothing more.
14
+ */
15
+ import { createFileRoute } from "@tanstack/react-router";
16
+ import { FullCompose } from "@/components/compose/FullCompose";
17
+
18
+ export const Route = createFileRoute("/mail/brief/compose/{-$outboxMessageId}")(
19
+ {
20
+ component: FullCompose,
21
+ },
22
+ );
@@ -0,0 +1,14 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * The reply, reply-all or forward under a conversation opened from Starred.
4
+ *
5
+ * The same shape as the brief's: a child of the message so the source is the
6
+ * address, the mode as one param rather than three literal routes, and the
7
+ * draft as an optional segment so recording it does not unmount the composer.
8
+ * The conversation reads it off the address and opens it at its head.
9
+ */
10
+ import { createFileRoute } from "@tanstack/react-router";
11
+
12
+ export const Route = createFileRoute(
13
+ "/mail/flagged/$threadId/$messageId/$mode/{-$outboxMessageId}",
14
+ )({});
@@ -6,6 +6,10 @@
6
6
  * with no thread, so the pane has nothing to fetch by. The segment names which
7
7
  * message inside the thread the reader pointed at, and the surface it renders is
8
8
  * the thread's.
9
+ *
10
+ * It mounts the pane rather than delegating to an index route, because the
11
+ * reply is a segment under it: a conversation that came back on a child match
12
+ * would collapse and scroll to the top the moment someone pressed Reply.
9
13
  */
10
14
  import { createFileRoute } from "@tanstack/react-router";
11
15
  import { FlaggedPane } from "@/components/mail/FlaggedPane";
@@ -0,0 +1,13 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/flagged/compose — the compose surface, open over the starred list, and
4
+ * /mail/flagged/compose/<outboxMessageId> once it has a draft to write to.
5
+ */
6
+ import { createFileRoute } from "@tanstack/react-router";
7
+ import { FullCompose } from "@/components/compose/FullCompose";
8
+
9
+ export const Route = createFileRoute(
10
+ "/mail/flagged/compose/{-$outboxMessageId}",
11
+ )({
12
+ component: FullCompose,
13
+ });
@@ -0,0 +1,17 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/outbox/compose — the compose surface, open over the outbox, and
4
+ * /mail/outbox/compose/<outboxMessageId> for the draft it is editing.
5
+ *
6
+ * `draft/<outboxMessageId>` beside it reads an outbox message; this writes one.
7
+ * The two are separate addresses because they are separate surfaces, and "Edit
8
+ * as draft" is the move from one to the other.
9
+ */
10
+ import { createFileRoute } from "@tanstack/react-router";
11
+ import { FullCompose } from "@/components/compose/FullCompose";
12
+
13
+ export const Route = createFileRoute(
14
+ "/mail/outbox/compose/{-$outboxMessageId}",
15
+ )({
16
+ component: FullCompose,
17
+ });
@@ -13,7 +13,6 @@ import {
13
13
  } from "@tanstack/react-router";
14
14
  import { useCallback, useEffect, useMemo, useState } from "react";
15
15
  import { z } from "zod";
16
- import { useCompose } from "@/components/compose/ComposeProvider";
17
16
  import { ComposeFab } from "@/components/layout/ComposeFab";
18
17
  import { MailShellProvider } from "@/components/layout/MailShell";
19
18
  import { MailTopBar } from "@/components/layout/MailTopBar";
@@ -26,7 +25,6 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
26
25
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
26
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
27
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
- import { hostsComposeSurface } from "@/lib/compose-routes";
30
28
  import {
31
29
  readIntelligencePref,
32
30
  resolveRailOpen,
@@ -34,13 +32,15 @@ import {
34
32
  } from "@/lib/intelligence-pref";
35
33
  import { MailContext } from "@/lib/mail-context";
36
34
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
37
- import { mailViewKey } from "@/lib/mail-route";
35
+ import { mailListRoute, mailViewKey } from "@/lib/mail-route";
38
36
  import { buildAccountNameIndex } from "@/lib/search-token-index";
39
37
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
40
38
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
39
  import {
42
40
  isOverlayPanel,
43
41
  type OverlayPanel,
42
+ useIsComposing,
43
+ useOpenCompose,
44
44
  useOpenPanels,
45
45
  useOpenThreadPath,
46
46
  useSetOpenPanels,
@@ -166,7 +166,6 @@ function MailLayout() {
166
166
  // the render before committing and nothing is painted with the stale query.
167
167
  // An effect would commit one frame carrying the previous view's text, which
168
168
  // the mirror then has to be defended against.
169
- const pathname = useRouterState({ select: (s) => s.location.pathname });
170
169
  const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
171
170
  const [searchViewKey, setSearchViewKey] = useState(viewKey);
172
171
  if (searchViewKey !== viewKey) {
@@ -214,14 +213,19 @@ function MailLayout() {
214
213
 
215
214
  // `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
216
215
  // mailbox the pane's own triage layer owns the key, so this is disabled there
217
- // rather than firing a second compose alongside it.
218
- const { openCompose } = useCompose();
216
+ // rather than firing a second compose alongside it, and while the composer is
217
+ // up the key belongs to whatever is being typed.
218
+ const openCompose = useOpenCompose();
219
+ const isComposing = useIsComposing();
220
+ const onMailbox = useRouterState({
221
+ select: (s) => mailListRoute(s.matches)?.list === "mailbox",
222
+ });
219
223
  const composeHandlers = useMemo(
220
- () => ({ compose: () => openCompose({ mode: "new" }) }),
224
+ () => ({ compose: openCompose }),
221
225
  [openCompose],
222
226
  );
223
227
  useTriageKeyboard({
224
- enabled: !hostsComposeSurface(pathname),
228
+ enabled: !onMailbox && !isComposing,
225
229
  handlers: composeHandlers,
226
230
  });
227
231
 
@@ -0,0 +1,22 @@
1
+ import { useParams, useRouterState } from "@tanstack/react-router";
2
+ import { type MailListRoute, mailListRoute } from "@/lib/mail-route";
3
+
4
+ export interface BrowsedList {
5
+ list: MailListRoute["list"] | undefined;
6
+ mailboxId: string | undefined;
7
+ }
8
+
9
+ /**
10
+ * The list the address is browsing, as the two values a navigation to it needs.
11
+ *
12
+ * Both are primitives, so a caller holding them in a dependency array settles.
13
+ * Every surface a list can open — compose, a reply — reads the same answer, so
14
+ * the four lists cannot disagree about which one is underneath.
15
+ */
16
+ export function useBrowsedList(): BrowsedList {
17
+ const list = useRouterState({
18
+ select: (state) => mailListRoute(state.matches)?.list,
19
+ });
20
+ const mailbox = useParams({ from: "/mail/$mailboxId", shouldThrow: false });
21
+ return { list, mailboxId: mailbox?.mailboxId };
22
+ }
@@ -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,205 @@
1
+ import { useNavigate, useParams } from "@tanstack/react-router";
2
+ import { useCallback } from "react";
3
+ import { useBrowsedList } from "./browsed-list";
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
+ interface ComposeNavigation {
54
+ /** Which list's compose route, and the draft segment under it. */
55
+ to: typeof BRIEF_COMPOSE | typeof FLAGGED_COMPOSE | typeof OUTBOX_COMPOSE;
56
+ params: { outboxMessageId: string | undefined };
57
+ }
58
+
59
+ interface MailboxComposeNavigation {
60
+ to: typeof MAILBOX_COMPOSE;
61
+ params: { mailboxId: string; outboxMessageId: string | undefined };
62
+ }
63
+
64
+ /**
65
+ * Where compose opens: the list being browsed, or the brief.
66
+ *
67
+ * Every case lands on a composer. The brief is the fallback rather than a
68
+ * refusal because it always exists and always mounts the surface, so the two
69
+ * addresses that name no folder — `/mail` on its way to the brief, and a folder
70
+ * route in the frame before its id resolves — open a composer instead of
71
+ * explaining why they cannot. A press that reports rather than writes is the
72
+ * dead button this change deletes, and neither case has anything to report:
73
+ * a message needs no folder to be written in.
74
+ */
75
+ function composeTarget(
76
+ list: "brief" | "flagged" | "outbox" | "mailbox" | undefined,
77
+ mailboxId: string | undefined,
78
+ outboxMessageId: string | undefined,
79
+ ): ComposeNavigation | MailboxComposeNavigation {
80
+ if (list === "flagged")
81
+ return { to: FLAGGED_COMPOSE, params: { outboxMessageId } };
82
+ if (list === "outbox")
83
+ return { to: OUTBOX_COMPOSE, params: { outboxMessageId } };
84
+ if (list === "mailbox" && mailboxId)
85
+ return { to: MAILBOX_COMPOSE, params: { mailboxId, outboxMessageId } };
86
+ return { to: BRIEF_COMPOSE, params: { outboxMessageId } };
87
+ }
88
+
89
+ /**
90
+ * Navigate to compose on the list being browsed, on the draft named or on none.
91
+ *
92
+ * The draft travels as an argument rather than being set somewhere first: an
93
+ * opener that had to do both could do only one, and the composer would come up
94
+ * on whatever the last one left behind.
95
+ */
96
+ function useComposeNavigate(): (
97
+ outboxMessageId: string | undefined,
98
+ options?: { replace?: boolean; keepPanels?: boolean },
99
+ ) => void {
100
+ const navigate = useNavigate();
101
+ const retainPanels = useRetainOpenPanels();
102
+ const { list, mailboxId } = useBrowsedList();
103
+
104
+ return useCallback(
105
+ (
106
+ outboxMessageId: string | undefined,
107
+ options?: { replace?: boolean; keepPanels?: boolean },
108
+ ) => {
109
+ navigate({
110
+ ...composeTarget(list, mailboxId, outboxMessageId),
111
+ search: (prev: Record<string, unknown>) => prev,
112
+ // Opening or closing the surface is going somewhere, so the panes the
113
+ // reader keeps up travel and the overlays they were reading over do
114
+ // not. Recording the draft is not going anywhere — the address is
115
+ // rewritten under a composer they are still typing in — so whatever is
116
+ // up stays up, sheet included.
117
+ hash: options?.keepPanels ? true : retainPanels,
118
+ replace: options?.replace ?? false,
119
+ });
120
+ },
121
+ [navigate, retainPanels, list, mailboxId],
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Start a new message.
127
+ *
128
+ * Takes nothing, because it is wired straight to buttons and key handlers: an
129
+ * optional draft argument here would quietly receive a click event instead.
130
+ * Resuming a draft is `useEditDraft`, which says so.
131
+ *
132
+ * A push, so Back leaves the surface and returns whatever it was opened over,
133
+ * and the query travels with it — a compose started mid-search is still inside
134
+ * that search.
135
+ */
136
+ export function useOpenCompose(): () => void {
137
+ const openCompose = useComposeNavigate();
138
+ return useCallback(() => openCompose(undefined), [openCompose]);
139
+ }
140
+
141
+ /** Resume a draft: the same surface, addressed at what it is editing. */
142
+ export function useEditDraft(): (outboxMessageId: string) => void {
143
+ const openCompose = useComposeNavigate();
144
+ return useCallback(
145
+ (outboxMessageId: string) => openCompose(outboxMessageId),
146
+ [openCompose],
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Record the draft the composer just created, in the address.
152
+ *
153
+ * A replace, and every panel left alone: the reader started one message, so the
154
+ * draft arriving under it is neither another step of history nor a move away
155
+ * from whatever they have open over the composer.
156
+ */
157
+ export function useAdoptComposeDraft(): (outboxMessageId: string) => void {
158
+ const openCompose = useComposeNavigate();
159
+ return useCallback(
160
+ (outboxMessageId: string) =>
161
+ openCompose(outboxMessageId, { replace: true, keepPanels: true }),
162
+ [openCompose],
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Close compose, landing back on the list it was opened from.
168
+ *
169
+ * A push, like opening it: closing a surface moves the reader on, and Back is
170
+ * how they undo that. Replacing the entry instead makes the press after a close
171
+ * do nothing at all, because the entry behind it is the list they are already
172
+ * looking at.
173
+ *
174
+ * Every branch leaves, the brief included. Leaving is what the reader asked
175
+ * for, so a case this cannot name has to put them somewhere real rather than
176
+ * return and strand them inside a surface they just dismissed.
177
+ */
178
+ export function useCloseCompose(): () => void {
179
+ const navigate = useNavigate();
180
+ const retainPanels = useRetainOpenPanels();
181
+ const { list, mailboxId } = useBrowsedList();
182
+
183
+ return useCallback(() => {
184
+ const search = (prev: Record<string, unknown>) => prev;
185
+ const hash = retainPanels;
186
+ if (list === "flagged") {
187
+ navigate({ to: "/mail/flagged", search, hash });
188
+ return;
189
+ }
190
+ if (list === "outbox") {
191
+ navigate({ to: "/mail/outbox", search, hash });
192
+ return;
193
+ }
194
+ if (list === "mailbox" && mailboxId) {
195
+ navigate({
196
+ to: "/mail/$mailboxId",
197
+ params: { mailboxId },
198
+ search,
199
+ hash,
200
+ });
201
+ return;
202
+ }
203
+ navigate({ to: "/mail/brief", search, hash });
204
+ }, [navigate, retainPanels, list, mailboxId]);
205
+ }
@@ -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,
@@ -18,3 +26,15 @@ export {
18
26
  useOpenThreadPath,
19
27
  } from "./open-thread";
20
28
  export { useOutboxDraftId } from "./outbox-draft";
29
+ export {
30
+ type ReplyAddress,
31
+ type ReplyMode,
32
+ type ReplySurface,
33
+ type ReplyTarget,
34
+ replyModes,
35
+ useAdoptReplyDraft,
36
+ useCloseReply,
37
+ useIsReplying,
38
+ useOpenReply,
39
+ useReplySurface,
40
+ } from "./reply";