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 ( +
+
+ ); +} + /** * 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 ( -
-
+ ); } @@ -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 && } +
+
+ ); }