{attachmentsForDisplay(attachments).map(({ att, index }) => {
diff --git a/lib/compose/__tests__/attachment-order.test.ts b/lib/compose/__tests__/attachment-order.test.ts
index d5337caea..de1376bed 100644
--- a/lib/compose/__tests__/attachment-order.test.ts
+++ b/lib/compose/__tests__/attachment-order.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { attachmentsForDisplay } from '../attachment-order';
describe('the order of the composer attachment chips', () => {
- it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
+ it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => {
const files = [
{ name: 'a.pdf' },
{ name: 'b.pdf', uploading: true },
@@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => {
{ name: 'broken.png', error: 'failed' },
];
const shown = attachmentsForDisplay(files);
- expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
+ expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']);
// Each keeps its place in the composer's list, for Remove.
- expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
+ expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]);
});
});
diff --git a/lib/compose/attachment-order.ts b/lib/compose/attachment-order.ts
index 158d340d6..594011a40 100644
--- a/lib/compose/attachment-order.ts
+++ b/lib/compose/attachment-order.ts
@@ -1,13 +1,14 @@
/**
* The composer's attachment chips in the order they're shown: failed first,
- * then still uploading, then the rest, each group in the order added. The
- * list scrolls past about two rows, so the files that need an eye on them
- * are the ones always in view. `index` is the place in the composer's list.
+ * then the rest in the order added. A file still uploading keeps its place:
+ * moved to the front, it jumped to the end once done and went out of sight
+ * past the rows on screen (compose-p2-11). `index` is the place in the
+ * composer's list.
*/
export function attachmentsForDisplay
(
attachments: readonly T[],
): Array<{ att: T; index: number }> {
- const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
+ const rank = (att: T) => (att.error ? 0 : 1);
return attachments
.map((att, index) => ({ att, index }))
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
From 664ec3831d44640e29f44f44fc0a3a0fe02c0ea6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:36:16 +0100
Subject: [PATCH 15/20] fix: open a recipient's menu when its chip is clicked
---
components/email/__tests__/composer-a11y.test.tsx | 15 +++++++++++++++
components/email/email-composer.tsx | 8 ++++++++
2 files changed, 23 insertions(+)
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index f71f24a0d..52783b107 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -394,6 +394,21 @@ describe('moving a recipient by keyboard (MO-07)', () => {
expect(liveRegion()).toHaveTextContent('recipient_moved:Anna Berg|cc');
});
+ // compose-p2-16: a click did nothing visible, and Escape then closed the composer.
+ it('opens the chip menu on a click, and Escape closes only the menu', async () => {
+ const onClose = vi.fn();
+ render();
+ const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
+ fireEvent.click(chip.querySelector('.truncate')!);
+
+ const move = await screen.findByTestId('recipient-move-bcc');
+ fireEvent.keyDown(move, { key: 'Escape' });
+
+ await waitFor(() => expect(screen.queryByTestId('recipient-move-bcc')).toBeNull());
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
+ });
+
it('opens the chip menu with Shift+F10 too', async () => {
render();
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index d0d922b13..615dfd910 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -6860,6 +6860,14 @@ export function RecipientChipInput({
!isEditing && "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
)}
onContextMenu={isEditing ? undefined : (e) => handleContextMenu(e, i, chip)}
+ // A click opens the chip's menu too (address, Edit, Move to,
+ // Remove), as Gmail and Outlook do; it did nothing visible.
+ onClick={isEditing ? undefined : (e) => {
+ if (e.target instanceof Element && e.target.closest('button')) return;
+ // Not on to the field, whose click puts the caret in the input.
+ e.stopPropagation();
+ openChipMenu(i);
+ }}
data-invalid={isInvalid || undefined}
title={isInvalid ? t('invalid_recipient') : undefined}
role="listitem"
From af8c8a6ea4a97e64d2b7178e1f28b4eb23469a70 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:39:07 +0100
Subject: [PATCH 16/20] fix: keep toasts above the quick reply box
---
.../__tests__/viewer-quick-reply-words.test.tsx | 15 +++++++++++++++
components/email/email-viewer.tsx | 17 ++++++++++++++++-
components/ui/__tests__/toast-layout.test.tsx | 8 ++++++++
components/ui/toast.tsx | 3 +++
4 files changed, 42 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/viewer-quick-reply-words.test.tsx b/components/email/__tests__/viewer-quick-reply-words.test.tsx
index f980a7c93..2262a4ba1 100644
--- a/components/email/__tests__/viewer-quick-reply-words.test.tsx
+++ b/components/email/__tests__/viewer-quick-reply-words.test.tsx
@@ -85,3 +85,18 @@ describe('quick reply wording', () => {
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
});
});
+
+// compose-p2-17: the toasts lift over the box by its height (toast.tsx).
+describe('quick reply box and the toasts', () => {
+ it('marks the box and gives the page its height, and takes both back when it goes', async () => {
+ const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(96);
+ const { unmount } = render();
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
+
+ expect(document.querySelector('[data-quick-reply-box]')).not.toBeNull();
+ expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('96px');
+ unmount();
+ expect(document.documentElement.style.getPropertyValue('--quick-reply-height')).toBe('');
+ height.mockRestore();
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 80ffc1c4c..7cdfca9a8 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -3516,6 +3516,21 @@ export function EmailViewer({
// Not on spam: a reply confirms the address to the sender.
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
+ // Toasts sit above the quick reply box, as above a composer's footer
+ // (compose-p2-17): its height for toast.tsx, as it grows when focused.
+ useEffect(() => {
+ const box = quickReplyBoxRef.current;
+ if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return;
+ const root = document.documentElement;
+ const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
+ update();
+ const observer = new ResizeObserver(update);
+ observer.observe(box);
+ return () => {
+ observer.disconnect();
+ root.style.removeProperty('--quick-reply-height');
+ };
+ }, [showQuickReply]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6191,7 +6206,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
- {showQuickReply && (
+ {showQuickReply && (
{
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
});
+ // compose-p2-17: "Draft saved" covered the quick reply box.
+ it('sits over the quick reply box, by its height', () => {
+ render();
+ const container = toastContainer();
+ expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ });
+
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
render();
const container = toastContainer();
diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx
index 33ebeab9b..8de8acca0 100644
--- a/components/ui/toast.tsx
+++ b/components/ui/toast.tsx
@@ -475,10 +475,13 @@ export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose:
// lifted over a registered bottom bar (a form's Save/Cancel footer)
// and over an open composer's footer.
"fixed bottom-[max(1.25rem,calc(var(--bottom-controls-height,0px)+0.75rem))] end-5 z-[99999] flex flex-col gap-2.5 [html[data-composer-open]_&]:bottom-20",
+ // And over the reader's quick reply box (compose-p2-17).
+ "[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Phone: full width with 12px margins, above the bottom controls
// (the tab bar, or whichever bar registered with useBottomControls).
"max-sm:inset-x-3 max-sm:bottom-[calc(var(--bottom-controls-height,3.75rem)+0.75rem)]",
"max-sm:[html[data-composer-open]_&]:bottom-[max(5rem,calc(var(--bottom-controls-height,3.75rem)+0.75rem))]",
+ "max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]",
// Out of the way of the focused element: the top end corner.
dodge && "top-5 bottom-auto!",
held && "invisible"
From 4637f67b37b660c5193eb9690db3599f8ae6c8de Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:39:51 +0100
Subject: [PATCH 17/20] fix: draw the To field's focus ring whole
---
components/email/__tests__/composer-a11y.test.tsx | 6 ++++++
components/email/email-composer.tsx | 5 ++++-
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx
index 52783b107..437bb596a 100644
--- a/components/email/__tests__/composer-a11y.test.tsx
+++ b/components/email/__tests__/composer-a11y.test.tsx
@@ -253,6 +253,12 @@ describe('recipient fields (SR-06, CV-02)', () => {
expect(screen.getByRole('list', { name: 'to:' })).toBeInTheDocument();
});
+ // compose-p2-18: the ring outside the input was cut by the field's scroller.
+ it("draws the To field's keyboard focus ring inside it", () => {
+ render();
+ expect(screen.getByRole('combobox', { name: 'to:' })).toHaveClass('focus-inset');
+ });
+
it('labels the subject field', () => {
render();
expect(screen.getByLabelText('subject_label')).toBe(screen.getByTestId('composer-subject'));
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 615dfd910..834bc7120 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -6984,7 +6984,10 @@ export function RecipientChipInput({
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
- className="flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
+ // focus-inset: the keyboard focus ring goes inside the box. Drawn
+ // outside, the field's own scroller cut it to a line along the top
+ // and the end (compose-p2-18, a pop-out focuses To from a key).
+ className="focus-inset flex-1 min-w-[120px] border-0 outline-none h-7 text-sm bg-transparent text-foreground placeholder:text-muted-foreground [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
role="combobox"
aria-expanded={activeAutoField === field && autocompleteResults.length > 0}
aria-autocomplete="list"
From 850ebce944465e36b1dca0a435e55e8356391657 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:57:24 +0100
Subject: [PATCH 18/20] fix: open a resumed reply draft under its message
---
.../mail/__tests__/mail-app-drafts.test.tsx | 2 ++
components/mail/mail-app.tsx | 16 +++++++++++-----
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/components/mail/__tests__/mail-app-drafts.test.tsx b/components/mail/__tests__/mail-app-drafts.test.tsx
index 84411113b..980410488 100644
--- a/components/mail/__tests__/mail-app-drafts.test.tsx
+++ b/components/mail/__tests__/mail-app-drafts.test.tsx
@@ -144,6 +144,8 @@ describe('a message with a reply draft (compose-p2-6)', () => {
await screen.findByText('stub reply draft: my answer');
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
+ // Under the message it answers, as a new reply is, not over it.
+ expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull();
});
it('starts a new reply when the conversation has no reply to this message', async () => {
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 0492dba12..451e78a0c 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -306,6 +306,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
const [dockedSession, setDockedSession] = useState(null);
+ // A reply draft opened again from its message (compose-p2-6): it docks too.
+ const [resumedReplySession, setResumedReplySession] = useState(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -2873,7 +2875,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} : null;
}
if (!found) return false;
- await handleEditDraft(found.draft, found.source);
+ await handleEditDraft(found.draft, found.source, { underMessage: true });
return true;
};
@@ -2912,7 +2914,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
* undone or cancelled - the account it was sent from (a group or shared one
* for mail sent from its identity) and the login that sent it.
*/
- const handleEditDraft = async (email?: Email, source?: DraftSource) => {
+ // `underMessage`: a reply draft resumed from the message it answers docks
+ // under that message, as a new reply does (compose-p2-6).
+ const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => {
if (!client) return;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
@@ -2961,7 +2965,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
- setComposerSessionId(id => id + 1);
+ const nextSession = composerSessionRef.current + 1;
+ setComposerSessionId(nextSession);
+ setResumedReplySession(underMessage ? nextSession : null);
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
@@ -5053,7 +5059,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Decided as it opens and kept for the session: moving it would mount it
// anew when the message goes away (another folder opened meanwhile).
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
- && (pendingDraft?.mode ?? composerMode) !== 'compose';
+ && ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId);
const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
@@ -6355,7 +6361,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
replyComposerOpen={dockedReply}
replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
preview: replyDraft.draft.preview,
- onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source).catch(reportDraftOpenFailure),
+ onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure),
onDiscard: discardReplyDraft,
} : null}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
From f05af5a5906e32eb152015b3b7a8450da53eab2e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 12:57:25 +0100
Subject: [PATCH 19/20] fix: keep toasts above a reply draft's card
---
components/email/__tests__/viewer-reply-draft.test.tsx | 6 ++++++
components/email/email-viewer.tsx | 7 ++++---
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx
index 52fba0c2e..b3dd05f39 100644
--- a/components/email/__tests__/viewer-reply-draft.test.tsx
+++ b/components/email/__tests__/viewer-reply-draft.test.tsx
@@ -68,6 +68,12 @@ describe('a reply draft under its message', () => {
expect(onDiscard).toHaveBeenCalledTimes(1);
});
+ // compose-p2-17: toasts lift over it as over the quick reply box.
+ it('counts as the quick reply box for the toasts', async () => {
+ await renderViewer({ preview: 'my answer', onEdit: vi.fn(), onDiscard: vi.fn() });
+ expect(screen.getByTestId('reply-draft')).toHaveAttribute('data-quick-reply-box');
+ });
+
it('keeps the quick reply box when there is none', async () => {
await renderViewer(null);
expect(screen.queryByTestId('reply-draft')).toBeNull();
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 655ea042b..91e56c5da 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -3527,8 +3527,9 @@ export function EmailViewer({
// Toasts sit above the quick reply box, as above a composer's footer
// (compose-p2-17): its height for toast.tsx, as it grows when focused.
useEffect(() => {
+ // The reply draft's card takes the box's place, and its ref.
const box = quickReplyBoxRef.current;
- if (!showQuickReply || !box || typeof ResizeObserver === 'undefined') return;
+ if (!(showQuickReply || showReplyDraft) || !box || typeof ResizeObserver === 'undefined') return;
const root = document.documentElement;
const update = () => root.style.setProperty('--quick-reply-height', `${box.offsetHeight}px`);
update();
@@ -3538,7 +3539,7 @@ export function EmailViewer({
observer.disconnect();
root.style.removeProperty('--quick-reply-height');
};
- }, [showQuickReply]);
+ }, [showQuickReply, showReplyDraft]);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
@@ -6215,7 +6216,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* The reply already started on this message, as Gmail shows it. */}
{showReplyDraft && replyDraft && (
-
+
From 6ec867b4e08e11e438d819a6dd4159909acad086 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 13:16:44 +0100
Subject: [PATCH 20/20] fix: count a pop-out's undo send from the delay the
user picked
---
.../popout/__tests__/popout-compose-app.test.tsx | 10 ++++++++++
components/popout/popout-compose-app.tsx | 11 +++++++----
lib/outgoing/send-progress-toast.ts | 15 +++++++++++----
3 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index 737e95948..f1bfe6f70 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { usePluginStore } from '@/stores/plugin-store';
+import { useSettingsStore } from '@/stores/settings-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
@@ -614,6 +615,15 @@ describe('a send held for undo', () => {
expect(sent?.notice.releasesAt).toBe(sendAt);
});
+ // The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
+ it('never counts from more than the delay the user picked', async () => {
+ const before = useSettingsStore.getState().sendDelaySeconds;
+ useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
+ await sendFromWindow(5.8);
+ expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
+ useSettingsStore.setState({ sendDelaySeconds: before } as never);
+ });
+
// compose-24: the window sat with a greyed Send until the server answered.
it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
let answer!: () => void;
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index c8145e2d2..36e65f7c6 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -10,6 +10,8 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
@@ -68,9 +70,9 @@ interface ReopenTarget {
}
/** "Sending…" or "Sent", where the composer was, with the send icon. */
-function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) {
+function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
return (
-
+
{text}
{children}
@@ -134,11 +136,12 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
- const seconds = Math.max(1, Math.ceil((sendAt - now) / 1000));
+ // As the main window's toast counts: never past the delay the user picked.
+ const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
// Once Undo is over, "Sent" for the moment before the window goes, as the
// main window's toast says it.
return (
-
+
{!over && (