fix: show Sending… and Sent in pop-out and quick compose windows straight after Send

The pop-out test for a message scheduled for later no longer expects no sending view at all: the window now says "Sending…" until it closes, so it checks that no Undo is offered there.
This commit is contained in:
Linus Rath
2026-11-04 11:45:42 +01:00
parent 5f0ad627f5
commit de401b4bbf
2 changed files with 68 additions and 7 deletions
@@ -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(<PopoutComposeApp />);
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
let done: Promise<unknown> = 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(<PopoutComposeApp />);
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<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
+37 -6
View File
@@ -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 (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{text}</p>
{children}
</div>
);
}
/**
* 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 (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
<SendStatus text={over && !busy ? tSend("sent") : tViewer("sending_in", { seconds })}>
{!over && (
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
</Button>
)}
</div>
</SendStatus>
);
}
@@ -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 <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
return (
<>
{sending && <SendStatus text={t("compose_send.sending")} />}
<div className={sending ? "hidden" : "contents"}>
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
mode={mode}
@@ -527,6 +556,8 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
className="min-h-0 flex-1"
/>
</ErrorBoundary>
</div>
</>
);
}