From f9a04126d4955af391cb322ac16471df253bbf57 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:47:40 +0100 Subject: [PATCH] fix: keep a quick reply's text when the user goes back instead of sending --- .../viewer-quick-reply-back.test.tsx | 64 +++++++++++++++++++ components/email/email-viewer.tsx | 12 ++-- components/mail/mail-app.tsx | 13 ++-- 3 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 components/email/__tests__/viewer-quick-reply-back.test.tsx diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx new file mode 100644 index 000000000..c9f478d82 --- /dev/null +++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx @@ -0,0 +1,64 @@ +import { render, act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; + +// A quick reply that a pre-send question stopped (Back on the external +// recipient confirm) must keep the typed text; one that went out clears it. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = { + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Dave', email: 'org-dave@org-two.test' }], to: [{ name: 'Alice', email: 'org-alice@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: 'Plain body' } }, +} as unknown as Email; + +beforeEach(() => clearAllHooks()); + +async function typeAndSend(onQuickReply: (body: string) => Promise) { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement; + fireEvent.focus(box); + fireEvent.change(box, { target: { value: 'My answer' } }); + fireEvent.click(screen.getByRole('button', { name: 'send' })); + await waitFor(() => expect(onQuickReply).toHaveBeenCalledWith('My answer')); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + return box; +} + +describe('quick reply', () => { + it('keeps the text when nothing was sent', async () => { + const box = await typeAndSend(vi.fn(async () => false)); + expect(box.value).toBe('My answer'); + }); + + it('clears the text after a send', async () => { + const box = await typeAndSend(vi.fn(async () => true)); + expect(box.value).toBe(''); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 573d48422..504ff96a7 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -175,7 +175,8 @@ interface EmailViewerProps { onMarkAsRead?: (emailId: string, read: boolean, opts?: { automatic?: boolean }) => void; onSetTag?: (emailId: string, tagId: string | null) => void; onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void; - onQuickReply?: (body: string) => Promise; + /** Resolves false when nothing was sent (the user went back), so the text stays. */ + onQuickReply?: (body: string) => Promise; onMarkAsSpam?: () => void; onUndoSpam?: () => void; onMoveToMailbox?: (mailboxId: string) => void; @@ -1375,9 +1376,12 @@ export function EmailViewer({ if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return; setIsSendingQuickReply(true); try { - await onQuickReply(quickReplyText); - setQuickReplyText(""); - setIsQuickReplyFocused(false); + const sent = await onQuickReply(quickReplyText); + // Back on a pre-send question keeps what the user typed. + if (sent !== false) { + setQuickReplyText(""); + setIsQuickReplyFocused(false); + } } catch (error) { console.error("Failed to send quick reply:", error); // The text stays in the box, so say why it did not go out. diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index e5bfaf3c9..850b9a575 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -3847,8 +3847,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; }, [previewAttachment]); - const handleQuickReply = async (body: string) => { - if (!client || !selectedEmail) return; + // Resolves true once the reply went out (or was scheduled), false when it + // stopped before sending, so the quick-reply box keeps the text. + const handleQuickReply = async (body: string): Promise => { + if (!client || !selectedEmail) return false; // Quick reply follows the same addressing rules as the composer: Reply-To // over From, and for our own messages in a thread the original recipients @@ -3910,7 +3912,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { fromEmail: headerFromEmail, identityId: sendingIdentity?.id, }); - if (!prepared) return; + if (!prepared) return false; const { html: finalHtmlBody, text: finalBody } = prepared; const originalEmailId = selectedEmail.id; @@ -3920,7 +3922,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (sendDelaySeconds > 0) { if (!client.hasDelayedSend()) { const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm')); - if (!confirmed) return; + if (!confirmed) return false; } else { delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); } @@ -3970,7 +3972,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (result.scheduled) { await refreshScheduledMetadata(client); - return; + return true; } if (!refused) toast.success(t('notifications.email_sent')); @@ -4012,6 +4014,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } catch (error) { console.error('Quick reply sent, but refreshing the view failed:', error); } + return true; }; // Mountain View reads a message inside its conversation, as Gmail does: load