Merge branch 'feat/do-ux-p2-compose-drafts' into feat/do-ux-p2-compose-fix

This commit is contained in:
Linus Rath
2026-11-04 12:40:56 +01:00
8 changed files with 622 additions and 10 deletions
@@ -0,0 +1,54 @@
// compose-p2-6: on a phone the conversation listed a reply draft as an
// ordinary message, expanded at the top with Reply, Reply all and Forward.
// It is marked Draft and offers Edit and Discard instead.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const base = {
threadId: 't1', mailboxIds: { inbox: true }, to: [{ email: 'me@example.org' }],
subject: 's', size: 1, hasAttachment: false, htmlBody: [], textBody: [], attachments: [], bodyValues: {},
};
const received = { ...base, id: 'e1', keywords: { $seen: true }, from: [{ name: 'Ann', email: 'ann@t.example' }], receivedAt: '2026-11-01T10:00:00Z', preview: 'question' };
const draft = { ...base, id: 'd1', keywords: { $seen: true, $draft: true }, from: [{ name: 'Me', email: 'me@example.org' }], receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer' };
function renderView(handlers: Record<string, unknown>) {
render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [draft, received], latestEmail: draft } as never}
emails={[draft, received] as never}
onBack={() => {}}
onReply={() => {}}
onReplyAll={() => {}}
onForward={() => {}}
{...handlers}
/>,
);
}
describe('a draft in a phone conversation', () => {
it('is marked Draft, and the newest received message is the one open', () => {
renderView({});
expect(screen.getAllByTestId('conversation-draft-label')).toHaveLength(1);
// Only the received message is open, with its Reply.
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
expect(screen.queryByRole('button', { name: /email_viewer.edit_draft/ })).toBeNull();
});
it('offers Edit and Discard instead of Reply once opened', () => {
const onEditDraft = vi.fn();
const onDiscardDraft = vi.fn();
renderView({ onEditDraft, onDiscardDraft });
fireEvent.click(screen.getByText('my answer'));
expect(screen.getAllByRole('button', { name: /email_viewer.reply$/ })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: /email_viewer.edit_draft/ }));
fireEvent.click(screen.getByRole('button', { name: /email_composer.discard/ }));
expect(onEditDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
expect(onDiscardDraft).toHaveBeenCalledWith(expect.objectContaining({ id: 'd1' }));
});
});
@@ -0,0 +1,76 @@
import { render, act, fireEvent, screen } 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';
import { useAuthStore } from '@/stores/auth-store';
// compose-p2-6: a reply saved as a draft was nowhere to be seen on the
// message it answers. The reader shows it under the message, marked Draft,
// with Edit and Discard, where the quick reply box would be.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
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: () => [] }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
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: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@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();
useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never);
});
async function renderViewer(replyDraft: { preview?: string; onEdit: () => void; onDiscard: () => void } | null) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={vi.fn()} replyDraft={replyDraft} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
}
describe('a reply draft under its message', () => {
it('shows Draft with its text, Edit and Discard, instead of the quick reply box', async () => {
const onEdit = vi.fn();
const onDiscard = vi.fn();
await renderViewer({ preview: 'my answer', onEdit, onDiscard });
const card = screen.getByTestId('reply-draft');
expect(card.textContent).toContain('draft');
expect(card.textContent).toContain('my answer');
expect(screen.queryByPlaceholderText(/^quick_reply_to/)).toBeNull();
fireEvent.click(screen.getByTestId('reply-draft-edit'));
fireEvent.click(screen.getByTestId('reply-draft-discard'));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onDiscard).toHaveBeenCalledTimes(1);
});
it('keeps the quick reply box when there is none', async () => {
await renderViewer(null);
expect(screen.queryByTestId('reply-draft')).toBeNull();
expect(screen.getByPlaceholderText(/^quick_reply_to/)).toBeTruthy();
});
});
+32 -1
View File
@@ -304,6 +304,11 @@ interface EmailViewerProps {
mvInlineComposer?: React.ReactNode;
/** A reply or forward to this message is open under it: no quick reply box as well. */
replyComposerOpen?: boolean;
/**
* A reply to this message saved as a draft (compose-p2-6): shown under it,
* marked Draft, with Edit and Discard, in place of the quick reply box.
*/
replyDraft?: { preview?: string; onEdit: () => void; onDiscard: () => void } | null;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1376,6 +1381,7 @@ export function EmailViewer({
onReplyAllConversationEmail,
mvInlineComposer,
replyComposerOpen,
replyDraft,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1400,6 +1406,7 @@ export function EmailViewer({
const tWindows = useTranslations('pwaWindows');
const tComposer = useTranslations('email_composer');
const tWords = useTranslations('compose_words');
const tList = useTranslations('email_list');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
@@ -3514,7 +3521,8 @@ export function EmailViewer({
const latestSrcDocRef = useRef<string | null>(null);
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// Not on spam: a reply confirms the address to the sender.
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
const showReplyDraft = !!email && !!replyDraft && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled;
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !showReplyDraft && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
// Toasts sit above the quick reply box, as above a composer's footer
// (compose-p2-17): its height for toast.tsx, as it grows when focused.
@@ -6205,6 +6213,29 @@ ${printWatermark ? printWatermark.html : ''}
</div>
)}
{/* The reply already started on this message, as Gmail shows it. */}
{showReplyDraft && replyDraft && (
<div data-testid="reply-draft" className="bg-background border-t border-border px-6 mt-auto" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-muted/30 px-3 py-2">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-destructive shrink-0">
<File className="w-4 h-4" />
{tList('draft')}
</span>
<span dir="auto" className="min-w-0 flex-1 truncate text-sm text-muted-foreground">{replyDraft.preview}</span>
<span className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={replyDraft.onEdit} data-testid="reply-draft-edit">
<Pencil className="w-4 h-4" />
{t('edit_draft')}
</Button>
<Button variant="ghost" size="sm" className="gap-1.5" onClick={replyDraft.onDiscard} data-testid="reply-draft-discard">
<Trash2 className="w-4 h-4" />
{tComposer('discard')}
</Button>
</span>
</div>
</div>
)}
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{showQuickReply && (<div ref={quickReplyBoxRef} data-quick-reply-box="" className="bg-background border-t border-border px-6 mt-auto max-sm:scroll-mb-16" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
+60 -8
View File
@@ -49,6 +49,8 @@ import {
Download,
Eye,
CalendarClock,
Pencil,
Trash2,
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
@@ -91,6 +93,9 @@ interface ThreadConversationViewProps {
onReply?: (email: Email) => void;
onReplyAll?: (email: Email) => void;
onForward?: (email: Email) => void;
/** A draft in the conversation (compose-p2-6): Edit and Discard take Reply's place. */
onEditDraft?: (email: Email) => void;
onDiscardDraft?: (email: Email) => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
@@ -196,6 +201,8 @@ export function ThreadConversationView({
onReply,
onReplyAll,
onForward,
onEditDraft,
onDiscardDraft,
onDownloadAttachment,
blobSource,
onMarkAsRead,
@@ -219,12 +226,12 @@ export function ThreadConversationView({
if (emails.length > 0) {
const idsToExpand = new Set<string>();
// Always expand most recent
idsToExpand.add(emails[0].id);
// Always expand the most recent message; a draft isn't one.
idsToExpand.add((emails.find((e) => !e.keywords?.$draft) ?? emails[0]).id);
// Also expand all unread emails
emails.forEach(email => {
if (!email.keywords?.$seen) {
if (!email.keywords?.$seen && !email.keywords?.$draft) {
idsToExpand.add(email.id);
}
});
@@ -280,7 +287,9 @@ export function ThreadConversationView({
<div className="flex-1 overflow-y-auto">
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => (
{emails.map((email, index) => {
const draft = !!email.keywords?.$draft;
return (
<EmailCard
key={email.id}
email={email}
@@ -290,15 +299,19 @@ export function ThreadConversationView({
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => externalContent.allow(email.id)}
onTrustSender={externalContent.trustSender(email)}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
isDraft={draft}
onEditDraft={draft && onEditDraft ? () => onEditDraft(email) : undefined}
onDiscardDraft={draft && onDiscardDraft ? () => onDiscardDraft(email) : undefined}
onReply={!draft && onReply ? () => onReply(email) : undefined}
onReplyAll={!draft && onReplyAll ? () => onReplyAll(email) : undefined}
onForward={!draft && onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
blobSource={blobSource}
onMarkAsRead={onMarkAsRead}
mailboxRole={mailboxRole}
/>
))}
);
})}
</div>
</div>
</div>
@@ -317,6 +330,9 @@ interface EmailCardProps {
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
isDraft?: boolean;
onEditDraft?: () => void;
onDiscardDraft?: () => void;
onDownloadAttachment?: ConversationAttachmentHandler;
/** Routing captured when this conversation was opened. */
blobSource?: EmailBlobContext;
@@ -355,6 +371,9 @@ function EmailCard({
onReply,
onReplyAll,
onForward,
isDraft = false,
onEditDraft,
onDiscardDraft,
onDownloadAttachment,
blobSource,
onMarkAsRead,
@@ -410,6 +429,11 @@ function EmailCard({
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
<ReceivedLabelChip email={email} />
{isDraft && (
<span data-testid="conversation-draft-label" className="text-xs font-medium text-destructive shrink-0">
{t('email_list.draft')}
</span>
)}
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3.5 h-3.5" />
@@ -475,6 +499,34 @@ function EmailCard({
in the single reader's More panel on phones. Long names (Arabic
"Reply all") wrap to a second line instead of being cut. */}
<div className="px-4 pb-4 flex flex-wrap gap-2">
{onEditDraft && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onEditDraft();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Pencil className="w-4 h-4 me-1.5 shrink-0" />
{t("email_viewer.edit_draft")}
</Button>
)}
{onDiscardDraft && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDiscardDraft();
}}
className="flex-auto px-2 whitespace-nowrap"
>
<Trash2 className="w-4 h-4 me-1.5 shrink-0" />
{t("email_composer.discard")}
</Button>
)}
{onReply && (
<Button
variant="outline"
@@ -0,0 +1,158 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// Drafts reopen where people look for them (compose-p2-6, compose-p2-8):
// a click on a draft opens it in the composer, and Reply on a message with a
// reply draft opens that draft instead of starting a second one.
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const DRAFT_ROW = vi.hoisted(() => ({
id: 'd1', threadId: 't1', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true },
from: [{ email: 'a@example.org' }], to: [{ email: 'ann@example.org' }],
subject: 'Re: Plans', receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer', size: 10, hasAttachment: false,
}));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ mode, initialData }: { mode: string; initialData?: { draftId?: string } | null }) => (
<div data-testid="stub-composer" data-mode={mode} data-draft={initialData?.draftId ?? ''}>stub composer</div>
),
}));
vi.mock('@/components/search/mv-search-form', () => ({
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => (
<div data-testid="viewer">
message viewer
<button onClick={() => onReply()}>stub reply</button>
{replyDraft && <button onClick={replyDraft.onEdit}>stub reply draft: {replyDraft.preview}</button>}
</div>
),
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => device.current,
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({
EmailList: ({ onEmailSelect }: { onEmailSelect: (email: unknown) => void }) => (
<button onClick={() => onEmailSelect(DRAFT_ROW)}>stub draft row</button>
),
}));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
const drafts = { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 1, unreadEmails: 0 } as Mailbox;
const MESSAGE = {
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, messageId: ['plans@example.org'],
} as never;
const FULL_DRAFT = {
...DRAFT_ROW, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], bodyValues: { 1: { value: 'my answer' } },
inReplyTo: ['plans@example.org'],
};
type Client = Record<string, unknown> & { getEmail: ReturnType<typeof vi.fn> };
let client: Client;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
getEmail: vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT } : null)),
getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m1', 'd1'] })),
getSomeEmails: vi.fn(async () => [{ ...(MESSAGE as object) }, { ...DRAFT_ROW }]),
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
useEmailStore.setState({
emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, drafts],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
selectedEmailKeys: new Set(), unreadSection: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
} as never);
useToastStore.getState().clearToasts();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
useToastStore.getState().clearToasts();
});
describe('a draft in the list (compose-p2-8)', () => {
it('opens in the composer on a click', async () => {
render(<MailApp />);
fireEvent.click(await screen.findByText('stub draft row'));
const composer = await screen.findByTestId('stub-composer');
expect(composer).toHaveAttribute('data-draft', 'd1');
expect(composer).toHaveAttribute('data-mode', 'compose');
});
});
describe('a message with a reply draft (compose-p2-6)', () => {
it('shows the draft under the message', async () => {
render(<MailApp />);
expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument();
});
it('opens that draft on Reply instead of a new reply', async () => {
render(<MailApp />);
await screen.findByText('stub reply draft: my answer');
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
});
it('starts a new reply when the conversation has no reply to this message', async () => {
client.getEmail = vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT, inReplyTo: ['other@example.org'] } : null));
render(<MailApp />);
await waitFor(() => expect(client.getEmail).toHaveBeenCalled());
expect(screen.queryByText(/stub reply draft/)).toBeNull();
fireEvent.click(screen.getByText('stub reply'));
await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-mode', 'reply'));
expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', '');
});
});
+130 -1
View File
@@ -43,6 +43,7 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { toast } from "@/stores/toast-store";
import { discardWithUndo } from "@/lib/compose/draft-close-toast";
import { findReplyDraft, threadDrafts } from "@/lib/reply-drafts";
import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { startSendProgress } from "@/lib/outgoing/send-progress-toast";
@@ -366,6 +367,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threadId: string; emailId: string; startedAt: number; promise: Promise<Email[] | null>;
} | null>(null);
const mvConversationOpenSeqRef = useRef(0);
// compose-p2-6: a reply to the open message saved as a draft. The reader
// shows it under the message, and Reply opens it again. The conversation
// fetch notes the thread's drafts; a scan reads them fresh after a composer
// closed or a draft went.
const [replyDraft, setReplyDraft] = useState<{ emailId: string; draft: Email; source: DraftSource } | null>(null);
const [replyDraftScan, setReplyDraftScan] = useState<{ emailId: string; n: number } | null>(null);
const conversationDraftsRef = useRef<{ threadId: string; emailId: string; drafts: Email[] } | null>(null);
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
@@ -2844,8 +2852,35 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// automatic sign-out it is opened again and saves them itself.
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
// Reply on a message with a reply draft opens that draft (compose-p2-6).
// After a composer closed (it may have saved that very draft under a new
// id) the drafts are read fresh.
const resumeReplyDraft = async (open: Email | null, composerWasOpen: boolean): Promise<boolean> => {
if (!client || !open || open.keywords?.$draft || mvShell || isScheduledView) return false;
let found = replyDraft?.emailId === open.id ? replyDraft : null;
// A phone's conversation holds its drafts in full.
const phoneConversation = isMobile && conversationEmails.some((e) => e.id === open.id);
if (composerWasOpen || phoneConversation) {
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
if (!threadClient) return false;
const drafts = phoneConversation && !composerWasOpen
? conversationEmails
: await threadDrafts(threadClient, open.threadId, accountId).catch(() => []);
const draft = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
found = draft ? {
emailId: open.id, draft,
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
} : null;
}
if (!found) return false;
await handleEditDraft(found.draft, found.source);
return true;
};
const handleReply = async (draftText?: string) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail);
@@ -3139,7 +3174,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]);
const handleReplyAll = async (draftText?: string) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
@@ -4737,6 +4774,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
conversationDraftsRef.current = {
threadId: ref.threadId, emailId: ref.id,
drafts: annotated.filter((e) => e.id !== ref.id && e.keywords?.$draft),
};
return annotated
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
@@ -4766,6 +4807,62 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh
}, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]);
// The open message's reply draft (compose-p2-6). Not in Mountain View, which
// has its own conversation, nor on a phone, whose conversation lists drafts.
const replyDraftMessageIds = String(selectedEmail?.messageId ?? '');
useEffect(() => {
const open = selectedEmail;
if (!client || !open || open.keywords?.$draft || mvShell || isMobile || isScheduledView || !replyDraftMessageIds) {
setReplyDraft(null);
return;
}
const fresh = replyDraftScan?.emailId === open.id;
const noted = conversationDraftsRef.current;
const known = noted && noted.threadId === open.threadId && noted.emailId === open.id ? noted.drafts : null;
if (!fresh && !known) {
// The conversation isn't here yet: keep what this message had.
setReplyDraft((prev) => (prev?.emailId === open.id ? prev : null));
return;
}
if (!fresh && known?.length === 0) {
setReplyDraft(null);
return;
}
const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
if (!threadClient) return;
let cancelled = false;
void (async () => {
const drafts = fresh ? await threadDrafts(threadClient, open.threadId, accountId).catch(() => []) : known ?? [];
const found = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
if (cancelled) return;
setReplyDraft(found ? {
emailId: open.id,
draft: { ...found, sourceClientAccountId: open.sourceClientAccountId, sourceAccountId: open.sourceAccountId },
source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
} : null);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message and what can change its drafts
}, [selectedEmail?.id, replyDraftMessageIds, mvConversationResult, replyDraftScan, client, mvShell, isMobile, isScheduledView]);
// A composer closing may have saved, sent or discarded a reply draft.
const scanReplyDrafts = () => {
const open = useEmailStore.getState().selectedEmail;
if (open) setReplyDraftScan((prev) => ({ emailId: open.id, n: (prev?.n ?? 0) + 1 }));
};
const composerWasOpenRef = useRef(showComposer);
useEffect(() => {
if (composerWasOpenRef.current && !showComposer) scanReplyDrafts();
composerWasOpenRef.current = showComposer;
}, [showComposer]);
const discardReplyDraft = () => {
const kept = replyDraft;
if (!kept) return;
setReplyDraft(null);
discardWithUndo(() => {
void handleDiscardDraft(kept.draft.id, kept.source).then(scanReplyDrafts);
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setReplyDraft(kept));
};
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
setMvConversationResult(prev => prev ? {
...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e),
@@ -5171,7 +5268,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Handle reply from conversation view
const handleConversationReply = async (email: Email) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
@@ -5184,7 +5283,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationReplyAll = async (email: Email) => {
const composerWasOpen = showComposer;
await closeOpenComposer();
if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
@@ -5332,6 +5433,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const outcome = await handleEmailSend(data);
setPendingDraft(null);
setComposerPrefill(null);
scanReplyDrafts();
return outcome;
}}
onSendFailed={(state) => {
@@ -5855,7 +5957,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onRescheduleScheduled={(email) => setRescheduleTarget(email)}
onCancelScheduledSend={handleCancelScheduledSend}
onEmailSelect={handleEmailSelect}
// A click (or Enter) on a draft opens it in the composer, as
// Gmail and Outlook do (compose-p2-8). j/k still read it.
// Mountain View and Scheduled keep their own ways.
onEmailSelect={!mvShell && !isScheduledView && !isEmbedded ? (email) => {
if (email.keywords?.$draft) void handleEditDraft(email).catch(reportDraftOpenFailure);
else void handleEmailSelect(email);
} : handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
@@ -6089,6 +6197,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onReply={handleConversationReply}
onReplyAll={handleConversationReplyAll}
onForward={handleConversationForward}
onEditDraft={(draft) => {
void handleEditDraft(draft, {
accountId: conversationBlobSource?.accountId,
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
}).catch(reportDraftOpenFailure);
}}
onDiscardDraft={(draft) => {
const before = conversationEmails;
setConversationEmails(before.filter((e) => e.id !== draft.id));
discardWithUndo(() => {
void handleDiscardDraft(draft.id, {
accountId: conversationBlobSource?.accountId,
localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
});
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setConversationEmails(before));
}}
blobSource={conversationBlobSource}
onDownloadAttachment={handleDownloadConversationAttachment}
onMarkAsRead={async (emailId, read) => {
@@ -6211,6 +6335,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} : undefined}
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
replyComposerOpen={dockedReply}
replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
preview: replyDraft.draft.preview,
onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source).catch(reportDraftOpenFailure),
onDiscard: discardReplyDraft,
} : null}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts';
import type { Email } from '@/lib/jmap/types';
const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string };
const draft = (id: string, extra: Partial<Email> = {}) => ({
id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra,
}) as Email;
describe('answersMessage', () => {
it('matches a draft whose In-Reply-To names the message, brackets or not', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['<abc@example.org>'] }), message)).toBe(true);
expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true);
});
it('leaves out a reply to another message, and a sent message', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false);
expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false);
});
});
describe('findReplyDraft', () => {
it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => {
const full: Record<string, Email> = {
d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }),
d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }),
d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }),
};
const getEmail = vi.fn(async (id: string) => full[id] ?? null);
const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email);
const found = await findReplyDraft(rows, message, getEmail);
expect(found?.id).toBe('d2');
expect(getEmail).toHaveBeenCalledWith('d3');
expect(getEmail).not.toHaveBeenCalledWith('d1');
});
it('finds nothing for a message without a Message-ID', async () => {
const getEmail = vi.fn();
expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull();
expect(getEmail).not.toHaveBeenCalled();
});
});
describe('threadDrafts', () => {
it('returns the drafts of a conversation, and asks nothing more of a single message', async () => {
const client = {
getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })),
getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]),
};
expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']);
expect(await threadDrafts(client, 't2', 'acc')).toEqual([]);
expect(client.getSomeEmails).toHaveBeenCalledTimes(1);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { Email } from '@/lib/jmap/types';
import { stripMessageIdBrackets } from '@/lib/email-threading';
/**
* A reply saved as a draft, found from the message it answers (compose-p2-6).
* The reader marks that message with it, and Reply opens it again instead of
* starting a second draft, as Gmail and Outlook do.
*/
/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */
function messageIdsOf(email: Pick<Email, 'messageId'>): string[] {
const raw = email.messageId as unknown;
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean);
}
/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */
export function answersMessage(draft: Pick<Email, 'inReplyTo' | 'keywords'>, email: Pick<Email, 'messageId'>): boolean {
if (!draft.keywords?.$draft) return false;
const ids = messageIdsOf(email);
if (ids.length === 0) return false;
return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id)));
}
const newestFirst = (a: Pick<Email, 'receivedAt'>, b: Pick<Email, 'receivedAt'>) =>
(b.receivedAt ?? '').localeCompare(a.receivedAt ?? '');
/**
* The newest of a conversation's drafts that answers `email`, in full. List
* rows carry no In-Reply-To, so a draft without it is read once more through
* `getEmail`; one that has it (a full fetch) is taken as it is.
*/
export async function findReplyDraft(
drafts: readonly Email[],
email: Pick<Email, 'id' | 'messageId'>,
getEmail: (id: string) => Promise<Email | null>,
): Promise<Email | null> {
if (messageIdsOf(email).length === 0) return null;
const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst);
for (const candidate of candidates) {
const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null);
if (full && answersMessage(full, email)) return full;
}
return null;
}
/** A conversation's drafts, read fresh (after a composer closed or a draft went). */
export async function threadDrafts(
client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise<Email[]> },
threadId: string,
accountId: string | undefined,
): Promise<Email[]> {
const thread = await client.getThread(threadId, accountId);
const ids = thread?.emailIds ?? [];
if (ids.length < 2) return [];
const members = await client.getSomeEmails(ids, accountId);
return members.filter((e) => e.keywords?.$draft);
}