@remit/web-client 0.0.145 → 0.0.147

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.145",
3
+ "version": "0.0.147",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -10,8 +10,10 @@ import type {
10
10
  } from "@remit/api-http-client/types.gen.ts";
11
11
  import {
12
12
  ComposeActionBar,
13
+ ComposeBodySkeleton,
13
14
  ComposeFormShell,
14
15
  ComposeHeader,
16
+ type ComposeSendState,
15
17
  ComposeSubjectField,
16
18
  composeHeaderSummary,
17
19
  defaultComposeLanguages,
@@ -19,6 +21,7 @@ import {
19
21
  modeOfDraft,
20
22
  QuotedText,
21
23
  type RichTextValue,
24
+ SMTP_MISSING_MESSAGE,
22
25
  sanitizeQuotedHtml,
23
26
  unwrapLanguage,
24
27
  wrapWithLanguage,
@@ -51,13 +54,6 @@ const LazyComposeBody = lazy(() =>
51
54
  import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
52
55
  );
53
56
 
54
- const ComposeBodyFallback = () => (
55
- <div className="min-h-[120px] px-3 py-2">
56
- <div className="h-8 mb-2 rounded bg-surface-sunken animate-pulse" />
57
- <div className="min-h-[80px] rounded bg-surface-sunken/50 animate-pulse" />
58
- </div>
59
- );
60
-
61
57
  import { useIsDesktop } from "../../hooks/useMediaQuery.js";
62
58
  import { useVisualViewport } from "../../hooks/useVisualViewport.js";
63
59
  import type { ComposeMode } from "./ComposeProvider";
@@ -183,6 +179,11 @@ const outgoingBody = (
183
179
  : undefined,
184
180
  });
185
181
 
182
+ type SendReadiness =
183
+ | { status: "sending" }
184
+ | { status: "blocked"; reason: string }
185
+ | { status: "ready"; accountId: string };
186
+
186
187
  const isFormEmpty = (
187
188
  toAddresses: AddressEntry[],
188
189
  ccAddresses: AddressEntry[],
@@ -201,6 +202,7 @@ const isFormEmpty = (
201
202
  // ---------------------------------------------------------------------------
202
203
 
203
204
  interface WiredComposeHeaderProps {
205
+ documentGeneration: number;
204
206
  selectedAccountId?: string;
205
207
  onAccountChange: (account: RemitImapAccountResponse) => void;
206
208
  toAddresses: AddressEntry[];
@@ -218,6 +220,7 @@ interface WiredComposeHeaderProps {
218
220
  }
219
221
 
220
222
  const WiredComposeHeader = ({
223
+ documentGeneration,
221
224
  selectedAccountId,
222
225
  onAccountChange,
223
226
  toAddresses,
@@ -235,10 +238,17 @@ const WiredComposeHeader = ({
235
238
  }: WiredComposeHeaderProps) => {
236
239
  const isDesktop = useIsDesktop();
237
240
  const { isKeyboardOpen } = useVisualViewport();
241
+ // What the user asked to see belongs to the document being written, not to
242
+ // the keyboard. Tying it to the keyboard collapsed the rows again the moment
243
+ // one came back up — over the recipient field being typed into, which took
244
+ // the keyboard down with it and started the cycle over.
245
+ const [expandedFor, setExpandedFor] = useState<number | undefined>(undefined);
246
+ const expanded = expandedFor === documentGeneration;
238
247
 
239
248
  return (
240
249
  <ComposeHeader
241
- collapsed={!isDesktop && isKeyboardOpen}
250
+ collapsed={!isDesktop && isKeyboardOpen && !expanded}
251
+ onExpand={() => setExpandedFor(documentGeneration)}
242
252
  summary={composeHeaderSummary({
243
253
  to: toAddresses,
244
254
  cc: ccAddresses,
@@ -305,6 +315,7 @@ export const ComposeForm = ({
305
315
  account?.accountId,
306
316
  );
307
317
  const [draftLoaded, setDraftLoaded] = useState(false);
318
+ const smtpConfigureRef = useRef<HTMLButtonElement>(null);
308
319
  const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
309
320
 
310
321
  // Reset form when the user switches to a different draft (outboxMessageId
@@ -523,14 +534,33 @@ export const ComposeForm = ({
523
534
  );
524
535
 
525
536
  // The action bar refuses a second press while one is in flight, but the
526
- // editor's own Cmd+Enter goes straight to `handleSend`, and the write that
537
+ // editor's own Cmd+Enter goes straight to `attemptSend`, and the write that
527
538
  // now precedes the request widens the window a second press lands in.
528
539
  const sendInFlightRef = useRef(false);
529
540
  const [isSending, setIsSending] = useState(false);
530
- const canSend =
531
- toAddresses.length > 0 &&
532
- !!selectedAccountId &&
533
- !selectedAccountMissingSmtp;
541
+ // Every refusal carries the sentence that explains it. Send is never a
542
+ // no-op: the state it reads has no way to be blocked without a reason. A
543
+ // ready state carries the account the message goes out from, so the send
544
+ // path has no condition of its own left to refuse on in silence.
545
+ const sendReadiness = useMemo<SendReadiness>(() => {
546
+ if (isSending) return { status: "sending" };
547
+ if (!selectedAccountId) {
548
+ return { status: "blocked", reason: "Choose an account to send from." };
549
+ }
550
+ if (selectedAccountMissingSmtp) {
551
+ return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
552
+ }
553
+ if (toAddresses.length === 0) {
554
+ return { status: "blocked", reason: "Add at least one recipient." };
555
+ }
556
+ return { status: "ready", accountId: selectedAccountId };
557
+ }, [
558
+ isSending,
559
+ selectedAccountId,
560
+ selectedAccountMissingSmtp,
561
+ toAddresses.length,
562
+ ]);
563
+ const sendState: ComposeSendState = sendReadiness;
534
564
 
535
565
  useEffect(() => {
536
566
  if (!selectedAccountId) return;
@@ -573,103 +603,106 @@ export const ComposeForm = ({
573
603
  saveDraft,
574
604
  ]);
575
605
 
576
- const handleSend = useCallback(async () => {
577
- if (sendInFlightRef.current) return;
578
- if (!selectedAccountId || toAddresses.length === 0) return;
579
-
580
- sendInFlightRef.current = true;
581
- setIsSending(true);
582
- try {
583
- stopAutoSave();
584
-
585
- const replyData =
586
- sourceMessage && (mode === "reply" || mode === "reply_all")
587
- ? getReferences(sourceMessage)
588
- : {};
589
-
590
- const { htmlBody, textBody } = outgoingBody(
591
- bodyMode,
592
- body,
593
- composeLanguage,
594
- );
595
- const createdThisAttempt = !outboxMessageId;
596
-
597
- // The debounce dropped above may have been holding the last two seconds
598
- // of typing, and an existing entry would otherwise go out as the server
599
- // last saw it (#674). What is on screen is written first, and a write
600
- // that fails stops the send rather than transmitting the older copy.
601
- const flushed = await saveImmediately({
602
- accountId: selectedAccountId,
603
- toAddresses: toAddresses.map((a) => a.email),
604
- ccAddresses:
605
- ccAddresses.length > 0 ? ccAddresses.map((a) => a.email) : undefined,
606
- bccAddresses:
607
- bccAddresses.length > 0
608
- ? bccAddresses.map((a) => a.email)
609
- : undefined,
610
- subject: subject || undefined,
611
- textBody,
612
- htmlBody,
613
- ...replyData,
614
- });
615
-
616
- if (flushed.outcome === "failed") {
617
- pushError({
618
- title: "Couldn't send message",
619
- detail:
620
- formatErrorDetail(flushed.error) ??
621
- "Saving the message failed, so nothing was sent. Try again.",
622
- error: flushed.error,
606
+ const handleSend = useCallback(
607
+ async (accountId: string) => {
608
+ if (sendInFlightRef.current) return;
609
+
610
+ sendInFlightRef.current = true;
611
+ setIsSending(true);
612
+ try {
613
+ stopAutoSave();
614
+
615
+ const replyData =
616
+ sourceMessage && (mode === "reply" || mode === "reply_all")
617
+ ? getReferences(sourceMessage)
618
+ : {};
619
+
620
+ const { htmlBody, textBody } = outgoingBody(
621
+ bodyMode,
622
+ body,
623
+ composeLanguage,
624
+ );
625
+ const createdThisAttempt = !outboxMessageId;
626
+
627
+ // The debounce dropped above may have been holding the last two seconds
628
+ // of typing, and an existing entry would otherwise go out as the server
629
+ // last saw it (#674). What is on screen is written first, and a write
630
+ // that fails stops the send rather than transmitting the older copy.
631
+ const flushed = await saveImmediately({
632
+ accountId,
633
+ toAddresses: toAddresses.map((a) => a.email),
634
+ ccAddresses:
635
+ ccAddresses.length > 0
636
+ ? ccAddresses.map((a) => a.email)
637
+ : undefined,
638
+ bccAddresses:
639
+ bccAddresses.length > 0
640
+ ? bccAddresses.map((a) => a.email)
641
+ : undefined,
642
+ subject: subject || undefined,
643
+ textBody,
644
+ htmlBody,
645
+ ...replyData,
623
646
  });
624
- return;
625
- }
626
647
 
627
- const messageId = flushed.outboxMessageId;
628
-
629
- const sent = await sendMutation
630
- .mutateAsync({
631
- path: { outboxMessageId: messageId },
632
- })
633
- .catch((error: unknown) => {
648
+ if (flushed.outcome === "failed") {
634
649
  pushError({
635
650
  title: "Couldn't send message",
636
651
  detail:
637
- formatErrorDetail(error) ??
638
- (createdThisAttempt
639
- ? "The draft was saved but the send request failed. Try again from the Outbox."
640
- : "The send request failed. Try again."),
641
- error,
652
+ formatErrorDetail(flushed.error) ??
653
+ "Saving the message failed, so nothing was sent. Try again.",
654
+ error: flushed.error,
642
655
  });
643
- return null;
644
- });
645
- if (sent === null) return;
646
-
647
- stopAutoSave(messageId);
648
- startSendPolling(messageId);
649
- onClose();
650
- } finally {
651
- sendInFlightRef.current = false;
652
- setIsSending(false);
653
- }
654
- }, [
655
- selectedAccountId,
656
- toAddresses,
657
- ccAddresses,
658
- bccAddresses,
659
- subject,
660
- body,
661
- bodyMode,
662
- composeLanguage,
663
- mode,
664
- sourceMessage,
665
- outboxMessageId,
666
- saveImmediately,
667
- sendMutation,
668
- stopAutoSave,
669
- startSendPolling,
670
- pushError,
671
- onClose,
672
- ]);
656
+ return;
657
+ }
658
+
659
+ const messageId = flushed.outboxMessageId;
660
+
661
+ const sent = await sendMutation
662
+ .mutateAsync({
663
+ path: { outboxMessageId: messageId },
664
+ })
665
+ .catch((error: unknown) => {
666
+ pushError({
667
+ title: "Couldn't send message",
668
+ detail:
669
+ formatErrorDetail(error) ??
670
+ (createdThisAttempt
671
+ ? "The draft was saved but the send request failed. Try again from the Outbox."
672
+ : "The send request failed. Try again."),
673
+ error,
674
+ });
675
+ return null;
676
+ });
677
+ if (sent === null) return;
678
+
679
+ stopAutoSave(messageId);
680
+ startSendPolling(messageId);
681
+ onClose();
682
+ } finally {
683
+ sendInFlightRef.current = false;
684
+ setIsSending(false);
685
+ }
686
+ },
687
+ [
688
+ toAddresses,
689
+ ccAddresses,
690
+ bccAddresses,
691
+ subject,
692
+ body,
693
+ bodyMode,
694
+ composeLanguage,
695
+ mode,
696
+ sourceMessage,
697
+ outboxMessageId,
698
+ saveImmediately,
699
+ sendMutation,
700
+ stopAutoSave,
701
+ startSendPolling,
702
+ pushError,
703
+ onClose,
704
+ ],
705
+ );
673
706
 
674
707
  const handleDiscard = useCallback(() => {
675
708
  stopAutoSave(outboxMessageId);
@@ -681,6 +714,27 @@ export const ComposeForm = ({
681
714
  onClose();
682
715
  }, [stopAutoSave, outboxMessageId, deleteMutation, onClose]);
683
716
 
717
+ // The refusal is announced first and moved to second. Focus alone carried
718
+ // nothing to a screen reader — the banner above was already on screen, so
719
+ // the press read as the button doing nothing.
720
+ const reportBlocked = useCallback(
721
+ (reason: string) => {
722
+ pushError({ title: "Can't send yet", detail: reason });
723
+ if (reason !== SMTP_MISSING_MESSAGE) return;
724
+ smtpConfigureRef.current?.focus();
725
+ },
726
+ [pushError],
727
+ );
728
+
729
+ const attemptSend = useCallback(() => {
730
+ if (sendReadiness.status === "sending") return;
731
+ if (sendReadiness.status === "blocked") {
732
+ reportBlocked(sendReadiness.reason);
733
+ return;
734
+ }
735
+ void handleSend(sendReadiness.accountId);
736
+ }, [sendReadiness, reportBlocked, handleSend]);
737
+
684
738
  const handleAccountChange = useCallback(
685
739
  (acct: RemitImapAccountResponse) => {
686
740
  setSelectedAccountId(acct.accountId);
@@ -693,11 +747,15 @@ export const ComposeForm = ({
693
747
  <ComposeFormShell
694
748
  banner={
695
749
  selectedAccount && selectedAccountMissingSmtp ? (
696
- <ComposeSmtpMissingBanner accountId={selectedAccount.accountId} />
750
+ <ComposeSmtpMissingBanner
751
+ accountId={selectedAccount.accountId}
752
+ configureRef={smtpConfigureRef}
753
+ />
697
754
  ) : undefined
698
755
  }
699
756
  header={
700
757
  <WiredComposeHeader
758
+ documentGeneration={documentGeneration}
701
759
  selectedAccountId={selectedAccountId}
702
760
  onAccountChange={handleAccountChange}
703
761
  toAddresses={toAddresses}
@@ -725,24 +783,15 @@ export const ComposeForm = ({
725
783
  }
726
784
  actionBar={
727
785
  <ComposeActionBar
728
- onSend={handleSend}
786
+ send={sendState}
787
+ onSend={attemptSend}
788
+ onBlocked={reportBlocked}
729
789
  onDiscard={handleDiscard}
730
- sending={isSending}
731
- canSend={canSend}
732
790
  saveStatus={saveStatus}
733
- unavailableReason={
734
- selectedAccountMissingSmtp ? "SMTP not configured" : undefined
735
- }
736
- onUnavailable={(reason) =>
737
- pushError({
738
- title: "Can't send yet",
739
- detail: reason,
740
- })
741
- }
742
791
  />
743
792
  }
744
793
  >
745
- <Suspense fallback={<ComposeBodyFallback />}>
794
+ <Suspense fallback={<ComposeBodySkeleton />}>
746
795
  <LazyComposeBody
747
796
  key={documentGeneration}
748
797
  mode={bodyMode}
@@ -750,8 +799,8 @@ export const ComposeForm = ({
750
799
  initialHtml={initialHtml}
751
800
  initialText={initialText}
752
801
  onChange={setBody}
753
- onSubmit={handleSend}
754
- autoFocus={mode === "new"}
802
+ onSubmit={attemptSend}
803
+ initialCaret={mode === "new" ? "start" : undefined}
755
804
  onConversionError={pushError}
756
805
  languages={accountLanguages}
757
806
  initialLanguage={draftLanguage}
@@ -1,18 +1,22 @@
1
1
  import { ComposeSmtpMissingBanner as Banner } from "@remit/ui";
2
2
  import { useNavigate } from "@tanstack/react-router";
3
+ import type { Ref } from "react";
3
4
 
4
5
  interface ComposeSmtpMissingBannerProps {
5
6
  accountId: string;
7
+ configureRef?: Ref<HTMLButtonElement>;
6
8
  }
7
9
 
8
10
  /** Sends "Configure SMTP" to the account's settings panel. */
9
11
  export const ComposeSmtpMissingBanner = ({
10
12
  accountId,
13
+ configureRef,
11
14
  }: ComposeSmtpMissingBannerProps) => {
12
15
  const navigate = useNavigate();
13
16
 
14
17
  return (
15
18
  <Banner
19
+ configureRef={configureRef}
16
20
  onConfigure={() => {
17
21
  navigate({
18
22
  to: "/settings/accounts",
@@ -0,0 +1,182 @@
1
+ /**
2
+ * PR #701 review: the phone header collapsed itself again the moment the
3
+ * software keyboard came back up.
4
+ *
5
+ * Tapping the collapsed line blurs whatever had focus, so the keyboard goes
6
+ * down and the rows appear — but only because the keyboard is down. Reaching
7
+ * for To puts it back up, and the header that had reset itself on the way down
8
+ * collapses over the field being typed into: the input unmounts mid-word, the
9
+ * keyboard drops with it, and the rows come back to start the loop again.
10
+ *
11
+ * What is revealed belongs to the document being written, not to the keyboard,
12
+ * so the keyboard going up and down over it changes nothing.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ RouterContextProvider,
25
+ } from "@tanstack/react-router";
26
+ import { act, createElement, useEffect } from "react";
27
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
28
+ import { type HttpMock, mockFetch } from "../../test-support/http";
29
+ import { ComposeForm } from "./ComposeForm";
30
+ import { ComposeProvider, useCompose } from "./ComposeProvider";
31
+
32
+ const ACCOUNT_ID = "acc-1";
33
+ const PHONE = {
34
+ viewportWidth: 390,
35
+ orientation: "portrait",
36
+ pointer: "coarse",
37
+ } as const;
38
+ /** Shrinkage the hook reads as a software keyboard, with room to spare. */
39
+ const KEYBOARD_HEIGHT = 300;
40
+
41
+ const account = {
42
+ accountId: ACCOUNT_ID,
43
+ email: "me@example.com",
44
+ smtpEnabled: true,
45
+ } as unknown as RemitImapAccountResponse;
46
+
47
+ interface VisualViewportStub {
48
+ height: number;
49
+ addEventListener: (type: string, listener: () => void) => void;
50
+ removeEventListener: (type: string, listener: () => void) => void;
51
+ }
52
+
53
+ const listeners = new Set<() => void>();
54
+
55
+ const viewport: VisualViewportStub = {
56
+ height: 0,
57
+ addEventListener: (_type, listener) => {
58
+ listeners.add(listener);
59
+ },
60
+ removeEventListener: (_type, listener) => {
61
+ listeners.delete(listener);
62
+ },
63
+ };
64
+
65
+ const setKeyboard = (open: boolean): void => {
66
+ const full = globalThis.window.innerHeight;
67
+ viewport.height = open ? full - KEYBOARD_HEIGHT : full;
68
+ act(() => {
69
+ for (const listener of [...listeners]) listener();
70
+ });
71
+ };
72
+
73
+ let harness: DomHarness | undefined;
74
+ let http: HttpMock | undefined;
75
+
76
+ afterEach(() => {
77
+ harness?.close();
78
+ harness = undefined;
79
+ http?.restore();
80
+ http = undefined;
81
+ listeners.clear();
82
+ Reflect.deleteProperty(globalThis.window, "visualViewport");
83
+ });
84
+
85
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
86
+
87
+ const rootRoute = createRootRoute();
88
+ const mailboxRoute = createRoute({
89
+ getParentRoute: () => rootRoute,
90
+ path: "/mail/$mailboxId",
91
+ validateSearch: (search: Record<string, unknown>) => search,
92
+ });
93
+
94
+ const testRouter = (): AnyRouter =>
95
+ createRouter({
96
+ routeTree: rootRoute.addChildren([mailboxRoute]),
97
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
98
+ }) as unknown as AnyRouter;
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, {
110
+ mode: "new",
111
+ account,
112
+ onClose: () => {},
113
+ });
114
+ };
115
+
116
+ const mount = async (): Promise<void> => {
117
+ http = mockFetch(async (call) => {
118
+ if (call.path.endsWith("/config")) return { accounts: [account] };
119
+ return { items: [] };
120
+ });
121
+
122
+ Object.defineProperty(globalThis.window, "visualViewport", {
123
+ configurable: true,
124
+ value: viewport,
125
+ });
126
+ viewport.height = globalThis.window.innerHeight - KEYBOARD_HEIGHT;
127
+
128
+ harness = createDomHarness(PHONE);
129
+ harness.renderApp(
130
+ createElement(RouterContextProvider, {
131
+ router: testRouter(),
132
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
133
+ children: createElement(
134
+ ComposeProvider,
135
+ null,
136
+ createElement(Opened, null),
137
+ ),
138
+ }),
139
+ );
140
+ await harness.flush();
141
+ await harness.wait(50);
142
+ };
143
+
144
+ const collapsedBar = (): HTMLElement | null =>
145
+ harness?.query('[data-testid="compose-header-collapsed"]') ?? null;
146
+
147
+ const recipientInput = (): HTMLElement | null =>
148
+ harness?.query("#address-field-To") ?? null;
149
+
150
+ describe("the phone compose header and the software keyboard", () => {
151
+ it("keeps the recipient rows once they are asked for", async () => {
152
+ await mount();
153
+
154
+ const collapsed = collapsedBar();
155
+ assert.ok(
156
+ collapsed,
157
+ "the header stands on one line while the keyboard is up",
158
+ );
159
+ assert.equal(recipientInput(), null);
160
+
161
+ harness?.click(collapsed);
162
+ await harness?.flush();
163
+ assert.ok(recipientInput(), "the rows came back on the tap");
164
+
165
+ // The tap took focus off the body, so the keyboard goes down; reaching for
166
+ // To puts it straight back up.
167
+ setKeyboard(false);
168
+ await harness?.flush();
169
+ setKeyboard(true);
170
+ await harness?.flush();
171
+
172
+ assert.ok(
173
+ recipientInput(),
174
+ "the recipient field survived the keyboard coming back",
175
+ );
176
+ assert.equal(
177
+ collapsedBar(),
178
+ null,
179
+ "the header did not collapse over the field being typed into",
180
+ );
181
+ });
182
+ });
@@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router";
2
2
  import { Pencil } from "lucide-react";
3
3
  import { useCallback } from "react";
4
4
  import { useCompose } from "@/components/compose/ComposeProvider";
5
+ import { locationOpensDetail } from "@/lib/mail-route";
5
6
 
6
7
  /**
7
8
  * Floating Action Button for composing a new message. Mobile-only.
@@ -12,8 +13,9 @@ import { useCompose } from "@/components/compose/ComposeProvider";
12
13
  * `/mail` shell also stops mounting the FAB above that width; the
13
14
  * `lg:hidden` class covers the pre-hydration frame.
14
15
  * - The compose surface is already open.
15
- * - The user is reading a thread (`?selectedMessageId=…`) — the single
16
- * pane is the conversation, and its reply bar is under this corner.
16
+ * - The user is reading a thread — the single pane is the conversation, and
17
+ * its reply bar is under this corner. The brief says so in its path; the
18
+ * lists still to move say so in `?selectedMessageId=…`.
17
19
  * - The user is off `/mail`, which is every route with no mail in it.
18
20
  */
19
21
  export const ComposeFab = () => {
@@ -24,7 +26,9 @@ export const ComposeFab = () => {
24
26
  }, [openCompose]);
25
27
 
26
28
  const search = location.search as Record<string, unknown> | undefined;
27
- const isReadingThread = Boolean(search?.selectedMessageId);
29
+ const isReadingThread =
30
+ Boolean(search?.selectedMessageId) ||
31
+ locationOpensDetail(location.pathname);
28
32
 
29
33
  if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
30
34
  return null;