mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
fix: keep a quick reply's text when the user goes back instead of sending
This commit is contained in:
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user