@remit/web-client 0.0.167 → 0.0.169

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.
@@ -46,7 +46,6 @@ import {
46
46
  useRef,
47
47
  useState,
48
48
  } from "react";
49
- import type { ComposeMode } from "@/components/compose/ComposeProvider";
50
49
  import { Drawer } from "@/components/layout/Drawer";
51
50
  import { ConversationView } from "@/components/mail/ConversationView";
52
51
  import { DraftsView } from "@/components/mail/DraftsView";
@@ -116,8 +115,11 @@ import {
116
115
  import {
117
116
  type OpenThreadPath,
118
117
  type OpenThreadTarget,
118
+ type ReplyMode,
119
119
  useIsComposing,
120
+ useIsReplying,
120
121
  useOpenCompose,
122
+ useOpenReply,
121
123
  useRetainOpenPanels,
122
124
  } from "@/routing";
123
125
  import { MailViewChrome } from "./MailViewChrome";
@@ -199,11 +201,11 @@ interface MailboxPaneContextValue {
199
201
  listCommandsRef: RefObject<MessageListCommands | null>;
200
202
  onRetry: () => void;
201
203
  // Toolbar / reading pane actions
202
- toolbarComposeRequest: ComposeMode | null;
203
- onToolbarReply: () => void;
204
- onToolbarReplyAll: () => void;
205
- onToolbarForward: () => void;
206
- onClearComposeRequest: () => void;
204
+ /**
205
+ * Answer the open conversation. Absent when none is open, which is what the
206
+ * toolbar turns into its own explanation.
207
+ */
208
+ onReply: ((mode: ReplyMode) => void) | undefined;
207
209
  onToolbarDelete: () => void;
208
210
  onToolbarStar: () => void;
209
211
  onToolbarMove: (destMailboxId: string) => void;
@@ -541,19 +543,34 @@ function MailboxPaneProvider({
541
543
 
542
544
  const getThreadMessageIds = useThreadMessageIds();
543
545
 
544
- const { requestCompose: setToolbarComposeRequest } = toolbarActions;
545
-
546
- const handleToolbarReply = useCallback(() => {
547
- setToolbarComposeRequest("reply");
548
- }, [setToolbarComposeRequest]);
549
- const handleToolbarReplyAll = useCallback(() => {
550
- setToolbarComposeRequest("reply_all");
551
- }, [setToolbarComposeRequest]);
552
- const handleToolbarForward = useCallback(() => {
553
- setToolbarComposeRequest("forward");
554
- }, [setToolbarComposeRequest]);
546
+ const openReply = useOpenReply();
547
+
548
+ // The toolbar answers the conversation on screen; the keyboard answers the
549
+ // row the cursor is on, which may be one the address has not opened yet. Both
550
+ // are one navigation, because the mode and the message it answers are
551
+ // segments of the same address.
552
+ const replyToOpenThread = useMemo(() => {
553
+ if (!selectedThread || !selectedMessageId) return undefined;
554
+ return (mode: ReplyMode) =>
555
+ openReply({
556
+ threadId: selectedThread.threadId,
557
+ messageId: selectedMessageId,
558
+ mode,
559
+ });
560
+ }, [openReply, selectedThread, selectedMessageId]);
561
+
562
+ const replyToFocusedThread = useMemo(() => {
563
+ if (!focusedThread) return undefined;
564
+ return (mode: ReplyMode) =>
565
+ openReply({
566
+ threadId: focusedThread.threadId,
567
+ messageId: focusedThread.messageId,
568
+ mode,
569
+ });
570
+ }, [openReply, focusedThread]);
555
571
 
556
572
  const isComposing = useIsComposing();
573
+ const isReplying = useIsReplying();
557
574
  const openCompose = useOpenCompose();
558
575
 
559
576
  const messageIdsForFocusedThread = useCallback(
@@ -599,26 +616,18 @@ function MailboxPaneProvider({
599
616
  senderEmail: focusedThread?.fromEmail ?? undefined,
600
617
  });
601
618
 
602
- const ensureFocusedOpen = useCallback(() => {
603
- if (selectedMessageId || !triageFocusedId) return false;
604
- const row = threads.find((t) => t.messageId === triageFocusedId);
605
- if (!row) return false;
606
- handleOpenThread({ threadId: row.threadId, messageId: triageFocusedId });
607
- return true;
608
- }, [selectedMessageId, triageFocusedId, threads, handleOpenThread]);
609
-
610
- const triageReply = useCallback(() => {
611
- if (ensureFocusedOpen()) return;
612
- if (selectedThread) setToolbarComposeRequest("reply");
613
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
614
- const triageReplyAll = useCallback(() => {
615
- if (ensureFocusedOpen()) return;
616
- if (selectedThread) setToolbarComposeRequest("reply_all");
617
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
618
- const triageForward = useCallback(() => {
619
- if (ensureFocusedOpen()) return;
620
- if (selectedThread) setToolbarComposeRequest("forward");
621
- }, [ensureFocusedOpen, selectedThread, setToolbarComposeRequest]);
619
+ const triageReply = useCallback(
620
+ () => replyToFocusedThread?.("reply"),
621
+ [replyToFocusedThread],
622
+ );
623
+ const triageReplyAll = useCallback(
624
+ () => replyToFocusedThread?.("reply-all"),
625
+ [replyToFocusedThread],
626
+ );
627
+ const triageForward = useCallback(
628
+ () => replyToFocusedThread?.("forward"),
629
+ [replyToFocusedThread],
630
+ );
622
631
 
623
632
  const triageTargetMessageIds = useCallback(
624
633
  (): string[] => messageIdsForFocusedThread(focusedThread),
@@ -732,9 +741,10 @@ function MailboxPaneProvider({
732
741
  context: triage,
733
742
  orderedIds: threads.map((t) => t.messageId),
734
743
  selectedMessageId,
735
- // The list stays mounted under the compose surface, so the triage keys
736
- // would otherwise fire at the message behind whatever is being typed.
737
- enabled: !isComposing,
744
+ // The list stays mounted under both writing surfaces, so the triage keys
745
+ // would otherwise fire at the message behind whatever is being typed — or
746
+ // answer a row the cursor moved to while a reply was open.
747
+ enabled: !isComposing && !isReplying,
738
748
  onClose: closeThread,
739
749
  handlers: {
740
750
  reply: triageReply,
@@ -812,11 +822,7 @@ function MailboxPaneProvider({
812
822
  onTriageContextChange: handleTriageContextChange,
813
823
  listCommandsRef,
814
824
  onRetry: () => refetch(),
815
- toolbarComposeRequest: toolbarActions.composeRequest,
816
- onToolbarReply: handleToolbarReply,
817
- onToolbarReplyAll: handleToolbarReplyAll,
818
- onToolbarForward: handleToolbarForward,
819
- onClearComposeRequest: toolbarActions.clearComposeRequest,
825
+ onReply: replyToOpenThread,
820
826
  onToolbarDelete: toolbarActions.deleteThread,
821
827
  onToolbarStar: toolbarActions.toggleStar,
822
828
  onToolbarMove: toolbarActions.moveThread,
@@ -1071,11 +1077,7 @@ function MailboxReading() {
1071
1077
  conversation,
1072
1078
  intelligenceOpen,
1073
1079
  onToggleIntelligence,
1074
- toolbarComposeRequest,
1075
- onToolbarReply,
1076
- onToolbarReplyAll,
1077
- onToolbarForward,
1078
- onClearComposeRequest,
1080
+ onReply,
1079
1081
  onToolbarDelete,
1080
1082
  onToolbarStar,
1081
1083
  onToolbarMove,
@@ -1135,8 +1137,6 @@ function MailboxReading() {
1135
1137
  onOpenIntelligence={
1136
1138
  conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1137
1139
  }
1138
- composeRequest={toolbarComposeRequest}
1139
- onComposeClose={onClearComposeRequest}
1140
1140
  />
1141
1141
  ) : (
1142
1142
  <ReadingPaneEmpty />
@@ -1150,9 +1150,9 @@ function MailboxReading() {
1150
1150
  intelligenceOpen={intelligenceShowing}
1151
1151
  canToggleIntelligence={hasThread}
1152
1152
  onToggleIntelligence={toggleIntelligence}
1153
- onReply={hasThread ? onToolbarReply : undefined}
1154
- onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1155
- onForward={hasThread ? onToolbarForward : undefined}
1153
+ onReply={onReply ? () => onReply("reply") : undefined}
1154
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
1155
+ onForward={onReply ? () => onReply("forward") : undefined}
1156
1156
  canDelete={hasThread}
1157
1157
  onDelete={hasThread ? onToolbarDelete : undefined}
1158
1158
  onToggleStar={hasThread ? onToolbarStar : undefined}
@@ -126,35 +126,55 @@ afterEach(() => {
126
126
  // `window`.
127
127
  (globalThis as { self?: typeof globalThis }).self ??= globalThis;
128
128
 
129
- // The conversation is what the mailbox route renders, and the per-message
130
- // action menu reads the route's own search — so it is mounted on a route
131
- // rather than beside one.
132
- const testRouter = (): AnyRouter => {
129
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
130
+ const REPLY_PATH = `${MESSAGE_PATH}/reply`;
131
+
132
+ // The conversation is what the message route renders, and the reply is the
133
+ // segment under it — so the tree here is the app's: the folder, the thread, the
134
+ // message, and the mode below it. The address is what opens the reply, so the
135
+ // cases below mount at one or the other.
136
+ const testRouter = (href: string): AnyRouter => {
133
137
  // The compose provider sits at the root the way `__root.tsx` mounts it.
134
138
  const rootRoute = createRootRoute({
135
139
  component: () =>
136
140
  createElement(ComposeProvider, null, createElement(Outlet)),
137
141
  });
142
+ const mailboxRoute = createRoute({
143
+ getParentRoute: () => rootRoute,
144
+ path: "/mail/$mailboxId",
145
+ validateSearch: (search: Record<string, unknown>) => search,
146
+ });
147
+ const threadRoute = createRoute({
148
+ getParentRoute: () => mailboxRoute,
149
+ path: "$threadId",
150
+ });
151
+ const messageRoute = createRoute({
152
+ getParentRoute: () => threadRoute,
153
+ path: "$messageId",
154
+ component: () =>
155
+ createElement(ConversationView, {
156
+ threadId: THREAD_ID,
157
+ mailboxId: MAILBOX_ID,
158
+ subject: "Lunch Thursday?",
159
+ selectedMessageId: MESSAGE_ID,
160
+ }),
161
+ });
162
+ const replyRoute = createRoute({
163
+ getParentRoute: () => messageRoute,
164
+ path: "$mode/{-$outboxMessageId}",
165
+ });
138
166
  const routeTree = rootRoute.addChildren([
139
- createRoute({
140
- getParentRoute: () => rootRoute,
141
- path: "/mail/$mailboxId",
142
- validateSearch: (search: Record<string, unknown>) => search,
143
- component: () =>
144
- createElement(ConversationView, {
145
- threadId: THREAD_ID,
146
- mailboxId: MAILBOX_ID,
147
- subject: "Lunch Thursday?",
148
- }),
149
- }),
167
+ mailboxRoute.addChildren([
168
+ threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
169
+ ]),
150
170
  ]);
151
171
  return createRouter({
152
172
  routeTree,
153
- history: createMemoryHistory({ initialEntries: [`/mail/${MAILBOX_ID}`] }),
173
+ history: createMemoryHistory({ initialEntries: [href] }),
154
174
  }) as unknown as AnyRouter;
155
175
  };
156
176
 
157
- const mount = async (): Promise<DomHarness> => {
177
+ const mountAt = async (href: string): Promise<[DomHarness, AnyRouter]> => {
158
178
  http = mockFetch((call) => {
159
179
  if (call.path.endsWith("/config")) return { accounts: [account] };
160
180
  if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
@@ -164,17 +184,39 @@ const mount = async (): Promise<DomHarness> => {
164
184
  return { items: [] };
165
185
  });
166
186
 
167
- const router = testRouter();
187
+ const router = testRouter(href);
168
188
  await router.load();
169
189
  harness = createDomHarness();
170
190
  harness.renderApp(createElement(RouterProvider, { router }));
171
191
  await harness.flush();
172
192
  await harness.wait(20);
173
193
  await harness.flush();
174
- return harness;
194
+ return [harness, router];
175
195
  };
176
196
 
177
- /** The r shortcut the reading pane binds — how a reader opens the reply. */
197
+ /** The conversation, with nothing being written under it. */
198
+ const mount = async (): Promise<DomHarness> => {
199
+ const [mounted] = await mountAt(MESSAGE_PATH);
200
+ return mounted;
201
+ };
202
+
203
+ /**
204
+ * The same conversation at the address that names a reply — which is the whole
205
+ * of what opens one.
206
+ *
207
+ * A cold load rather than a press: a navigation inside this harness moves the
208
+ * router but never re-renders the tree, so nothing here can say what the pane
209
+ * does as an address changes under it. Every case below is what one address
210
+ * renders, and the transitions between two of them — the reply opening, and its
211
+ * closing again with the conversation left standing — are asserted in
212
+ * `packages/e2e/specs/detail-routes.spec.ts`, against a real browser.
213
+ */
214
+ const mountReplying = async (): Promise<DomHarness> => {
215
+ const [mounted] = await mountAt(REPLY_PATH);
216
+ return mounted;
217
+ };
218
+
219
+ /** The r shortcut the reading pane binds — how a reader asks for the reply. */
178
220
  const pressReply = async (mounted: DomHarness): Promise<void> => {
179
221
  mounted.dispatch(
180
222
  mounted.window,
@@ -224,9 +266,7 @@ const verticalScrollers = (root: Element): Element[] =>
224
266
 
225
267
  describe("answering the message that is open", () => {
226
268
  it("puts the reply in the same scrolling region as the message", async () => {
227
- const mounted = await mount();
228
-
229
- await pressReply(mounted);
269
+ const mounted = await mountReplying();
230
270
 
231
271
  const pane = mounted.query("article");
232
272
  assert.ok(pane, "the conversation pane is mounted");
@@ -246,9 +286,7 @@ describe("answering the message that is open", () => {
246
286
  });
247
287
 
248
288
  it("leads the pane with the reply, above the thread it answers", async () => {
249
- const mounted = await mount();
250
-
251
- await pressReply(mounted);
289
+ const mounted = await mountReplying();
252
290
 
253
291
  const compose = mounted.query('[data-testid="compose-body-area"]');
254
292
  assert.ok(compose, "the reply opened");
@@ -313,7 +351,7 @@ describe("answering the message that is open", () => {
313
351
  );
314
352
  });
315
353
 
316
- it("gives the pane one scrollbar, reply open or not", async () => {
354
+ it("gives the pane one scrollbar with nothing being written", async () => {
317
355
  const mounted = await mount();
318
356
 
319
357
  const pane = mounted.query("article");
@@ -323,9 +361,13 @@ describe("answering the message that is open", () => {
323
361
  1,
324
362
  "reading a thread scrolls one thing",
325
363
  );
364
+ });
326
365
 
327
- await pressReply(mounted);
366
+ it("gives the pane one scrollbar with the reply open", async () => {
367
+ const mounted = await mountReplying();
328
368
 
369
+ const pane = mounted.query("article");
370
+ assert.ok(pane, "the conversation pane is mounted");
329
371
  assert.equal(
330
372
  verticalScrollers(pane).length,
331
373
  1,
@@ -333,6 +375,17 @@ describe("answering the message that is open", () => {
333
375
  );
334
376
  });
335
377
 
378
+ // The address is the whole of what opens a reply, so what the key does is
379
+ // move it: the mode lands under the message that was open, and the thread
380
+ // that was matched behind it stays matched.
381
+ it("moves the address to the reply under the open message", async () => {
382
+ const [mounted, router] = await mountAt(MESSAGE_PATH);
383
+
384
+ await pressReply(mounted);
385
+
386
+ assert.equal(router.state.location.pathname, REPLY_PATH);
387
+ });
388
+
336
389
  it("collapses the message from the chevron beside the sender", async () => {
337
390
  const mounted = await mount();
338
391
 
@@ -2,7 +2,7 @@ import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { useMailContext } from "@/lib/mail-context";
4
4
  import { shouldMirrorQuery } from "@/lib/search-view";
5
- import { useIsComposing } from "@/routing";
5
+ import { useIsComposing, useIsReplying } from "@/routing";
6
6
 
7
7
  /**
8
8
  * The list the mirror writes to. Each list calls the hook with its own route,
@@ -44,7 +44,9 @@ export type SearchMirrorTarget =
44
44
  *
45
45
  * A composer is not the pre-search list's leftover either. It is the reader's
46
46
  * own unsent message, so a search started while it is up narrows the list
47
- * behind it and leaves it standing.
47
+ * behind it and leaves it standing. A reply, a reply-all and a forward hold
48
+ * unsent text the same way and are spared the same way. Both are read off the
49
+ * path, so neither has to be kept in step with the surface that is showing.
48
50
  */
49
51
  export function useSearchMirror(target: SearchMirrorTarget): void {
50
52
  const navigate = useNavigate();
@@ -52,6 +54,8 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
52
54
  const { q: urlQuery = "" } = useSearch({ from: "/mail" });
53
55
  const pathname = useRouterState({ select: (s) => s.location.pathname });
54
56
  const isComposing = useIsComposing();
57
+ const isReplying = useIsReplying();
58
+ const isWriting = isComposing || isReplying;
55
59
 
56
60
  // Read at effect time rather than depended on: the URL is what the mirror
57
61
  // compares against, not what re-triggers it.
@@ -73,10 +77,10 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
73
77
  if (!mayWrite) return;
74
78
  const queryGoesActive =
75
79
  Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
76
- // The composer is the reader's own unsent message, not a leftover of the
77
- // list they were on, so a query going active narrows the list behind it and
80
+ // A message being written is the reader's own, not a leftover of the list
81
+ // they were on, so a query going active narrows the list behind it and
78
82
  // leaves it where it is.
79
- const closesTheOpenSurface = queryGoesActive && !isComposing;
83
+ const closesTheOpenSurface = queryGoesActive && !isWriting;
80
84
  const search = (prev: Record<string, unknown>) => ({
81
85
  ...prev,
82
86
  q: committedQuery || undefined,
@@ -107,6 +111,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
107
111
  mailboxId,
108
112
  listPath,
109
113
  pathname,
110
- isComposing,
114
+ isWriting,
111
115
  ]);
112
116
  }
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * useThreadActions — the reading pane's verbs for one open thread.
3
3
  *
4
- * Delete, move, star and the compose requests (reply / reply-all / forward),
5
- * over the same mutation hooks the mailbox list uses. The mailbox view keys
6
- * them by its route; the brief and Flagged are cross-account, so they key by
7
- * the open thread's own `mailboxId` / `accountId` (#149).
4
+ * Delete, move and star, over the same mutation hooks the mailbox list uses.
5
+ * The mailbox view keys them by its route; the brief and Flagged are
6
+ * cross-account, so they key by the open thread's own `mailboxId` /
7
+ * `accountId` (#149).
8
+ *
9
+ * Answering a message is not here: reply, reply-all and forward are a segment
10
+ * under the message, so they are a navigation rather than a verb a pane holds.
8
11
  */
9
12
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
10
- import { useCallback, useState } from "react";
11
- import type { ComposeMode } from "@/components/compose/ComposeProvider";
13
+ import { useCallback } from "react";
12
14
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
13
15
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
14
16
  import { useMoveMessages } from "@/hooks/useMoveMessages";
@@ -31,9 +33,6 @@ export interface ThreadActions {
31
33
  deleteThread: () => void;
32
34
  moveThread: (destinationMailboxId: string) => void;
33
35
  toggleStar: () => void;
34
- composeRequest: ComposeMode | null;
35
- requestCompose: (mode: ComposeMode) => void;
36
- clearComposeRequest: () => void;
37
36
  }
38
37
 
39
38
  export const useThreadActions = ({
@@ -91,11 +90,6 @@ export const useThreadActions = ({
91
90
  toggleStarFor(thread.messageId, thread.hasStars);
92
91
  }, [thread, toggleStarFor]);
93
92
 
94
- const [composeRequest, setComposeRequest] = useState<ComposeMode | null>(
95
- null,
96
- );
97
- const clearComposeRequest = useCallback(() => setComposeRequest(null), []);
98
-
99
93
  return {
100
94
  mailboxId: resolvedMailboxId,
101
95
  accountId: resolvedAccountId,
@@ -103,8 +97,5 @@ export const useThreadActions = ({
103
97
  deleteThread,
104
98
  moveThread,
105
99
  toggleStar,
106
- composeRequest,
107
- requestCompose: setComposeRequest,
108
- clearComposeRequest,
109
100
  };
110
101
  };