diff --git a/components/email/__tests__/mv-conversation-expand.test.tsx b/components/email/__tests__/mv-conversation-expand.test.tsx index 21056b2e4..e2994bb48 100644 --- a/components/email/__tests__/mv-conversation-expand.test.tsx +++ b/components/email/__tests__/mv-conversation-expand.test.tsx @@ -131,7 +131,7 @@ describe('Mountain View conversation folding (#1222)', () => { />, ); const header = container.querySelector('[data-mv-conversation-expanded][data-email-id="m1"] [data-mv-conversation-header]')!; - fireEvent.click(header.querySelector('button')!); + fireEvent.click(header.querySelector('button[aria-label]')!); expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'm1' })); expect(expandedIds(container)).toEqual(['m1', 'm2']); diff --git a/components/email/__tests__/mv-conversation-phone.test.tsx b/components/email/__tests__/mv-conversation-phone.test.tsx new file mode 100644 index 000000000..ec015e2b1 --- /dev/null +++ b/components/email/__tests__/mv-conversation-phone.test.tsx @@ -0,0 +1,116 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +// #1224: on the phone Mountain View showed threads in a separate view, newest +// first and without the to/cc/bcc details. The phone viewer now draws the same +// conversation as the desktop one: oldest first, every message foldable, and +// each unfolded message has its own details. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), + useMediaQuery: () => false, +})); + +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: () => [], +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes: Mailbox[] = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function mail(n: number, overrides: Partial = {}): Email { + return { + id: `m${n}`, + blobId: `b${n}`, + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }], + to: [{ name: 'Me', email: 'me@example.com' }], + subject: 'Subject', + receivedAt: `2026-01-0${n}T10:00:00Z`, + preview: `Preview ${n}`, + hasAttachment: false, + size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [], + attachments: [], + bodyValues: { '1': { value: `Body ${n}`, isTruncated: false } }, + ...overrides, + } as unknown as Email; +} + +const cardIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-card], [data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id')); + +describe('Mountain View conversation on the phone (#1224)', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: -1 }); + }); + + it('draws the conversation oldest first around the open message', () => { + const open = mail(3); + const { container } = render( + , + ); + expect(cardIds(container)).toEqual(['m1', 'm2']); + const before = container.querySelector('[data-mv-conversation-before]')!; + const openHeader = container.querySelector('[data-mv-open-header-mobile]')!; + expect(before.compareDocumentPosition(openHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('shows cc and bcc of an unfolded message in its details', () => { + const older = mail(1, { + cc: [{ name: 'Carol', email: 'carol@example.com' }], + bcc: [{ name: 'Dave', email: 'dave@example.com' }], + }); + const open = mail(2); + const { container } = render( + , + ); + fireEvent.click(screen.getByText('Sender 1')); + expect(container.querySelector('[data-mv-conversation-details]')).toBeNull(); + + fireEvent.click(container.querySelector('[data-mv-conversation-details-toggle]')!); + const details = container.querySelector('[data-mv-conversation-details]') as HTMLElement; + expect(details).not.toBeNull(); + expect(details.textContent).toContain('Carol '); + expect(details.textContent).toContain('Dave '); + // The toggle does not fold the message it sits in. + expect(cardIds(container)).toEqual(['m1']); + expect(container.querySelector('[data-mv-conversation-expanded]')).not.toBeNull(); + }); + + it('folds the open message from its phone sender line', () => { + const open = mail(2); + const { container } = render( + , + ); + fireEvent.click(container.querySelector('[data-mv-open-header-mobile]')!); + expect(container.querySelector('[data-mv-open-folded]')).not.toBeNull(); + expect(cardIds(container)).toEqual(['m1', 'm2']); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index df972f2eb..340bd02c9 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -256,6 +256,46 @@ describe('ThreadListItem row content', () => { }); }); +describe('ThreadListItem opening a thread on the phone', () => { + const twoMessageThread = () => groupEmailsByThread([ + makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }), + makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }), + ])[0]; + + function tap(interfaceLayout: 'mountain-view' | 'default') { + useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' }); + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); + const onEmailSelect = vi.fn(); + const onOpenConversation = vi.fn(); + render( + {}} + onEmailSelect={onEmailSelect} + onOpenConversation={onOpenConversation} + />, + ); + fireEvent.click(screen.getByText('Test Subject')); + return { onEmailSelect, onOpenConversation }; + } + + // #1224: the phone thread view lists newest first and has no details, so + // Mountain View opens the thread in the viewer, as on the desktop. + it('opens the latest message in the viewer in Mountain View', () => { + const { onEmailSelect, onOpenConversation } = tap('mountain-view'); + expect(onOpenConversation).not.toHaveBeenCalled(); + expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'newer' })); + }); + + it('keeps the phone thread view in the default layout', () => { + const { onEmailSelect, onOpenConversation } = tap('default'); + expect(onOpenConversation).toHaveBeenCalledTimes(1); + expect(onEmailSelect).not.toHaveBeenCalled(); + }); +}); + describe('ThreadListItem Mountain View phone row layout', () => { it('sets the star at the end of the preview line, not under the time', () => { useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e57c39f7b..f184fea57 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -678,7 +678,7 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string { * One message of a Mountain View conversation, folded to its sender, date and * snippet. Clicking it unfolds the message in place. */ -function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h' }) { +function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) { const sender = email.from?.[0]; const unread = !email.keywords?.$seen; return ( @@ -687,7 +687,7 @@ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpe onClick={() => onOpen?.(email)} data-mv-conversation-card="" data-email-id={email.id} - className="w-full flex items-start gap-4 px-6 py-3 border-t border-border text-start hover:bg-muted/40 transition-colors" + className={cn("w-full flex items-start py-3 border-t border-border text-start hover:bg-muted/40 transition-colors", compact ? "gap-3 px-4" : "gap-4 px-6")} > @@ -696,7 +696,7 @@ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpe {sender?.name || sender?.email || ''} - {mvSenderDate(email.receivedAt, timeFormat)} + {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)} {cleanPreview(email.preview)} @@ -711,6 +711,39 @@ function isInteractiveClick(target: EventTarget | null, container: HTMLElement): return !!el && el !== container && container.contains(el); } +/** + * The sender, recipients, dates and ids of an unfolded conversation message, + * as the open message's details panel lists them. + */ +function MvConversationDetails({ email, timeFormat, className }: { email: Email; timeFormat: '12h' | '24h'; className?: string }) { + const t = useTranslations('email_viewer'); + const addresses = (list?: { name?: string | null; email?: string | null }[]) => + (list ?? []).map((a) => (a.name && a.email ? `${a.name} <${a.email}>` : a.email || a.name || '')).join(', '); + const fullDate = (iso: string) => + formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const rows: [string, string | undefined][] = [ + [t('from'), addresses(email.from)], + [t('reply_to_label').replace(':', ''), addresses(email.replyTo)], + [t('to'), addresses(email.to)], + [t('cc'), addresses(email.cc)], + [t('bcc'), addresses(email.bcc)], + [t('details.sent'), email.sentAt ? fullDate(email.sentAt) : undefined], + [t('details.received'), fullDate(email.receivedAt)], + [t('subject'), email.subject], + [t('headers.message_id'), email.messageId], + ]; + return ( +
+ {rows.filter(([, value]) => value).map(([label, value]) => ( + +
{label}
+
{value}
+
+ ))} +
+ ); +} + /** * A message of a Mountain View conversation unfolded in place: its sender line, * which folds it again when clicked, and its body. The list fetch carries no @@ -724,9 +757,11 @@ export function MvExpandedConversationCard({ onMarkRead, onReply, onDownloadAttachment, + compact, }: { email: Email; timeFormat: '12h' | '24h'; + compact?: boolean; onCollapse: (email: Email) => void; onLoadBody?: (email: Email) => void; onMarkRead?: (email: Email) => void; @@ -737,6 +772,7 @@ export function MvExpandedConversationCard({ const externalContent = useConversationExternalContent(); const sender = email.from?.[0]; const hasBody = !!email.bodyValues; + const [showDetails, setShowDetails] = useState(false); const loadRequested = useRef(false); useEffect(() => { if (hasBody || loadRequested.current || !onLoadBody) return; @@ -765,24 +801,33 @@ export function MvExpandedConversationCard({ } }} data-mv-conversation-header="" - className="flex items-start gap-4 px-6 pt-3 pb-2 cursor-pointer" + className={cn("flex items-start pt-3 pb-2 cursor-pointer", compact ? "gap-3 px-4" : "gap-4 px-6")} > {sender?.name || sender?.email || ''} - {sender?.name && sender.email && ( + {!compact && sender?.name && sender.email && ( <{sender.email}> )} - {mvSenderDate(email.receivedAt, timeFormat)} + {compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)} - {email.to && email.to.length > 0 && ( - - {t('to')} {email.to.map((a) => a.name || a.email).join(', ')} + {onReply && (