@remit/web-client 0.0.135 → 0.0.137

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.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which surface a draft reopens in, and when a mode switch is refused.
3
+ *
4
+ * The mode is derived from `htmlBody`, with no field of its own. It has to be
5
+ * "a non-empty string" and not "truthy": the rich editor serializes an empty
6
+ * document to `<p><br></p>` and a plain draft clears the column to `""`, so a
7
+ * falsy check opens a plain draft correctly by accident and an absent column
8
+ * — an old draft, a partial write — the wrong way round.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import {
13
+ conversionOutcome,
14
+ modeOfDraft,
15
+ switchNeedsWarning,
16
+ } from "./compose-mode.js";
17
+
18
+ describe("the mode a draft reopens in", () => {
19
+ it("opens a draft with HTML as rich", () => {
20
+ assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
21
+ });
22
+
23
+ it("opens an empty rich document as rich", () => {
24
+ assert.equal(modeOfDraft("<p><br></p>"), "rich");
25
+ });
26
+
27
+ it("opens a draft whose HTML was cleared as plain", () => {
28
+ assert.equal(modeOfDraft(""), "plain");
29
+ });
30
+
31
+ it("opens a draft that never had HTML as plain", () => {
32
+ assert.equal(modeOfDraft(undefined), "plain");
33
+ });
34
+ });
35
+
36
+ describe("whether the switch warns first", () => {
37
+ it("warns when the document holds formatting", () => {
38
+ assert.equal(switchNeedsWarning("plain", ["table"]), true);
39
+ });
40
+
41
+ it("says nothing over plain paragraphs", () => {
42
+ assert.equal(switchNeedsWarning("plain", []), false);
43
+ });
44
+
45
+ it("never warns on the way back to rich", () => {
46
+ assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
47
+ });
48
+ });
49
+
50
+ describe("a conversion that would empty a written message", () => {
51
+ it("goes ahead when the conversion carried the message across", () => {
52
+ assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
53
+ outcome: "switch",
54
+ });
55
+ });
56
+
57
+ it("goes ahead when there was nothing to carry", () => {
58
+ assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
59
+ });
60
+
61
+ it("refuses, naming the direction, when plain text came back empty", () => {
62
+ assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
63
+ outcome: "blocked",
64
+ title: "Couldn't switch to plain text",
65
+ detail: "The conversion came back empty, so your message is unchanged.",
66
+ });
67
+ });
68
+
69
+ it("refuses, naming the direction, when rich text came back empty", () => {
70
+ assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
71
+ outcome: "blocked",
72
+ title: "Couldn't switch to rich text",
73
+ detail: "The conversion came back empty, so your message is unchanged.",
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,50 @@
1
+ import type { ComposeBodyMode } from "@remit/ui/rich-text";
2
+
3
+ /**
4
+ * Which surface a draft reopens in, with no field of its own. `htmlBody` a
5
+ * non-empty string is a rich draft; anything else is plain.
6
+ *
7
+ * Not "falsy": the rich editor serializes an empty document to `<p><br></p>`,
8
+ * so a rich draft's `htmlBody` is never absent, and a plain draft clears the
9
+ * column to the empty string rather than omitting it — absent means "leave
10
+ * alone" at every layer below this one.
11
+ */
12
+ export const modeOfDraft = (htmlBody: string | undefined): ComposeBodyMode =>
13
+ typeof htmlBody === "string" && htmlBody.length > 0 ? "rich" : "plain";
14
+
15
+ /**
16
+ * Whether switching to plain text destroys something. True for any node type or
17
+ * text format the document holds that plain text cannot carry.
18
+ */
19
+ export const switchNeedsWarning = (
20
+ target: ComposeBodyMode,
21
+ formatting: readonly string[],
22
+ ): boolean => target === "plain" && formatting.length > 0;
23
+
24
+ export type ConversionOutcome =
25
+ | { outcome: "switch" }
26
+ | { outcome: "blocked"; title: string; detail: string };
27
+
28
+ const BLOCKED_TITLES: Record<ComposeBodyMode, string> = {
29
+ plain: "Couldn't switch to plain text",
30
+ rich: "Couldn't switch to rich text",
31
+ };
32
+
33
+ /**
34
+ * A conversion that empties a written message does not happen. Autosave would
35
+ * persist the blank body a moment later, so the draft would be gone with
36
+ * nothing said. An empty body converting to an empty body is not this case.
37
+ */
38
+ export const conversionOutcome = (
39
+ target: ComposeBodyMode,
40
+ source: string,
41
+ converted: string,
42
+ ): ConversionOutcome => {
43
+ if (source.trim() === "" || converted.trim() !== "")
44
+ return { outcome: "switch" };
45
+ return {
46
+ outcome: "blocked",
47
+ title: BLOCKED_TITLES[target],
48
+ detail: "The conversion came back empty, so your message is unchanged.",
49
+ };
50
+ };
@@ -0,0 +1,245 @@
1
+ /**
2
+ * The attachment list wired to the real download (#683): a click fetches the
3
+ * part's `contentUrl` and hands the bytes to the browser under the sanitized
4
+ * name, and a fetch that fails says what failed instead of doing nothing.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { afterEach, describe, it } from "node:test";
9
+ import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import type { RemitImapBodyPartResponse } from "@remit/api-http-client/types.gen.ts";
11
+ import { createElement } from "react";
12
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
13
+ import { MessageAttachments } from "./MessageAttachments";
14
+
15
+ let harness: DomHarness | undefined;
16
+ const originalFetch = globalThis.fetch;
17
+ const originalCreateElement = document.createElement.bind(document);
18
+ const objectUrls = globalThis.URL as unknown as {
19
+ createObjectURL?: (blob: Blob) => string;
20
+ revokeObjectURL?: (value: string) => void;
21
+ };
22
+ const originalCreateObjectURL = objectUrls.createObjectURL;
23
+ const originalRevokeObjectURL = objectUrls.revokeObjectURL;
24
+
25
+ afterEach(() => {
26
+ harness?.close();
27
+ harness = undefined;
28
+ globalThis.fetch = originalFetch;
29
+ document.createElement = originalCreateElement;
30
+ objectUrls.createObjectURL = originalCreateObjectURL;
31
+ objectUrls.revokeObjectURL = originalRevokeObjectURL;
32
+ });
33
+
34
+ const bodyParts = (
35
+ ...overrides: Partial<RemitImapBodyPartResponse>[]
36
+ ): RemitImapBodyPartResponse[] =>
37
+ overrides.map(
38
+ (override, index) =>
39
+ ({
40
+ bodyPartId: `part-${index + 1}`,
41
+ mediaType: "APPLICATION",
42
+ mediaSubtype: "PDF",
43
+ sizeOctets: 4096,
44
+ disposition: "attachment",
45
+ dispositionFilename: "report.pdf",
46
+ contentUrl: `https://cdn.test/content/parts/${index + 2}`,
47
+ isMultipart: false,
48
+ ...override,
49
+ }) as RemitImapBodyPartResponse,
50
+ );
51
+
52
+ interface SavedFile {
53
+ filename: string;
54
+ url: string;
55
+ }
56
+
57
+ const captureSaves = (): SavedFile[] => {
58
+ const saved: SavedFile[] = [];
59
+ objectUrls.createObjectURL = () => "blob:test";
60
+ objectUrls.revokeObjectURL = () => undefined;
61
+
62
+ document.createElement = ((tag: string) => {
63
+ const element = originalCreateElement(tag);
64
+ if (tag === "a") {
65
+ const anchor = element as HTMLAnchorElement;
66
+ anchor.click = () =>
67
+ saved.push({ filename: anchor.download, url: anchor.href });
68
+ }
69
+ return element;
70
+ }) as typeof document.createElement;
71
+ return saved;
72
+ };
73
+
74
+ const MESSAGE_ID = "msg-1";
75
+
76
+ const mount = (parts?: RemitImapBodyPartResponse[], hasAttachment = true) => {
77
+ harness = createDomHarness();
78
+ harness.renderApp(
79
+ createElement(MessageAttachments, {
80
+ messageId: MESSAGE_ID,
81
+ bodyParts: parts,
82
+ hasAttachment,
83
+ }),
84
+ );
85
+ return harness;
86
+ };
87
+
88
+ describe("MessageAttachments", () => {
89
+ it("lists each attachment part with a download control", () => {
90
+ const dom = mount(
91
+ bodyParts(
92
+ { dispositionFilename: "board-pack.pdf" },
93
+ {
94
+ dispositionFilename: "site-plan.png",
95
+ mediaType: "IMAGE",
96
+ mediaSubtype: "PNG",
97
+ sizeOctets: 1024,
98
+ },
99
+ ),
100
+ );
101
+
102
+ assert.match(dom.text(), /2 attachments/);
103
+ assert.ok(dom.byLabel("Download board-pack.pdf"));
104
+ assert.ok(dom.byLabel("Download site-plan.png"));
105
+ assert.match(dom.text(), /PNG · 1 KB/);
106
+ });
107
+
108
+ it("renders nothing for a message with no attachments", () => {
109
+ const dom = mount(
110
+ bodyParts({ disposition: "inline", mediaSubtype: "HTML" }),
111
+ false,
112
+ );
113
+ assert.equal(dom.html(), "");
114
+ });
115
+
116
+ it("downloads the part's content URL and saves it under the sanitized name", async () => {
117
+ const requested: string[] = [];
118
+ globalThis.fetch = (async (url: string) => {
119
+ requested.push(String(url));
120
+ return new Response("payload", { status: 200 });
121
+ }) as unknown as typeof fetch;
122
+ const saved = captureSaves();
123
+
124
+ const dom = mount(
125
+ bodyParts({
126
+ dispositionFilename: "../../../etc/passwd",
127
+ contentUrl: "https://cdn.test/content/parts/2",
128
+ }),
129
+ );
130
+ dom.click(dom.byLabel("Download passwd"));
131
+ await dom.flush();
132
+
133
+ assert.deepEqual(requested, ["https://cdn.test/content/parts/2"]);
134
+ assert.deepEqual(saved, [{ filename: "passwd", url: "blob:test" }]);
135
+ });
136
+
137
+ it("states what failed and offers a retry instead of doing nothing", async () => {
138
+ globalThis.fetch = (async () =>
139
+ new Response("gone", {
140
+ status: 404,
141
+ statusText: "Not Found",
142
+ })) as unknown as typeof fetch;
143
+
144
+ const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
145
+ dom.click(dom.byLabel("Download board-pack.pdf"));
146
+ await dom.flush();
147
+
148
+ const alert = dom.query('[data-testid="attachment-error"]');
149
+ assert.ok(alert, "a failed download must render an alert");
150
+ assert.match(alert.textContent ?? "", /missing from storage/);
151
+ assert.match(alert.textContent ?? "", /Re-sync the account/);
152
+ assert.match(alert.textContent ?? "", /Try again/);
153
+ assert.match(alert.innerHTML, /issues\/new/);
154
+ });
155
+
156
+ it("retries the same part when the failure's retry is taken", async () => {
157
+ let attempts = 0;
158
+ globalThis.fetch = (async () => {
159
+ attempts += 1;
160
+ return attempts === 1
161
+ ? new Response("gone", { status: 404, statusText: "Not Found" })
162
+ : new Response("payload", { status: 200 });
163
+ }) as unknown as typeof fetch;
164
+ const saved = captureSaves();
165
+
166
+ const dom = mount(bodyParts({ dispositionFilename: "board-pack.pdf" }));
167
+ dom.click(dom.byLabel("Download board-pack.pdf"));
168
+ await dom.flush();
169
+
170
+ dom.click(dom.byText("button", "Try again"));
171
+ await dom.flush();
172
+
173
+ assert.equal(attempts, 2);
174
+ assert.deepEqual(saved, [{ filename: "board-pack.pdf", url: "blob:test" }]);
175
+ assert.equal(dom.query('[data-testid="attachment-error"]'), null);
176
+ });
177
+
178
+ // The signed content URL outlives neither an hour nor a re-signing, and a
179
+ // deferred part is materialized by the describe read itself. Re-hitting the
180
+ // same URL cannot fix either, so the retry goes back through describeMessage
181
+ // and uses the URL that read minted.
182
+ it("renews the content URL through describeMessage when the link expired", async () => {
183
+ const requested: string[] = [];
184
+ globalThis.fetch = (async (url: string) => {
185
+ requested.push(String(url));
186
+ return requested.length === 1
187
+ ? new Response("expired", {
188
+ status: 403,
189
+ headers: { "x-remit-403-reason": "expired" },
190
+ })
191
+ : new Response("payload", { status: 200 });
192
+ }) as unknown as typeof fetch;
193
+ const saved = captureSaves();
194
+
195
+ const dom = mount(
196
+ bodyParts({ contentUrl: "https://cdn.test/parts/2?exp=1&sig=old" }),
197
+ );
198
+ dom.queryClient.setQueryData(
199
+ messageOperationsDescribeMessageQueryKey({
200
+ path: { messageId: MESSAGE_ID },
201
+ }),
202
+ {
203
+ bodyParts: bodyParts({
204
+ contentUrl: "https://cdn.test/parts/2?exp=2&sig=new",
205
+ }),
206
+ },
207
+ );
208
+
209
+ dom.click(dom.byLabel("Download report.pdf"));
210
+ await dom.flush();
211
+ await dom.flush();
212
+
213
+ assert.deepEqual(requested, [
214
+ "https://cdn.test/parts/2?exp=1&sig=old",
215
+ "https://cdn.test/parts/2?exp=2&sig=new",
216
+ ]);
217
+ assert.equal(saved.length, 1);
218
+ assert.equal(dom.query('[data-testid="attachment-error"]'), null);
219
+ });
220
+
221
+ it("does not re-read the message for a failure a re-read cannot fix", async () => {
222
+ let attempts = 0;
223
+ globalThis.fetch = (async () => {
224
+ attempts += 1;
225
+ return new Response("gone", { status: 404, statusText: "Not Found" });
226
+ }) as unknown as typeof fetch;
227
+
228
+ const dom = mount(bodyParts({}));
229
+ dom.click(dom.byLabel("Download report.pdf"));
230
+ await dom.flush();
231
+
232
+ assert.equal(attempts, 1);
233
+ assert.ok(dom.query('[data-testid="attachment-error"]'));
234
+ });
235
+
236
+ it("says so when the server flags an attachment no part describes", () => {
237
+ const dom = mount([], true);
238
+ assert.match(dom.text(), /none of its parts describe one/);
239
+ });
240
+
241
+ it("claims nothing while the message is still loading", () => {
242
+ const dom = mount(undefined, true);
243
+ assert.equal(dom.html(), "");
244
+ });
245
+ });
@@ -0,0 +1,153 @@
1
+ import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type {
3
+ RemitImapBodyPartResponse,
4
+ RemitImapDescribeMessageResponse,
5
+ } from "@remit/api-http-client/types.gen.ts";
6
+ import { type AttachmentDownloadState, AttachmentList } from "@remit/ui";
7
+ import { useQueryClient } from "@tanstack/react-query";
8
+ import { useMemo, useState } from "react";
9
+ import { useAuthProvider } from "@/auth/provider";
10
+ import {
11
+ attachmentFailureContent,
12
+ attachmentReportUrl,
13
+ extractAttachmentFailureDetail,
14
+ extractAttachmentFailureReason,
15
+ fetchAttachment,
16
+ isRepairableByRefetch,
17
+ saveBlob,
18
+ } from "@/lib/attachment-download";
19
+ import {
20
+ type MessageAttachment,
21
+ selectMessageAttachments,
22
+ } from "@/lib/message-attachments";
23
+
24
+ interface MessageAttachmentsProps {
25
+ messageId: string;
26
+ /** Body parts from `describeMessage`; absent while the message is loading. */
27
+ bodyParts?: readonly RemitImapBodyPartResponse[];
28
+ /** The thread row's server-side attachment flag. */
29
+ hasAttachment?: boolean;
30
+ className?: string;
31
+ }
32
+
33
+ const IDLE: AttachmentDownloadState = { status: "idle" };
34
+
35
+ /**
36
+ * The attachments carried by an open message, and the download behind each row.
37
+ *
38
+ * Download state is per row and lives here rather than in a query: a download is
39
+ * a one-shot user action with no cached result to hold, and the bytes are handed
40
+ * to the browser rather than rendered.
41
+ */
42
+ export const MessageAttachments = ({
43
+ messageId,
44
+ bodyParts,
45
+ hasAttachment = false,
46
+ className,
47
+ }: MessageAttachmentsProps) => {
48
+ const { getToken } = useAuthProvider();
49
+ const queryClient = useQueryClient();
50
+ const [downloads, setDownloads] = useState<
51
+ Record<string, AttachmentDownloadState>
52
+ >({});
53
+
54
+ const attachments = useMemo(
55
+ () => selectMessageAttachments(bodyParts ?? []),
56
+ [bodyParts],
57
+ );
58
+
59
+ const describeKey = messageOperationsDescribeMessageQueryKey({
60
+ path: { messageId },
61
+ });
62
+
63
+ /**
64
+ * A stale signature and an unmaterialized part are both repaired by re-reading
65
+ * the message: `describeMessage` re-signs every `contentUrl`, and it
66
+ * materializes the deferred per-part objects on its way through. Re-hitting
67
+ * the same URL alone cannot fix either (remit-mail/remit#1240), so the retry
68
+ * goes through the read path and then uses the URL it just minted.
69
+ */
70
+ const fetchThroughRefreshedDescribe = (
71
+ attachment: MessageAttachment,
72
+ ): Promise<Blob> =>
73
+ fetchAttachment(attachment.contentUrl, getToken).catch(
74
+ (error: unknown): Promise<Blob> => {
75
+ if (!isRepairableByRefetch(extractAttachmentFailureReason(error))) {
76
+ throw error;
77
+ }
78
+ return queryClient
79
+ .refetchQueries({ queryKey: describeKey })
80
+ .then(() => {
81
+ const refreshed =
82
+ queryClient.getQueryData<RemitImapDescribeMessageResponse>(
83
+ describeKey,
84
+ );
85
+ const renewed = refreshed
86
+ ? selectMessageAttachments(refreshed.bodyParts).find(
87
+ (candidate) => candidate.bodyPartId === attachment.bodyPartId,
88
+ )
89
+ : undefined;
90
+ return fetchAttachment(
91
+ renewed?.contentUrl ?? attachment.contentUrl,
92
+ getToken,
93
+ );
94
+ });
95
+ },
96
+ );
97
+
98
+ const setState = (id: string, state: AttachmentDownloadState) =>
99
+ setDownloads((current) => ({ ...current, [id]: state }));
100
+
101
+ const download = (bodyPartId: string) => {
102
+ const attachment = attachments.find(
103
+ (candidate) => candidate.bodyPartId === bodyPartId,
104
+ );
105
+ if (!attachment) {
106
+ throw new Error(
107
+ `No attachment part ${bodyPartId} on message ${messageId}`,
108
+ );
109
+ }
110
+
111
+ setState(bodyPartId, { status: "downloading" });
112
+ fetchThroughRefreshedDescribe(attachment)
113
+ .then((blob) => {
114
+ saveBlob(blob, attachment.filename);
115
+ setState(bodyPartId, IDLE);
116
+ })
117
+ .catch((error: unknown) => {
118
+ const reason = extractAttachmentFailureReason(error);
119
+ const fallback = extractAttachmentFailureDetail(error);
120
+ const { title, detail } = attachmentFailureContent(
121
+ reason,
122
+ attachment.filename,
123
+ fallback,
124
+ );
125
+ setState(bodyPartId, {
126
+ status: "failed",
127
+ title,
128
+ detail,
129
+ reportUrl: attachmentReportUrl(reason, attachment.filename, fallback),
130
+ });
131
+ });
132
+ };
133
+
134
+ // Only claim an attachment is unaccounted for once the parts have arrived;
135
+ // while `describeMessage` is in flight there is nothing to contradict.
136
+ const hasUnlistedAttachment =
137
+ hasAttachment && bodyParts !== undefined && attachments.length === 0;
138
+
139
+ return (
140
+ <AttachmentList
141
+ className={className}
142
+ attachments={attachments.map((attachment) => ({
143
+ attachmentId: attachment.bodyPartId,
144
+ filename: attachment.filename,
145
+ typeLabel: attachment.typeLabel,
146
+ sizeOctets: attachment.sizeOctets,
147
+ download: downloads[attachment.bodyPartId] ?? IDLE,
148
+ }))}
149
+ onDownload={download}
150
+ hasUnlistedAttachment={hasUnlistedAttachment}
151
+ />
152
+ );
153
+ };
@@ -16,6 +16,7 @@ import { formatDatePreset } from "@/lib/format";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { AutoMovedIndicator } from "./AutoMovedIndicator";
18
18
  import { MessageActionMenu } from "./MessageActionMenu";
19
+ import { MessageAttachments } from "./MessageAttachments";
19
20
  import { MessageBody } from "./MessageBody";
20
21
  import { MobileMessageBar } from "./MobileMessageBar";
21
22
  import { RawMessageView } from "./RawMessageView";
@@ -134,7 +135,11 @@ const CollapsedCard = ({
134
135
  trailing={
135
136
  <>
136
137
  {threadMessage.hasAttachment && (
137
- <Paperclip className="size-3 shrink-0 text-fg-subtle" />
138
+ <Paperclip
139
+ className="size-3 shrink-0 text-fg-subtle"
140
+ role="img"
141
+ aria-label="Has an attachment"
142
+ />
138
143
  )}
139
144
  <StarButton
140
145
  isStarred={threadMessage.hasStars}
@@ -238,11 +243,6 @@ const ExpandedCard = ({
238
243
  onToggleStar={onToggleStar}
239
244
  isStarPending={isStarPending}
240
245
  />
241
- {threadMessage.hasAttachment && (
242
- <span className="text-fg-subtle p-0.5">
243
- <Paperclip className="size-3.5" />
244
- </span>
245
- )}
246
246
  </div>
247
247
  {isUnread && (
248
248
  // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on decorative indicator provides useful context for assistive tech
@@ -324,6 +324,15 @@ const ExpandedCard = ({
324
324
  isTrusted={isTrusted}
325
325
  category={toDisplayCategory(threadMessage.category)}
326
326
  />
327
+ {/* The body slot runs edge to edge on a phone; the attachment
328
+ list is app chrome, not part of the email, so it takes the
329
+ gutter back. */}
330
+ <MessageAttachments
331
+ messageId={threadMessage.messageId}
332
+ bodyParts={messageData?.bodyParts}
333
+ hasAttachment={threadMessage.hasAttachment}
334
+ className="mt-4 px-2 lg:px-0"
335
+ />
327
336
  </div>
328
337
  )
329
338
  }
@@ -57,16 +57,29 @@ export const ConfirmDialog = ({
57
57
  return () => window.removeEventListener("keydown", handleKeyDown, true);
58
58
  }, [isOpen, handleKeyDown]);
59
59
 
60
+ // Whoever opened the dialog gets the focus back when it closes. Without this
61
+ // a cancelled confirmation drops focus to the body, and the control the user
62
+ // was on — the compose mode toggle, a row's delete button — is gone from
63
+ // under the keyboard.
60
64
  useEffect(() => {
61
- if (isOpen) {
62
- cancelRef.current?.focus();
63
- }
65
+ if (!isOpen) return;
66
+ const opener =
67
+ document.activeElement instanceof HTMLElement
68
+ ? document.activeElement
69
+ : null;
70
+ cancelRef.current?.focus();
71
+ return () => {
72
+ if (opener?.isConnected) opener.focus();
73
+ };
64
74
  }, [isOpen]);
65
75
 
66
76
  if (!isOpen) return null;
67
77
 
68
78
  return (
69
- <div className="fixed inset-0 z-50 flex items-center justify-center">
79
+ // Above every other overlay, the mobile compose sheet included: a
80
+ // confirmation is the decision blocking whatever is under it, and a drawer
81
+ // portalled to the body at the same level would cover it.
82
+ <div className="fixed inset-0 z-[60] flex items-center justify-center">
70
83
  {/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
71
84
  dialog itself must stay in the accessibility tree, and an
72
85
  aria-hidden ancestor would take it out. */}