@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
@@ -1,14 +1,12 @@
1
1
  import {
2
2
  configOperationsGetConfigOptions,
3
- messageOperationsDescribeMessageOptions,
4
3
  threadDetailOperationsListThreadMessagesOptions,
5
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
5
  import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
7
6
  import { MobileReadingPane } from "@remit/ui";
8
7
  import { useQuery } from "@tanstack/react-query";
9
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10
- import type { ComposeMode } from "@/components/compose/ComposeProvider";
11
- import { InlineCompose } from "@/components/compose/InlineCompose";
9
+ import { ConversationCompose } from "@/components/compose/ConversationCompose";
12
10
  import { EmptyState } from "@/components/ui/EmptyState";
13
11
  import { ErrorState } from "@/components/ui/ErrorState";
14
12
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
@@ -17,6 +15,7 @@ import { useMarkAsRead } from "@/hooks/useMarkAsRead";
17
15
  import { useIsDesktop } from "@/hooks/useMediaQuery";
18
16
  import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
19
17
  import { useToggleStar } from "@/hooks/useToggleStar";
18
+ import { type ReplyMode, useOpenReply, useReplySurface } from "@/routing";
20
19
  import { AuthenticityBanner } from "./AuthenticityBanner";
21
20
  import { MessageCard } from "./MessageCard";
22
21
 
@@ -47,21 +46,6 @@ interface ConversationViewProps {
47
46
  * list is always visible in the resizable side pane.
48
47
  */
49
48
  onBack?: () => void;
50
- /**
51
- * When set, immediately opens the inline compose in this mode. The
52
- * parent (e.g. the reading-pane toolbar) sets this to wire the
53
- * top-bar reply/reply-all/forward buttons into the inline compose
54
- * that lives inside the conversation. Reset by calling
55
- * `onComposeClose` after the compose opens.
56
- */
57
- composeRequest?: ComposeMode | null;
58
- /**
59
- * Called once the conversation has acted on `composeRequest` (or
60
- * whenever the inline compose is dismissed). The caller must reset
61
- * `composeRequest` to `null`: a request left standing is read as a fresh
62
- * one on every render.
63
- */
64
- onComposeClose?: () => void;
65
49
  /**
66
50
  * Mobile only: swipe-left handler to open the next message in the list the
67
51
  * thread was opened from. Omitted at the end of the list so the gesture
@@ -103,8 +87,6 @@ export const ConversationView = ({
103
87
  authenticity,
104
88
  onOpenIntelligence,
105
89
  onBack,
106
- composeRequest,
107
- onComposeClose,
108
90
  onSwipeNext,
109
91
  onSwipePrevious,
110
92
  mobileIntelligenceOpen,
@@ -240,72 +222,65 @@ export const ConversationView = ({
240
222
  messages,
241
223
  });
242
224
 
243
- // Compose state for inline reply/forward.
244
- // Controlled via the `composeRequest` prop (toolbar wire-up) or
245
- // locally via r/a/f keyboard shortcuts.
246
- const [composeMode, setComposeMode] = useState<ComposeMode | null>(null);
247
-
248
- // Counts openings rather than tracking the mode: replying from a second
249
- // message while compose is already open is the same request again, and the
250
- // reader still has to be taken to it.
251
- const [composeOpenings, setComposeOpenings] = useState(0);
252
- const openCompose = useCallback((mode: ComposeMode) => {
253
- setComposeMode(mode);
254
- setComposeOpenings((count) => count + 1);
255
- }, []);
256
-
257
- // When the toolbar passes a composeRequest, open the inline compose.
258
- useEffect(() => {
259
- if (composeRequest && composeRequest !== "new") {
260
- openCompose(composeRequest);
261
- // Notify the parent that the request has been consumed.
262
- onComposeClose?.();
263
- }
264
- }, [composeRequest, onComposeClose, openCompose]);
265
-
266
- // Reply and forward act on the latest turn of the conversation, which is the
267
- // last message now that the thread reads oldest first.
268
- const { data: latestMessageData } = useQuery({
269
- ...messageOperationsDescribeMessageOptions({
270
- path: { messageId: latestMessage?.messageId ?? "" },
271
- }),
272
- enabled: !!latestMessage && composeMode !== null,
273
- });
225
+ // The reply is a segment under the message it answers, so what is being
226
+ // written and which turn it answers are the address's to state. Nothing here
227
+ // keeps a second opinion about whether a composer is up.
228
+ const reply = useReplySurface();
229
+ const openReply = useOpenReply();
230
+
231
+ // Which turn a verb aimed at the whole conversation answers: the message the
232
+ // address names, or the latest turn when it names none. A per-message button
233
+ // passes its own instead, which the address could not express before this.
234
+ const answeredMessageId = selectedMessageId ?? latestMessage?.messageId;
235
+
236
+ const replyWith = useCallback(
237
+ (mode: ReplyMode, messageId: string | undefined) => {
238
+ if (!messageId) return;
239
+ openReply({ threadId, messageId, mode });
240
+ },
241
+ [openReply, threadId],
242
+ );
274
243
 
275
- const handleReply = useCallback(() => openCompose("reply"), [openCompose]);
244
+ const handleReply = useCallback(
245
+ () => replyWith("reply", answeredMessageId),
246
+ [replyWith, answeredMessageId],
247
+ );
276
248
 
277
249
  const handleReplyAll = useCallback(
278
- () => openCompose("reply_all"),
279
- [openCompose],
250
+ () => replyWith("reply-all", answeredMessageId),
251
+ [replyWith, answeredMessageId],
280
252
  );
281
253
 
282
254
  const handleForward = useCallback(
283
- () => openCompose("forward"),
284
- [openCompose],
255
+ () => replyWith("forward", answeredMessageId),
256
+ [replyWith, answeredMessageId],
285
257
  );
286
258
 
287
- const handleCloseCompose = useCallback(() => {
288
- setComposeMode(null);
289
- onComposeClose?.();
290
- }, [onComposeClose]);
291
-
292
259
  // Compose opens at the head of the pane, which from halfway down a long
293
260
  // thread is several screens up: without this, replying looks like nothing
294
261
  // happening. Aligned on its top edge, where the recipients are, and left
295
262
  // alone afterwards — it grows downward as it is written into, so following
296
263
  // its height would pull the rows being typed into off the top.
264
+ //
265
+ // Keyed on what is being answered and how, not on the draft under it: the
266
+ // address gains a draft segment while the reader types, and scrolling there
267
+ // would move the pane mid-sentence.
297
268
  const composeRef = useRef<HTMLDivElement>(null);
269
+ const answering =
270
+ reply?.kind === "reply"
271
+ ? `${reply.mode}:${reply.sourceMessageId}`
272
+ : undefined;
298
273
  useEffect(() => {
299
- if (composeMode === null || composeOpenings === 0) return;
274
+ if (!answering) return;
300
275
  composeRef.current?.scrollIntoView({
301
276
  behavior: "smooth",
302
277
  block: "start",
303
278
  });
304
- }, [composeOpenings, composeMode]);
279
+ }, [answering]);
305
280
 
306
281
  // Register keyboard shortcuts
307
282
  useKeyboardNavigation({
308
- enabled: !isLoading && messages.length > 0 && composeMode === null,
283
+ enabled: !isLoading && messages.length > 0 && reply === undefined,
309
284
  bindings: [
310
285
  { key: "j", handler: focusNext, preventDefault: true },
311
286
  { key: "ArrowDown", handler: focusNext, preventDefault: true },
@@ -340,10 +315,22 @@ export const ConversationView = ({
340
315
  );
341
316
  }
342
317
 
318
+ // An address naming a reply over a conversation with nothing in it says so.
319
+ // The mailbox is shared with other clients, so a thread can empty underneath
320
+ // the reader between opening it and answering it, and a composer that simply
321
+ // failed to appear reads as a broken button.
343
322
  if (messages.length === 0) {
344
323
  return (
345
- <div className="flex h-full items-center justify-center">
346
- <EmptyState message="No messages in this thread" />
324
+ <div className="flex h-full items-center justify-center p-4">
325
+ {reply ? (
326
+ <ErrorState
327
+ variant="inline"
328
+ title="There is nothing here to answer"
329
+ error="This conversation has no messages left. Another mail client may have moved or deleted them."
330
+ />
331
+ ) : (
332
+ <EmptyState message="No messages in this thread" />
333
+ )}
347
334
  </div>
348
335
  );
349
336
  }
@@ -355,7 +342,8 @@ export const ConversationView = ({
355
342
  // its own px-5 inset (matches the AppShell ReadingPane geometry). On mobile
356
343
  // each expanded card owns a per-message action bar. Its reply verbs are
357
344
  // wired whatever the account's SMTP state: compose is where an account that
358
- // cannot send says so.
345
+ // cannot send says so. A card's verb answers that card's message, which is
346
+ // the turn the address then names.
359
347
  const renderMessages = (mobile: boolean) => (
360
348
  <div data-testid="conversation-messages">
361
349
  {orderedMessages.map((message, index) => (
@@ -376,9 +364,17 @@ export const ConversationView = ({
376
364
  }
377
365
  accountId={mailboxAccountId}
378
366
  mobile={mobile}
379
- onReply={mobile ? handleReply : undefined}
380
- onReplyAll={mobile ? handleReplyAll : undefined}
381
- onForward={mobile ? handleForward : undefined}
367
+ onReply={
368
+ mobile ? () => replyWith("reply", message.messageId) : undefined
369
+ }
370
+ onReplyAll={
371
+ mobile
372
+ ? () => replyWith("reply-all", message.messageId)
373
+ : undefined
374
+ }
375
+ onForward={
376
+ mobile ? () => replyWith("forward", message.messageId) : undefined
377
+ }
382
378
  />
383
379
  </div>
384
380
  ))}
@@ -389,14 +385,23 @@ export const ConversationView = ({
389
385
  // is what the reader came back for, and it is not something to go looking
390
386
  // for under a thread. It scrolls with the conversation rather than floating
391
387
  // over it, so the pane has one scrollbar whether or not a reply is open.
392
- const compose = composeMode !== null && (
388
+ //
389
+ // A segment naming no mode is a hand-typed address, and it says so where the
390
+ // composer would have been rather than opening nothing: the conversation
391
+ // behind it is still open and still readable.
392
+ const compose = reply && (
393
393
  <div ref={composeRef}>
394
- <InlineCompose
395
- mode={composeMode}
396
- account={activeAccount}
397
- sourceMessage={latestMessageData}
398
- onClose={handleCloseCompose}
399
- />
394
+ {reply.kind === "reply" ? (
395
+ <ConversationCompose surface={reply} />
396
+ ) : (
397
+ <div className="border-b border-line bg-canvas p-4">
398
+ <ErrorState
399
+ variant="inline"
400
+ title="That is not a way to answer a message"
401
+ error={`"${reply.segment}" is not reply, reply-all or forward.`}
402
+ />
403
+ </div>
404
+ )}
400
405
  </div>
401
406
  );
402
407
 
@@ -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 { useRetainOpenPanels } from "@/routing";
44
+ import {
45
+ useComposeDraftId,
46
+ useEditDraft,
47
+ useRetainOpenPanels,
48
+ } from "@/routing";
45
49
  import { NavMenuButton } from "./NavMenuButton";
46
50
 
47
51
  // ---------------------------------------------------------------------------
@@ -154,7 +158,8 @@ 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();
159
164
  const retainPanels = useRetainOpenPanels();
160
165
  const queryClient = useQueryClient();
@@ -186,10 +191,6 @@ export function DraftsView({
186
191
  deleteMutation.mutate({ path: { outboxMessageId } });
187
192
  };
188
193
 
189
- const handleRemitDraftOpen = (outboxMessageId: string) => {
190
- openCompose({ mode: "new", outboxMessageId });
191
- };
192
-
193
194
  const handleImapDraftOpen = (messageId: string) => {
194
195
  const threadId = imapThreads.find(
195
196
  (thread) => thread.messageId === messageId,
@@ -246,11 +247,8 @@ export function DraftsView({
246
247
  <RemitDraftRow
247
248
  key={thread.id}
248
249
  row={thread}
249
- isSelected={
250
- composeState.isOpen &&
251
- thread.id === composeState.outboxMessageId
252
- }
253
- onOpen={handleRemitDraftOpen}
250
+ isSelected={thread.id === openDraftId}
251
+ onOpen={editDraft}
254
252
  onDelete={handleRemitDraftDelete}
255
253
  isDeleting={deleteMutation.isPending}
256
254
  />
@@ -55,6 +55,10 @@ import { useMailContext } from "@/lib/mail-context";
55
55
  import {
56
56
  type OpenThreadPath,
57
57
  type OpenThreadTarget,
58
+ type ReplyMode,
59
+ useIsComposing,
60
+ useIsReplying,
61
+ useOpenReply,
58
62
  useRetainOpenPanels,
59
63
  } from "@/routing";
60
64
 
@@ -81,6 +85,11 @@ interface FlaggedPaneContextValue {
81
85
  * account — Flagged spans accounts, so there is no route mailbox to key by.
82
86
  */
83
87
  actions: ThreadActions;
88
+ /**
89
+ * Answer the conversation the address has open. Absent when it names none,
90
+ * which is what the toolbar turns into its own explanation.
91
+ */
92
+ onReply: ((mode: ReplyMode) => void) | undefined;
84
93
  /** Keyboard, multi-select and next/previous, shared with the mailbox view. */
85
94
  triage: TriageContext;
86
95
  onDeleteMessages: (messageIds: string[]) => void;
@@ -210,15 +219,47 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
210
219
  const triageTarget = focusedThread ?? selectedThread;
211
220
  const triageActions = useThreadActions({ thread: triageTarget });
212
221
 
222
+ // The toolbar answers the conversation on screen; the keyboard answers the
223
+ // row the cursor is on, which may be one the address has not opened yet.
224
+ // Both are one navigation, because the mode and the message it answers are
225
+ // segments of the same address — there is no open-it-first step for the
226
+ // answering half to be dropped from.
227
+ const isComposing = useIsComposing();
228
+ const isReplying = useIsReplying();
229
+ const openReply = useOpenReply();
230
+ const replyToOpenThread = useMemo(() => {
231
+ if (!selectedThread || !selectedMessageId) return undefined;
232
+ return (mode: ReplyMode) =>
233
+ openReply({
234
+ threadId: selectedThread.threadId,
235
+ messageId: selectedMessageId,
236
+ mode,
237
+ });
238
+ }, [openReply, selectedThread, selectedMessageId]);
239
+
240
+ const replyToFocusedThread = useMemo(() => {
241
+ if (!triageTarget) return undefined;
242
+ return (mode: ReplyMode) =>
243
+ openReply({
244
+ threadId: triageTarget.threadId,
245
+ messageId: triageTarget.messageId,
246
+ mode,
247
+ });
248
+ }, [openReply, triageTarget]);
249
+
213
250
  const { nextMessageId, previousMessageId } = useTriageLayer({
214
251
  context: triage,
215
252
  orderedIds: triage.orderedIds,
216
253
  selectedMessageId,
254
+ // The list stays mounted under both writing surfaces, so the triage keys
255
+ // would otherwise fire at the message behind whatever is being typed — or
256
+ // answer a row the cursor moved to while a reply was open.
257
+ enabled: !isComposing && !isReplying,
217
258
  onClose: handleCloseThread,
218
259
  handlers: {
219
- reply: () => actions.requestCompose("reply"),
220
- replyAll: () => actions.requestCompose("reply_all"),
221
- forward: () => actions.requestCompose("forward"),
260
+ reply: () => replyToFocusedThread?.("reply"),
261
+ replyAll: () => replyToFocusedThread?.("reply-all"),
262
+ forward: () => replyToFocusedThread?.("forward"),
222
263
  // The list takes any verb aimed at its selection and opens the wizard
223
264
  // on it, so a shortcut can never reach a bulk action the bar would have
224
265
  // reviewed. What comes back here is a verb aimed at the bare cursor.
@@ -258,6 +299,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
258
299
  nextThread: adjacentThread(nextMessageId),
259
300
  previousThread: adjacentThread(previousMessageId),
260
301
  actions,
302
+ onReply: replyToOpenThread,
261
303
  triage,
262
304
  onDeleteMessages: deleteMessages,
263
305
  handleDeselectIfRemoved,
@@ -292,7 +334,7 @@ function FlaggedListSlot() {
292
334
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
293
335
  */
294
336
  function FlaggedReading() {
295
- const { conversation, actions } = useFlaggedPane();
337
+ const { conversation, actions, onReply } = useFlaggedPane();
296
338
  const { intelligenceOpen, onToggleIntelligence } = useMailContext();
297
339
  // The rail's own width gate, not the shell tier: between 1024 and 1280 the
298
340
  // reading pane is mounted but the rail is not, so "enabled" would promise an
@@ -308,13 +350,9 @@ function FlaggedReading() {
308
350
  intelligenceOpen={canToggleIntelligence && intelligenceOpen}
309
351
  canToggleIntelligence={canToggleIntelligence}
310
352
  onToggleIntelligence={onToggleIntelligence}
311
- onReply={hasThread ? () => actions.requestCompose("reply") : undefined}
312
- onReplyAll={
313
- hasThread ? () => actions.requestCompose("reply_all") : undefined
314
- }
315
- onForward={
316
- hasThread ? () => actions.requestCompose("forward") : undefined
317
- }
353
+ onReply={onReply ? () => onReply("reply") : undefined}
354
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
355
+ onForward={onReply ? () => onReply("forward") : undefined}
318
356
  onDelete={hasThread ? actions.deleteThread : undefined}
319
357
  onToggleStar={hasThread ? actions.toggleStar : undefined}
320
358
  isStarred={actions.isStarred}
@@ -336,8 +374,6 @@ function FlaggedReading() {
336
374
  subject={conversation.subject}
337
375
  selectedMessageId={conversation.messageId}
338
376
  authenticity={conversation.authenticity}
339
- composeRequest={actions.composeRequest}
340
- onComposeClose={actions.clearComposeRequest}
341
377
  />
342
378
  ) : (
343
379
  <ReadingPaneEmpty />