@remit/web-client 0.0.165 → 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 (43) 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/BriefPane.tsx +6 -5
  15. package/src/components/mail/DraftsView.tsx +14 -15
  16. package/src/components/mail/FlaggedPane.tsx +6 -5
  17. package/src/components/mail/MailboxPane.tsx +36 -124
  18. package/src/components/mail/MessageActionMenu.tsx +4 -3
  19. package/src/components/mail/MessageList.tsx +4 -3
  20. package/src/components/mail/OutboxPane.tsx +8 -13
  21. package/src/components/mail/SwipeableMessageRow.tsx +4 -3
  22. package/src/components/mail/intelligence-drawer.stories.tsx +208 -0
  23. package/src/hooks/useSaveDraft.ts +11 -0
  24. package/src/hooks/useSearchMirror.ts +12 -1
  25. package/src/lib/mail-route.test.ts +35 -1
  26. package/src/lib/mail-route.ts +2 -1
  27. package/src/routeTree.gen.ts +92 -0
  28. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  29. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  30. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  31. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  32. package/src/routes/mail.tsx +12 -8
  33. package/src/routing/compose-press-opens.render.test.ts +179 -0
  34. package/src/routing/compose.ts +220 -0
  35. package/src/routing/fragment.render.test.ts +98 -0
  36. package/src/routing/fragment.test.ts +54 -9
  37. package/src/routing/fragment.ts +37 -9
  38. package/src/routing/index.ts +10 -1
  39. package/src/routing/nav-link.tsx +5 -3
  40. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  41. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  42. package/src/lib/compose-routes.test.ts +0 -46
  43. package/src/lib/compose-routes.ts +0 -25
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Compose pressed while already composing starts a new message (#719).
3
+ *
4
+ * The compose route carries the draft as an optional segment, so pressing
5
+ * Compose from inside the composer drops that segment and leaves the same route
6
+ * matched — nothing remounts the form. The reset effect used to bail whenever
7
+ * the incoming id was absent, so the previous draft's recipients, subject and
8
+ * body stayed on screen under an address that said new message, and the next
9
+ * autosave took the create branch and wrote a SECOND draft holding the first
10
+ * one's content.
11
+ *
12
+ * Two properties, one cause: what is on screen, and what is written. The write
13
+ * is the damaging half — the duplicate outlives the session, in the reader's
14
+ * drafts.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { afterEach, describe, it } from "node:test";
19
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
20
+ import {
21
+ type AnyRouter,
22
+ createMemoryHistory,
23
+ createRootRoute,
24
+ createRoute,
25
+ createRouter,
26
+ RouterContextProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement, useState } from "react";
29
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
30
+ import { type HttpMock, mockFetch } from "../../test-support/http";
31
+ import { ComposeForm } from "./ComposeForm";
32
+ import { ComposeProvider } from "./ComposeProvider";
33
+
34
+ const ACCOUNT_ID = "acc-1";
35
+ const DRAFT_ID = "ob-first";
36
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
37
+
38
+ const account = {
39
+ accountId: ACCOUNT_ID,
40
+ email: "me@example.com",
41
+ smtpEnabled: true,
42
+ } as unknown as RemitImapAccountResponse;
43
+
44
+ const draft = {
45
+ outboxMessageId: DRAFT_ID,
46
+ accountId: ACCOUNT_ID,
47
+ fromAddress: account.email,
48
+ toAddresses: ["them@example.com"],
49
+ ccAddresses: [],
50
+ bccAddresses: [],
51
+ references: [],
52
+ subject: "Lunch on Thursday",
53
+ textBody: "Does one o'clock work?",
54
+ status: "draft",
55
+ };
56
+
57
+ let harness: DomHarness | undefined;
58
+ let http: HttpMock | undefined;
59
+
60
+ afterEach(() => {
61
+ harness?.close();
62
+ harness = undefined;
63
+ http?.restore();
64
+ http = undefined;
65
+ });
66
+
67
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
68
+
69
+ const rootRoute = createRootRoute();
70
+ const mailboxRoute = createRoute({
71
+ getParentRoute: () => rootRoute,
72
+ path: "/mail/$mailboxId",
73
+ validateSearch: (search: Record<string, unknown>) => search,
74
+ });
75
+
76
+ const testRouter = (): AnyRouter =>
77
+ createRouter({
78
+ routeTree: rootRoute.addChildren([mailboxRoute]),
79
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
80
+ }) as unknown as AnyRouter;
81
+
82
+ /**
83
+ * The composer with its draft handed in, and a button that takes it away again
84
+ * — which is what pressing Compose does to the address it is mounted under.
85
+ */
86
+ const Composer = () => {
87
+ const [draftId, setDraftId] = useState<string | undefined>(DRAFT_ID);
88
+
89
+ return createElement(
90
+ "div",
91
+ null,
92
+ createElement(
93
+ "button",
94
+ { type: "button", onClick: () => setDraftId(undefined) },
95
+ "Compose",
96
+ ),
97
+ createElement(ComposeForm, {
98
+ mode: "new",
99
+ account,
100
+ outboxMessageId: draftId,
101
+ onDraftCreated: setDraftId,
102
+ onClose: () => undefined,
103
+ }),
104
+ );
105
+ };
106
+
107
+ const creates = () =>
108
+ (http?.calls ?? []).filter(
109
+ (call) => call.method === "POST" && call.path.endsWith("/outbox"),
110
+ );
111
+
112
+ const mount = async (): Promise<DomHarness> => {
113
+ http = mockFetch(async (call) => {
114
+ if (call.path.endsWith("/config")) return { accounts: [account] };
115
+ if (call.method === "POST" && call.path.endsWith("/outbox"))
116
+ return { ...draft, outboxMessageId: "ob-second" };
117
+ return draft;
118
+ });
119
+
120
+ const created = createDomHarness();
121
+ harness = created;
122
+ created.renderApp(
123
+ createElement(RouterContextProvider, {
124
+ router: testRouter(),
125
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
126
+ children: createElement(ComposeProvider, null, createElement(Composer)),
127
+ }),
128
+ );
129
+ await created.flush();
130
+ await created.wait(50);
131
+ return created;
132
+ };
133
+
134
+ const subjectField = (mounted: DomHarness): HTMLInputElement => {
135
+ const field = mounted.query<HTMLInputElement>("[data-subject-field]");
136
+ if (!field) throw new Error("the composer has no subject field");
137
+ return field;
138
+ };
139
+
140
+ describe("Compose pressed while already composing", () => {
141
+ it("takes the previous draft's message off the screen", async () => {
142
+ const mounted = await mount();
143
+ assert.equal(subjectField(mounted).value, draft.subject);
144
+ assert.match(mounted.text(), /them@example.com/);
145
+
146
+ mounted.click(mounted.byText("button", "Compose"));
147
+ await mounted.flush();
148
+ await mounted.wait(20);
149
+
150
+ assert.equal(subjectField(mounted).value, "");
151
+ assert.doesNotMatch(mounted.text(), /them@example.com/);
152
+ });
153
+
154
+ // The half that outlives the session: an autosave from a form still holding
155
+ // the old content, with no draft to write it to, creates a second one.
156
+ it("writes no second draft carrying the first one's content", async () => {
157
+ const mounted = await mount();
158
+ assert.equal(subjectField(mounted).value, draft.subject);
159
+
160
+ mounted.click(mounted.byText("button", "Compose"));
161
+ await mounted.flush();
162
+ await mounted.wait(AUTOSAVE_DEBOUNCE_MS + 500);
163
+
164
+ assert.deepEqual(
165
+ creates().map((call) => call.body?.subject),
166
+ [],
167
+ "a blank new message has nothing to save, so nothing was written",
168
+ );
169
+ });
170
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What the compose surface calls itself.
3
+ *
4
+ * The address says which message is being written, so the heading says the same
5
+ * thing: a message with nothing saved behind it yet is a new one, and one the
6
+ * autosave has written is the draft it wrote. The heading changing as the first
7
+ * save lands is the same moment the action bar says "Draft saved".
8
+ */
9
+ export const composeSurfaceTitle = (outboxMessageId: string | undefined) =>
10
+ outboxMessageId ? "Draft" : "New Message";
@@ -23,11 +23,11 @@ import {
23
23
  createRouter,
24
24
  RouterContextProvider,
25
25
  } from "@tanstack/react-router";
26
- import { act, createElement, useEffect } from "react";
26
+ import { act, createElement } from "react";
27
27
  import { createDomHarness, type DomHarness } from "../../test-support/dom";
28
28
  import { type HttpMock, mockFetch } from "../../test-support/http";
29
29
  import { ComposeForm } from "./ComposeForm";
30
- import { ComposeProvider, useCompose } from "./ComposeProvider";
30
+ import { ComposeProvider } from "./ComposeProvider";
31
31
 
32
32
  const ACCOUNT_ID = "acc-1";
33
33
  const PHONE = {
@@ -97,21 +97,13 @@ const testRouter = (): AnyRouter =>
97
97
  history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
98
98
  }) as unknown as AnyRouter;
99
99
 
100
- const Opened = () => {
101
- const { state, openCompose } = useCompose();
102
-
103
- useEffect(() => {
104
- openCompose({ mode: "new", account });
105
- }, [openCompose]);
106
-
107
- if (!state.isOpen) return null;
108
-
109
- return createElement(ComposeForm, {
100
+ const Opened = () =>
101
+ createElement(ComposeForm, {
110
102
  mode: "new",
111
103
  account,
104
+ onDraftCreated: () => {},
112
105
  onClose: () => {},
113
106
  });
114
- };
115
107
 
116
108
  const mount = async (): Promise<void> => {
117
109
  http = mockFetch(async (call) => {
@@ -1,33 +1,24 @@
1
1
  import { useLocation } from "@tanstack/react-router";
2
2
  import { Pencil } from "lucide-react";
3
- import { useCallback } from "react";
4
- import { useCompose } from "@/components/compose/ComposeProvider";
5
3
  import { locationOpensDetail } from "@/lib/mail-route";
4
+ import { useOpenCompose } from "@/routing";
6
5
 
7
6
  /**
8
7
  * Floating Action Button for composing a new message. Mobile-only.
9
8
  *
10
9
  * Layout follows Material 3: 56×56 surface, 16px from the right and
11
- * bottom edges (plus the iOS safe-area inset). Hidden when any of:
10
+ * bottom edges (plus the iOS safe-area inset). Hidden when either:
12
11
  * - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
13
12
  * `/mail` shell also stops mounting the FAB above that width; the
14
13
  * `lg:hidden` class covers the pre-hydration frame.
15
- * - The compose surface is already open.
16
- * - The user is reading a thread — the single pane is the conversation, and
17
- * its reply bar is under this corner. Every list says so in its path.
18
- * - The user is off `/mail`, which is every route with no mail in it.
14
+ * - The single pane has something open — a conversation, or compose itself.
15
+ * Every list says so in its path.
19
16
  */
20
17
  export const ComposeFab = () => {
21
- const { state, openCompose } = useCompose();
18
+ const compose = useOpenCompose();
22
19
  const location = useLocation();
23
- const compose = useCallback(() => {
24
- openCompose({ mode: "new" });
25
- }, [openCompose]);
26
20
 
27
- const isReadingThread = locationOpensDetail(location.pathname);
28
-
29
- if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
30
- return null;
21
+ if (locationOpensDetail(location.pathname)) return null;
31
22
 
32
23
  return (
33
24
  <button
@@ -1,6 +1,8 @@
1
1
  import { AppShellSlotted } from "@remit/ui";
2
2
  import { createContext, type ReactNode, useContext } from "react";
3
+ import { FullCompose } from "@/components/compose/FullCompose";
3
4
  import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
5
+ import { useIsComposing } from "@/routing";
4
6
 
5
7
  /**
6
8
  * The chrome every list shares, published by the `/mail` layout and consumed by
@@ -53,6 +55,11 @@ export interface MailShellProps {
53
55
  * Below the reading boundary the shell is one pane and takes `phone`, which
54
56
  * swaps between the list and whatever is open in place. Above it the panes sit
55
57
  * side by side and the reading and intelligence slots are filled.
58
+ *
59
+ * Compose is the one surface the single pane cannot take from the `Outlet`,
60
+ * because at this width there is no reading slot to fill. It is read off the
61
+ * address here rather than in each list's phone view, so the four lists cannot
62
+ * disagree about it.
56
63
  */
57
64
  export function MailShell({
58
65
  phone,
@@ -62,6 +69,7 @@ export function MailShell({
62
69
  hasThread = false,
63
70
  }: MailShellProps) {
64
71
  const chrome = useContext(MailShellCtx);
72
+ const isComposing = useIsComposing();
65
73
  if (!chrome) return <AppShellSkeleton />;
66
74
 
67
75
  const shared = {
@@ -78,7 +86,7 @@ export function MailShell({
78
86
  return (
79
87
  <AppShellSlotted
80
88
  {...shared}
81
- list={phone}
89
+ list={isComposing ? <FullCompose /> : phone}
82
90
  intelligenceOpen={chrome.intelligenceOpen}
83
91
  />
84
92
  );
@@ -9,14 +9,14 @@
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 { useCallback, useMemo } from "react";
12
+ import { useMemo } from "react";
13
13
  import { AccountMenu } from "@/auth/AccountMenu";
14
- import { useCompose } from "@/components/compose/ComposeProvider";
15
14
  import { useRefreshControl } from "@/hooks/useRefreshControl";
16
15
  import { useSearchScope } from "@/hooks/useSearchScope";
17
16
  import { openBugReport } from "@/lib/bug-report";
18
17
  import { useMailContext } from "@/lib/mail-context";
19
18
  import { useMailFreshness } from "@/lib/mail-freshness";
19
+ import { useOpenCompose } from "@/routing";
20
20
 
21
21
  interface MailTopBarProps {
22
22
  accounts: RemitImapAccountResponse[];
@@ -26,10 +26,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
26
26
  const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
27
27
  useMailContext();
28
28
  const navigate = useNavigate();
29
- const { openCompose } = useCompose();
30
- const compose = useCallback(() => {
31
- openCompose({ mode: "new" });
32
- }, [openCompose]);
29
+ const compose = useOpenCompose();
33
30
  const { scope, clearScope } = useSearchScope(accounts);
34
31
  const chips =
35
32
  scope.kind === "scoped"
@@ -44,7 +44,7 @@ import { useMailContext } from "@/lib/mail-context";
44
44
  import {
45
45
  type OpenThreadPath,
46
46
  type OpenThreadTarget,
47
- retainOpenPanels,
47
+ useRetainOpenPanels,
48
48
  } from "@/routing";
49
49
 
50
50
  /* ------------------------------------------------------------------ */
@@ -102,6 +102,7 @@ interface BriefPaneProps {
102
102
 
103
103
  function BriefPaneProvider({ thread, children }: BriefPaneProps) {
104
104
  const navigate = useNavigate();
105
+ const retainPanels = useRetainOpenPanels();
105
106
  const { searchInput } = useMailContext();
106
107
  const threadId = thread?.threadId;
107
108
  const pointedAtMessageId = thread?.messageId;
@@ -152,19 +153,19 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
152
153
  // `searchInput`: a row can be tapped before the debounce settles, when
153
154
  // the committed query is still empty.
154
155
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
155
- hash: retainOpenPanels,
156
+ hash: retainPanels,
156
157
  });
157
158
  },
158
- [navigate, searchInput],
159
+ [navigate, retainPanels, searchInput],
159
160
  );
160
161
 
161
162
  const handleCloseThread = useCallback(() => {
162
163
  navigate({
163
164
  to: "/mail/brief",
164
165
  search: (prev) => prev,
165
- hash: retainOpenPanels,
166
+ hash: retainPanels,
166
167
  });
167
- }, [navigate]);
168
+ }, [navigate, retainPanels]);
168
169
 
169
170
  const handleDeselectIfRemoved = useCallback(
170
171
  (removedIds: string[]) => {
@@ -5,8 +5,8 @@
5
5
  * \Drafts special-use folder (issue #505):
6
6
  *
7
7
  * 1. "Not yet sent (Remit)" — outbox rows with status === "draft" belonging
8
- * to the account that owns the open \Drafts mailbox. Clicking a row opens
9
- * compose pre-filled via openCompose({ mode: "new", outboxMessageId }).
8
+ * to the account that owns the open \Drafts mailbox. Clicking a row names
9
+ * the draft and navigates to the folder's compose route.
10
10
  *
11
11
  * 2. "On the server" — IMAP \Drafts thread rows already loaded for the
12
12
  * mailbox. Clicking a row opens the normal reading pane (read-only;
@@ -39,9 +39,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
39
39
  import { useNavigate } from "@tanstack/react-router";
40
40
  import { FileText, Inbox, Trash2 } from "lucide-react";
41
41
  import { useMemo } from "react";
42
- import { useCompose } from "@/components/compose/ComposeProvider";
42
+
43
43
  import { groupDraftSections } from "@/lib/drafts";
44
- import { retainOpenPanels } from "@/routing";
44
+ import {
45
+ useComposeDraftId,
46
+ useEditDraft,
47
+ useRetainOpenPanels,
48
+ } from "@/routing";
45
49
  import { NavMenuButton } from "./NavMenuButton";
46
50
 
47
51
  // ---------------------------------------------------------------------------
@@ -154,8 +158,10 @@ export function DraftsView({
154
158
  title,
155
159
  unreadCount,
156
160
  }: DraftsViewProps) {
157
- const { openCompose, state: composeState } = useCompose();
161
+ const openDraftId = useComposeDraftId();
162
+ const editDraft = useEditDraft();
158
163
  const navigate = useNavigate();
164
+ const retainPanels = useRetainOpenPanels();
159
165
  const queryClient = useQueryClient();
160
166
 
161
167
  // Fetch the full outbox list — both sources are already fetched by the
@@ -185,10 +191,6 @@ export function DraftsView({
185
191
  deleteMutation.mutate({ path: { outboxMessageId } });
186
192
  };
187
193
 
188
- const handleRemitDraftOpen = (outboxMessageId: string) => {
189
- openCompose({ mode: "new", outboxMessageId });
190
- };
191
-
192
194
  const handleImapDraftOpen = (messageId: string) => {
193
195
  const threadId = imapThreads.find(
194
196
  (thread) => thread.messageId === messageId,
@@ -198,7 +200,7 @@ export function DraftsView({
198
200
  to: "/mail/$mailboxId/$threadId/$messageId",
199
201
  params: { mailboxId, threadId, messageId },
200
202
  search: (prev) => prev,
201
- hash: retainOpenPanels,
203
+ hash: retainPanels,
202
204
  });
203
205
  };
204
206
 
@@ -245,11 +247,8 @@ export function DraftsView({
245
247
  <RemitDraftRow
246
248
  key={thread.id}
247
249
  row={thread}
248
- isSelected={
249
- composeState.isOpen &&
250
- thread.id === composeState.outboxMessageId
251
- }
252
- onOpen={handleRemitDraftOpen}
250
+ isSelected={thread.id === openDraftId}
251
+ onOpen={editDraft}
253
252
  onDelete={handleRemitDraftDelete}
254
253
  isDeleting={deleteMutation.isPending}
255
254
  />
@@ -55,7 +55,7 @@ import { useMailContext } from "@/lib/mail-context";
55
55
  import {
56
56
  type OpenThreadPath,
57
57
  type OpenThreadTarget,
58
- retainOpenPanels,
58
+ useRetainOpenPanels,
59
59
  } from "@/routing";
60
60
 
61
61
  /* ------------------------------------------------------------------ */
@@ -113,6 +113,7 @@ interface FlaggedPaneProps {
113
113
 
114
114
  function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
115
115
  const navigate = useNavigate();
116
+ const retainPanels = useRetainOpenPanels();
116
117
  const { searchInput } = useMailContext();
117
118
  const threadId = thread?.threadId;
118
119
  const pointedAtMessageId = thread?.messageId;
@@ -158,19 +159,19 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
158
159
  // `searchInput`: a row can be tapped before the debounce settles, when
159
160
  // the committed query is still empty.
160
161
  search: (prev) => ({ ...prev, q: searchInput || undefined }),
161
- hash: retainOpenPanels,
162
+ hash: retainPanels,
162
163
  });
163
164
  },
164
- [navigate, searchInput],
165
+ [navigate, retainPanels, searchInput],
165
166
  );
166
167
 
167
168
  const handleCloseThread = useCallback(() => {
168
169
  navigate({
169
170
  to: "/mail/flagged",
170
171
  search: (prev) => prev,
171
- hash: retainOpenPanels,
172
+ hash: retainPanels,
172
173
  });
173
- }, [navigate]);
174
+ }, [navigate, retainPanels]);
174
175
 
175
176
  const handleDeselectIfRemoved = useCallback(
176
177
  (removedIds: string[]) => {