diff --git a/components/email/__tests__/thread-conversation-drafts.test.tsx b/components/email/__tests__/thread-conversation-drafts.test.tsx new file mode 100644 index 000000000..80bc7f7ed --- /dev/null +++ b/components/email/__tests__/thread-conversation-drafts.test.tsx @@ -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) { + render( + {}} + 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' })); + }); +}); diff --git a/components/email/__tests__/viewer-reply-draft.test.tsx b/components/email/__tests__/viewer-reply-draft.test.tsx new file mode 100644 index 000000000..52fba0c2e --- /dev/null +++ b/components/email/__tests__/viewer-reply-draft.test.tsx @@ -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) => + 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(); + 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(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7cdfca9a8..655ea042b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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 : ''} )} + {/* The reply already started on this message, as Gmail shows it. */} + {showReplyDraft && replyDraft && ( +
+
+ + + {tList('draft')} + + {replyDraft.preview} + + + + +
+
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} {showQuickReply && (
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index eb6c91992..d949f8b25 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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(); - // 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({
- {emails.map((email, index) => ( + {emails.map((email, index) => { + const draft = !!email.keywords?.$draft; + return ( 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} /> - ))} + ); + })}
@@ -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({ + {isDraft && ( + + {t('email_list.draft')} + + )} {email.isScheduled && ( @@ -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. */}
+ {onEditDraft && ( + + )} + {onDiscardDraft && ( + + )} {onReply && ( , +})); +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => ( +
+ message viewer + + {replyDraft && } +
+ ), +})); +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 }) => ( + + ), +})); + +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 & { getEmail: ReturnType }; +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(); + 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(); + expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument(); + }); + + it('opens that draft on Reply instead of a new reply', async () => { + render(); + 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(); + 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', ''); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index e06b393e6..b9607a8bb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; } | 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([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(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 => { + 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 ? {composerNode} : 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} diff --git a/lib/__tests__/reply-drafts.test.ts b/lib/__tests__/reply-drafts.test.ts new file mode 100644 index 000000000..47454c9a3 --- /dev/null +++ b/lib/__tests__/reply-drafts.test.ts @@ -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 = {}) => ({ + 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: [''] }), 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 = { + 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); + }); +}); diff --git a/lib/reply-drafts.ts b/lib/reply-drafts.ts new file mode 100644 index 000000000..08d2d9b33 --- /dev/null +++ b/lib/reply-drafts.ts @@ -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): 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: Pick): 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, b: Pick) => + (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, + getEmail: (id: string) => Promise, +): Promise { + 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 }, + threadId: string, + accountId: string | undefined, +): Promise { + 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); +}