mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-p2-compose-drafts' into feat/do-ux-p2-compose-fix
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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', '');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user