fix: keep a quick reply's text when the user goes back instead of sending

This commit is contained in:
Linus Rath
2026-10-28 03:47:40 +01:00
parent 0fcfd29d6a
commit f9a04126d4
3 changed files with 80 additions and 9 deletions
@@ -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<boolean | void>) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
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('');
});
});
+8 -4
View File
@@ -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<void>;
/** Resolves false when nothing was sent (the user went back), so the text stays. */
onQuickReply?: (body: string) => Promise<boolean | void>;
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.
+8 -5
View File
@@ -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<boolean> => {
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