diff --git a/components/email/__tests__/composer-a11y.test.tsx b/components/email/__tests__/composer-a11y.test.tsx index 9534b61b9..3a9d3f39f 100644 --- a/components/email/__tests__/composer-a11y.test.tsx +++ b/components/email/__tests__/composer-a11y.test.tsx @@ -138,8 +138,9 @@ vi.mock('@/stores/template-store', () => { return { useTemplateStore: hook }; }); +const toastError = vi.hoisted(() => vi.fn()); vi.mock('@/stores/toast-store', () => ({ - toast: { info: () => {}, error: () => {}, success: () => {} }, + toast: { info: () => {}, error: toastError, success: () => {} }, })); vi.mock('@/lib/plugin-hooks', () => ({ @@ -394,14 +395,17 @@ describe('attachment uploads (SR-16)', () => { expect(bar).toHaveAttribute('aria-valuetext', 'attachment_progress:45'); }); - it('says in words that an upload failed and announces it', async () => { + // The error toast is a live region of its own; the composer saying it + // too would read the failure twice. + it('says in words that an upload failed and reports it once', async () => { uploadClient(() => Promise.reject(new Error('boom'))); render(); await attach(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' })); await waitFor(() => expect(screen.getByText('attachment_upload_failed')).toBeInTheDocument()); expect(screen.queryByRole('progressbar')).toBeNull(); - expect(liveRegion()).toHaveTextContent('upload_failed:report.pdf'); + expect(toastError).toHaveBeenCalledWith('upload_failed:report.pdf'); + expect(liveRegion()).not.toHaveTextContent('upload_failed'); }); }); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index e934e97ee..1ce1de0e3 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { EmailComposer } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; +import { LIVE_REGION_IDS } from '@/lib/announce'; // ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── @@ -322,6 +323,28 @@ describe('composer close guard with a follow-up', () => { await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1)); }); + it('tells screen readers the draft was saved when the user saves, not on autosave', async () => { + const createDraft = vi.fn().mockResolvedValue('draft-9'); + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + const polite = () => document.getElementById(LIVE_REGION_IDS.polite); + try { + const { requestCloseRef, afterClose } = renderWithCloseRef(); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await new Promise((r) => setTimeout(r, 200)); + expect(polite()?.textContent ?? '').not.toContain('draft_saved'); + + fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } }); + act(() => requestCloseRef.current!(afterClose)); + fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save')); + + await waitFor(() => expect(polite()?.textContent).toContain('draft_saved')); + } finally { + useAuthStore.setState({ client: null }); + } + }); + it('drops the follow-up when the save fails and the rescue takes over', async () => { // When the server refuses the draft, the composer text survives only via // the unmount stash into the continue-draft slot (#702). That rescue diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0e8749ec0..6d0f7e011 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -141,6 +141,7 @@ import { isValidEmail } from "@/lib/validation"; import { composeTextStyle, composeTextStyleCss } from "@/components/email/font-size"; import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions"; import { describeRecipientChanges, recipientDisplayName, type RecipientChange, type RecipientFields } from "@/lib/recipient-announcements"; +import { announce as announceStatus } from "@/lib/announce"; import { normalizeCid } from "@/lib/attachment-visibility"; import { RichTextEditor } from "@/components/email/rich-text-editor"; import { isRecipientMentionActive } from "@/components/email/recipient-mention"; @@ -2277,8 +2278,8 @@ export function EmailComposer({ } catch (error) { if (controller?.signal.aborted) continue; debug.error(`Failed to upload ${file.name}:`, error); + // The error toast reads itself out (components/ui/toast.tsx). toast.error(t('upload_failed', { filename: file.name })); - announce(t('upload_failed', { filename: file.name })); setAttachments(prev => prev.map(att => @@ -2289,7 +2290,7 @@ export function EmailComposer({ ); } } - }, [client, t, plainTextMode, announce]); + }, [client, t, plainTextMode]); const handleImageUpload = useCallback(async ( file: File, @@ -2745,13 +2746,17 @@ export function EmailComposer({ // racing two `Email/set { destroy, create }` requests against the same // draftId, which left orphan drafts and (when EmailSubmission failed) // looked like "send didn't happen" (#303). - const saveDraft = (opts: { auto?: boolean } = {}): Promise => { + const saveDraft = (opts: { auto?: boolean; announce?: boolean } = {}): Promise => { const previous = inflightSaveRef.current; const promise = (async (): Promise => { if (previous) { try { await previous; } catch { /* prior failure already reported */ } } - return saveDraftOnce(!!opts.auto); + const id = await saveDraftOnce(!!opts.auto); + // Only the save the user asked for is read out: autosave would say it + // every minute, and the save before sending is followed by "Sent". + if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved')); + return id; })(); inflightSaveRef.current = promise; promise.finally(() => { @@ -3777,7 +3782,7 @@ export function EmailComposer({ // close attempt just re-opened this dialog - Discard was the only way out // (#702). Requests carry a deadline now, so a broken save resolves. try { - await saveDraft(); + await saveDraft({ announce: true }); if (saveFailedRef.current) { // The server never took the draft. Clearing stateRef here would drop the // message on the floor with nothing but a toast, so leave the text in