diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index 363ba58c4..737e95948 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -614,9 +614,38 @@ describe('a send held for undo', () => {
expect(sent?.notice.releasesAt).toBe(sendAt);
});
+ // 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;
+ useEmailStore.setState({ sendEmail: vi.fn(() => new Promise((resolve) => { answer = () => resolve({}); })) } as never);
+ sendAt = new Date(Date.now() + 5000).toISOString();
+ window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
+ let done: Promise = Promise.resolve();
+ act(() => { done = (composer as unknown as Sending).onSend(DATA); });
+ expect(screen.getByTestId('popout-sending').textContent).toMatch(/sending$/);
+ await act(async () => { answer(); await done; });
+ });
+
+ it('shows the composer again with its text when the send fails', async () => {
+ useEmailStore.setState({ sendEmail: vi.fn(async () => { throw new Error('refused'); }) } as never);
+ sendAt = new Date(Date.now() + 5000).toISOString();
+ window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
+ render();
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
+ await act(async () => {
+ await expect((composer as unknown as Sending).onSend(DATA)).rejects.toThrow('refused');
+ });
+ expect(screen.queryByTestId('popout-sending')).toBeNull();
+ expect(screen.getByTestId('composer').closest('.hidden')).toBeNull();
+ });
+
it('closes once the undo time is over', async () => {
await sendFromWindow(1.2);
expect(closeSelf).not.toHaveBeenCalled();
+ // As the main window's toast: "Sent" once Undo is over (compose-24).
+ await waitFor(() => expect(screen.getByTestId('popout-sending').textContent).toMatch(/(^|\.)sent$/), { timeout: 3000 });
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
});
@@ -631,7 +660,8 @@ describe('a send held for undo', () => {
it('a message scheduled for later still goes to the main window, and the window closes', async () => {
await sendFromWindow(3600);
- expect(screen.queryByTestId('popout-sending')).toBeNull();
+ // "Sending…" may still show as the window goes, but no Undo here.
+ expect(screen.queryByRole('button', { name: 'undo_send' })).toBeNull();
expect(closeSelf).toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index b86aa9cca..52adf6bf1 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -67,6 +67,17 @@ interface ReopenTarget {
ownerAccountId?: string;
}
+/** "Sending…" or "Sent", where the composer was, with the send icon. */
+function SendStatus({ text, children }: { text: string; children?: React.ReactNode }) {
+ return (
+
+
+
{text}
+ {children}
+
+ );
+}
+
/**
* A send held for undo, shown in the window it was sent from: that's where the
* user is looking, and quick compose is used with the main window out of sight.
@@ -79,6 +90,7 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onGone: () => void;
}) {
const tViewer = useTranslations("email_viewer");
+ const tSend = useTranslations("compose_send");
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false);
const sendAt = Date.parse(held.sendAt);
@@ -122,17 +134,17 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
- const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
+ const seconds = Math.max(1, Math.ceil((sendAt - now) / 1000));
+ // Once Undo is over, "Sent" for the moment before the window goes, as the
+ // main window's toast says it.
return (
-
-
-
{tViewer("sending_in", { seconds })}
+
{!over && (
)}
-
+
);
}
@@ -320,7 +332,11 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
: t("email_composer.new_message"),
);
- const handleSend = useCallback(async (data: SendData) => {
+ // "Sending…" from the moment Send goes through, as the main window says it
+ // (compose-24); the composer waited greyed out for the server before. It
+ // stays mounted behind, and comes back with the text if the send fails.
+ const [sending, setSending] = useState(false);
+ const sendFromWindow = useCallback(async (data: SendData) => {
if (!client) return false;
const sendClient = data.localAccountId
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
@@ -386,6 +402,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
}
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
+ const handleSend = useCallback(async (data: SendData) => {
+ if (!client) return false;
+ if (!data.delayedUntil) setSending(true);
+ try {
+ return await sendFromWindow(data);
+ } catch (error) {
+ setSending(false);
+ throw error;
+ }
+ }, [client, sendFromWindow]);
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
if (!client) return;
@@ -507,6 +533,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
if (held) return ;
return (
+ <>
+ {sending && }
+