From ddab7c781f2cadd21999cbee07d265d61ef59d12 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:24:47 +0200 Subject: [PATCH] feat: open several messages of a conversation at once in mountain view #1222 --- .../__tests__/mv-conversation-expand.test.tsx | 170 +++++ components/email/email-viewer.tsx | 225 ++++++- components/email/thread-conversation-view.tsx | 626 ++++++++++-------- components/mail/mail-app.tsx | 61 +- 4 files changed, 776 insertions(+), 306 deletions(-) create mode 100644 components/email/__tests__/mv-conversation-expand.test.tsx diff --git a/components/email/__tests__/mv-conversation-expand.test.tsx b/components/email/__tests__/mv-conversation-expand.test.tsx new file mode 100644 index 000000000..21056b2e4 --- /dev/null +++ b/components/email/__tests__/mv-conversation-expand.test.tsx @@ -0,0 +1,170 @@ +import { render, screen, fireEvent, act } 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'; + +// #1222: Mountain View showed one message of a conversation at a time. Every +// message now folds and unfolds in place, unread ones start unfolded (so +// opening the thread reads all of them), and the open message folds too. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); + +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: () => [], +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes: 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, + ...overrides, + } as unknown as Email; +} + +function withBody(e: Email, text: string): Email { + return { + ...e, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [], + attachments: [], + bodyValues: { '1': { value: text, isTruncated: false } }, + } as unknown as Email; +} + +const expandedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id')); +const foldedIds = (c: HTMLElement) => + [...c.querySelectorAll('[data-mv-conversation-card]')].map((e) => e.getAttribute('data-email-id')); + +function renderViewer(conversation: Email[], open: Email, props: Partial[0]> = {}) { + return render( + , + ); +} + +describe('Mountain View conversation folding (#1222)', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 }); + }); + + it('opens with every unread message unfolded, fetches their bodies and marks them read', () => { + const open = withBody(mail(4, { keywords: {} }), 'Open body'); + const conversation = [mail(1), mail(2, { keywords: {} }), mail(3, { keywords: {} }), open]; + const onLoad = vi.fn(); + const onRead = vi.fn(); + const { container } = renderViewer(conversation, open, { + onLoadConversationEmail: onLoad, + onMarkConversationEmailRead: onRead, + }); + + expect(expandedIds(container)).toEqual(['m2', 'm3']); + expect(foldedIds(container)).toEqual(['m1']); + expect(onLoad.mock.calls.map(([e]) => e.id).sort()).toEqual(['m2', 'm3']); + expect(onRead.mock.calls.map(([e]) => e.id).sort()).toEqual(['m2', 'm3']); + }); + + it('unfolds a folded message in place and folds it again from its header', () => { + const open = withBody(mail(3), 'Open body'); + const conversation = [withBody(mail(1), 'First body'), mail(2), open]; + const { container, rerender } = renderViewer(conversation, open); + + expect(expandedIds(container)).toEqual([]); + fireEvent.click(screen.getByText('Sender 1')); + expect(expandedIds(container)).toEqual(['m1']); + expect(screen.getByText('First body')).toBeInTheDocument(); + + // A second message can be open at the same time. + fireEvent.click(screen.getByText('Sender 2')); + expect(expandedIds(container)).toEqual(['m1', 'm2']); + + // The reply button acts on its own and leaves the message unfolded. + const onReply = vi.fn(); + rerender( + , + ); + const header = container.querySelector('[data-mv-conversation-expanded][data-email-id="m1"] [data-mv-conversation-header]')!; + fireEvent.click(header.querySelector('button')!); + expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'm1' })); + expect(expandedIds(container)).toEqual(['m1', 'm2']); + + fireEvent.click(header); + expect(expandedIds(container)).toEqual(['m2']); + expect(foldedIds(container)).toContain('m1'); + }); + + it('folds the open message from its sender line and unfolds it again', () => { + const open = withBody(mail(2), 'Open body'); + const conversation = [mail(1), open]; + const { container } = renderViewer(conversation, open); + + const header = container.querySelector('[data-mv-open-header]') as HTMLElement; + expect(header).not.toBeNull(); + fireEvent.click(header); + expect(container.querySelector('[data-mv-open-header]')).toBeNull(); + expect(foldedIds(container)).toEqual(['m1', 'm2']); + // The body stays mounted, only hidden, so unfolding does not reload it. + expect(container.querySelector('.email-content-wrapper')).toHaveClass('hidden'); + + act(() => { + fireEvent.click(container.querySelector('[data-mv-open-folded] [data-mv-conversation-card]')!); + }); + expect(container.querySelector('[data-mv-open-header]')).not.toBeNull(); + expect(container.querySelector('.email-content-wrapper')).not.toHaveClass('hidden'); + }); + + it('does not fold a message that has no conversation around it', () => { + const open = withBody(mail(1), 'Only body'); + const { container } = render( + , + ); + expect(container.querySelector('[data-mv-open-header]')).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 61017f113..e57c39f7b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; +import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; @@ -117,6 +117,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code"; import { VerificationCodeChip } from "./verification-code-chip"; +import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view"; import type { TnefAttachment } from "@/lib/tnef"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { RulesPanel, useRulesAvailability } from "./rules-menu"; @@ -161,11 +162,16 @@ interface EmailViewerProps { mvFolderName?: string; /** * Mountain View: every message of the open conversation, oldest first, - * including the open one. The others are drawn as collapsed cards around - * it, as Gmail does; clicking one opens it. + * including the open one. The others are drawn around it as cards that + * fold and unfold in place, as Gmail does; unread ones start unfolded. */ mvConversation?: Email[]; - onOpenConversationEmail?: (email: Email) => void; + /** Mountain View: fetch the full message for a conversation card that was unfolded. */ + onLoadConversationEmail?: (email: Email) => void; + /** Mountain View: an unfolded conversation message was read. */ + onMarkConversationEmailRead?: (email: Email) => void; + /** Mountain View: reply to one message of the conversation. */ + onReplyConversationEmail?: (email: Email) => void; /** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */ mvInlineComposer?: React.ReactNode; onShowShortcuts?: () => void; @@ -670,7 +676,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 opens that message. + * snippet. Clicking it unfolds the message in place. */ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h' }) { const sender = email.from?.[0]; @@ -699,37 +705,156 @@ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpe ); } +/** True when a click landed on something inside `container` that acts on its own (a button, a link, a field). */ +function isInteractiveClick(target: EventTarget | null, container: HTMLElement): boolean { + const el = target instanceof Element ? target.closest('button, a, input, textarea, select, [role="button"], [role="menuitem"]') : null; + return !!el && el !== container && container.contains(el); +} + /** - * The folded messages before or after the open one. A long run before it - * shows only its first and last message around a count, as Gmail does, and - * the count unfolds the rest. + * 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 + * body, so the first unfold asks for the full message. */ -export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun }: { +export function MvExpandedConversationCard({ + email, + timeFormat, + onCollapse, + onLoadBody, + onMarkRead, + onReply, + onDownloadAttachment, +}: { + email: Email; + timeFormat: '12h' | '24h'; + onCollapse: (email: Email) => void; + onLoadBody?: (email: Email) => void; + onMarkRead?: (email: Email) => void; + onReply?: (email: Email) => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; +}) { + const t = useTranslations('email_viewer'); + const externalContent = useConversationExternalContent(); + const sender = email.from?.[0]; + const hasBody = !!email.bodyValues; + const loadRequested = useRef(false); + useEffect(() => { + if (hasBody || loadRequested.current || !onLoadBody) return; + loadRequested.current = true; + onLoadBody(email); + }, [hasBody, email, onLoadBody]); + // Unfolding an unread message reads it, as opening it does. + useMarkReadWhenShown(email, true, onMarkRead ? () => onMarkRead(email) : undefined); + + return ( +
+
{ + if (isInteractiveClick(e.target, e.currentTarget)) return; + if (window.getSelection()?.toString()) return; + onCollapse(email); + }} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCollapse(email); + } + }} + data-mv-conversation-header="" + className="flex items-start gap-4 px-6 pt-3 pb-2 cursor-pointer" + > + + + + {sender?.name || sender?.email || ''} + {sender?.name && sender.email && ( + <{sender.email}> + )} + + {mvSenderDate(email.receivedAt, timeFormat)} + + + {email.to && email.to.length > 0 && ( + + {t('to')} {email.to.map((a) => a.name || a.email).join(', ')} + + )} + + {onReply && ( + + )} +
+ {hasBody ? ( + externalContent.allow(email.id)} + onTrustSender={externalContent.trustSender(email)} + onDownloadAttachment={onDownloadAttachment} + className="ps-[80px] pe-6 pb-4" + bodyStyle={{ paddingBlock: '0.25rem 0.5rem' }} + /> + ) : ( +
+
+
+
+
+ )} +
+ ); +} + +/** + * The messages before or after the open one, each folded or unfolded. A long + * run of folded messages before it shows only its first and last message + * around a count, as Gmail does, and the count unfolds the rest. + */ +export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded }: { emails: Email[]; + /** A folded message was clicked. */ onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; collapseLongRun?: boolean; + expandedIds?: ReadonlySet; + renderExpanded?: (email: Email) => React.ReactNode; }) { - const [expanded, setExpanded] = useState(false); + const [showAll, setShowAll] = useState(false); if (emails.length === 0) return null; - if (!collapseLongRun || expanded || emails.length <= 3) { - return <>{emails.map((e) => )}; + const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id); + const renderOne = (e: Email) => isExpanded(e) + ? {renderExpanded!(e)} + : ; + const middle = emails.slice(1, -1); + if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) { + return <>{emails.map(renderOne)}; } - const hidden = emails.length - 2; return ( <> - + {renderOne(emails[0])}
- + {renderOne(emails[emails.length - 1])} ); } @@ -810,7 +935,9 @@ export function EmailViewer({ mvPosition, mvFolderName, mvConversation, - onOpenConversationEmail, + onLoadConversationEmail, + onMarkConversationEmailRead, + onReplyConversationEmail, mvInlineComposer, onShowShortcuts, onEditDraft, @@ -926,6 +1053,44 @@ export function EmailViewer({ const mvOpenIndex = mvViewer && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1; const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : []; const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : []; + // Mountain View: which other messages of the conversation are unfolded. A + // conversation opens with its unread messages unfolded, as Gmail does, so + // reading the thread reads all of them. + const [mvExpandedIds, setMvExpandedIds] = useState>(() => new Set()); + const mvExpandedThreadRef = useRef(null); + useEffect(() => { + if (!mvConversation || mvConversation.length === 0 || !email) { + mvExpandedThreadRef.current = null; + return; + } + const key = mvConversation[0].threadId; + if (mvExpandedThreadRef.current === key) return; + mvExpandedThreadRef.current = key; + setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id))); + }, [mvConversation, email]); + const toggleMvExpanded = useCallback((target: Email) => { + setMvExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(target.id)) next.delete(target.id); + else next.add(target.id); + return next; + }); + }, []); + // The open message folds too, but only inside a conversation. + const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); + useEffect(() => setMvOpenCollapsed(false), [email?.id]); + const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed; + const renderMvExpanded = (target: Email) => ( + + ); // Mountain View's toolbars are icon-only, on the phone as on the desktop. const showToolbarLabels = showToolbarLabelsSetting && interfaceLayout !== 'mountain-view'; // Inside a Pro pane, `isMobile` above is pane-width based: overlays that @@ -4157,14 +4322,28 @@ export function EmailViewer({ {/* Mountain View: the conversation's earlier messages, folded. */} {mvViewer && mvBefore.length > 0 && (
- +
)} + {/* Mountain View: the open message folded like the rest of the conversation. */} + {mvOpenFolded && email ? ( +
+ setMvOpenCollapsed(false)} timeFormat={timeFormat} /> +
+ ) : (<> {/* === SENDER INFO (Desktop) === */}
0 && "border-t border-border")} + className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", mvViewer && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")} style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }} + data-mv-open-header={mvOpenIndex >= 0 ? "" : undefined} + onClick={mvOpenIndex >= 0 ? (e) => { + // In a conversation, a click on the sender line folds the open + // message, as Gmail does; its buttons and links keep their own job. + if (isInteractiveClick(e.target, e.currentTarget)) return; + if (window.getSelection()?.toString()) return; + setMvOpenCollapsed(true); + } : undefined} >
+ + {/* Expanded Content */} + {isExpanded && ( +
+ + + {/* Action Buttons */} +
+ {onReply && ( + + )} + {onReplyAll && ( + + )} + {onForward && ( + + )} +
+
+ )} +
+ ); +} + +interface ConversationEmailBodyProps { + email: Email; + allowExternal: boolean; + onAllowExternal: () => void; + onTrustSender?: () => void; + onDownloadAttachment?: (blobId: string, name: string, type?: string) => void; + /** Padding around the body; the phone card's density padding by default. */ + bodyStyle?: React.CSSProperties; + className?: string; +} + +/** + * One conversation message's body: the remote-content banner, the sanitized + * body in a sandboxed iframe and its attachments. Shared by the phone thread + * view and Mountain View's expanded conversation messages. + */ +export function ConversationEmailBody({ + email, + allowExternal, + onAllowExternal, + onTrustSender, + onDownloadAttachment, + bodyStyle, + className, +}: ConversationEmailBodyProps) { + const t = useTranslations(); + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); const wopiStatus = useWopiStatus(true); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); @@ -253,47 +456,15 @@ function EmailCard({ const plainTextFont = useSettingsStore((state) => state.plainTextFont); const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes); const verificationCode = useMemo( - () => (showVerificationCodes && isExpanded ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null), + () => (showVerificationCodes ? findVerificationCode(email.subject, verificationCodeBodyText(email)) : null), // A keyword change replaces the email object; only another body needs another look. // eslint-disable-next-line react-hooks/exhaustive-deps - [showVerificationCodes, isExpanded, email.id, email.subject, email.bodyValues], + [showVerificationCodes, email.id, email.subject, email.bodyValues], ); - const sender = email.from?.[0]; - const formatAddress = useOwnDomainAddress(); - const isUnread = !email.keywords?.$seen; - const isStarred = email.keywords?.$flagged; const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); const { client } = useAuthStore(); - // Mark as read when email is expanded - useEffect(() => { - // Only trigger if expanded, email is unread, and we have a handler - if (!isExpanded || !onMarkAsRead || email.keywords?.$seen) { - return; - } - - const markAsReadDelay = useSettingsStore.getState().markAsReadDelay; - - // Never auto-mark - if (markAsReadDelay === -1) { - return; - } - - // Instant mark - if (markAsReadDelay === 0) { - onMarkAsRead(email.id, true); - return; - } - - // Delayed mark - const timeout = setTimeout(() => { - onMarkAsRead(email.id, true); - }, markAsReadDelay); - - return () => clearTimeout(timeout); - }, [isExpanded, email.id, email.keywords?.$seen, onMarkAsRead]); - // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { if (!client || !email?.attachments) { @@ -511,212 +682,107 @@ function EmailCard({ }, [t, isDark, allowExternal]); return ( -
- {/* Card Header - Always visible */} - + {onTrustSender && ( + + )} +
+
+ )} + + {verificationCode && ( +
+ +
+ )} + + {/* Email Body */} +
+ {emailContent.isHtml ? ( +