feat: open several messages of a conversation at once in mountain view #1222

This commit is contained in:
Linus Rath
2026-10-13 14:24:47 +02:00
parent 94095cf86a
commit ddab7c781f
4 changed files with 776 additions and 306 deletions
@@ -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> = {}): 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<Parameters<typeof EmailViewer>[0]> = {}) {
return render(
<EmailViewer
email={open}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
mvConversation={conversation}
{...props}
/>,
);
}
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(
<EmailViewer
email={open}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
mvConversation={conversation}
onReplyConversationEmail={onReply}
/>,
);
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(
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />,
);
expect(container.querySelector('[data-mv-open-header]')).toBeNull();
});
});
+204 -21
View File
@@ -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 (
<div className="border-t border-border" data-mv-conversation-expanded="" data-email-id={email.id}>
<div
role="button"
tabIndex={0}
aria-expanded
onClick={(e) => {
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"
>
<Avatar name={sender?.name} email={sender?.email} size="md" className="w-10 h-10 flex-shrink-0" />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className="text-sm font-semibold truncate">{sender?.name || sender?.email || ''}</span>
{sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{mvSenderDate(email.receivedAt, timeFormat)}
</span>
</span>
{email.to && email.to.length > 0 && (
<span className="block text-xs text-muted-foreground truncate">
{t('to')} {email.to.map((a) => a.name || a.email).join(', ')}
</span>
)}
</span>
{onReply && (
<button
type="button"
onClick={() => onReply(email)}
className="grid place-items-center w-10 h-10 -my-2 -me-2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex-shrink-0"
title={t('tooltips.reply')}
aria-label={t('tooltips.reply')}
>
<Reply className="w-5 h-5" />
</button>
)}
</div>
{hasBody ? (
<ConversationEmailBody
email={email}
allowExternal={externalContent.isAllowed(email)}
onAllowExternal={() => externalContent.allow(email.id)}
onTrustSender={externalContent.trustSender(email)}
onDownloadAttachment={onDownloadAttachment}
className="ps-[80px] pe-6 pb-4"
bodyStyle={{ paddingBlock: '0.25rem 0.5rem' }}
/>
) : (
<div className="ps-[80px] pe-6 pb-4 pt-1 space-y-2 animate-pulse" data-mv-conversation-loading="">
<div className="h-2 bg-muted rounded w-full" />
<div className="h-2 bg-muted rounded w-5/6" />
<div className="h-2 bg-muted rounded w-4/6" />
</div>
)}
</div>
);
}
/**
* 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<string>;
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) => <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} />)}</>;
const isExpanded = (e: Email) => !!renderExpanded && !!expandedIds?.has(e.id);
const renderOne = (e: Email) => isExpanded(e)
? <Fragment key={e.id}>{renderExpanded!(e)}</Fragment>
: <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} />;
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 (
<>
<MvConversationCard email={emails[0]} onOpen={onOpen} timeFormat={timeFormat} />
{renderOne(emails[0])}
<div className="relative border-t border-border h-4">
<button
type="button"
onClick={() => setExpanded(true)}
onClick={() => setShowAll(true)}
data-mv-conversation-more=""
className="absolute start-6 top-1/2 -translate-y-1/2 grid place-items-center w-10 h-10 rounded-full border border-border bg-background text-sm text-muted-foreground hover:bg-muted transition-colors"
>
{hidden}
{middle.length}
</button>
</div>
<MvConversationCard email={emails[emails.length - 1]} onOpen={onOpen} timeFormat={timeFormat} />
{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<ReadonlySet<string>>(() => new Set());
const mvExpandedThreadRef = useRef<string | null>(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) => (
<MvExpandedConversationCard
email={target}
timeFormat={timeFormat}
onCollapse={toggleMvExpanded}
onLoadBody={onLoadConversationEmail}
onMarkRead={onMarkConversationEmailRead}
onReply={onReplyConversationEmail}
onDownloadAttachment={onDownloadAttachment}
/>
);
// 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 && (
<div className="hidden lg:block" data-mv-conversation-before="">
<MvConversationRun emails={mvBefore} onOpen={onOpenConversationEmail} timeFormat={timeFormat} collapseLongRun />
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} collapseLongRun />
</div>
)}
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
<div className="hidden lg:block" data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} />
</div>
) : (<>
{/* === SENDER INFO (Desktop) === */}
<div
className={cn("hidden lg:block bg-background px-6", !mvViewer && "border-b border-border", mvViewer && mvBefore.length > 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}
>
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
<button
@@ -5497,11 +5676,15 @@ export function EmailViewer({
</div>
)}
</>)}
<div className="grow shrink-0 flex flex-col">
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
// Folded, the body stays mounted so unfolding does not reload it.
mvOpenFolded && "hidden",
// Mountain View indents the body under the sender's name, as Gmail does.
mvViewer && "lg:ps-[60px]",
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
@@ -5550,7 +5733,7 @@ export function EmailViewer({
{/* Mountain View: the conversation's later messages, folded. */}
{mvViewer && mvAfter.length > 0 && (
<div className="hidden lg:block mt-4 border-b border-border" data-mv-conversation-after="">
<MvConversationRun emails={mvAfter} onOpen={onOpenConversationEmail} timeFormat={timeFormat} />
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} />
</div>
)}
+346 -280
View File
@@ -77,6 +77,85 @@ const getFileIcon = (name?: string, type?: string) => {
return File;
};
/**
* Whether a conversation message may load remote content: the user's policy,
* a trusted sender, or a one-off "load" for that message.
*/
export function useConversationExternalContent() {
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
}, []);
const isAllowed = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
if (!senderEmail) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
allow(email.id);
};
};
return { isAllowed, allow, trustSender };
}
/**
* Starts the "mark as read" timer for an unread message while it is shown,
* following the user's mark-as-read delay.
*/
export function useMarkReadWhenShown(
email: Email,
shown: boolean,
onMarkAsRead?: (emailId: string, read: boolean) => void,
) {
useEffect(() => {
if (!shown || !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);
// eslint-disable-next-line react-hooks/exhaustive-deps -- a new handler identity must not restart the timer
}, [shown, email.id, email.keywords?.$seen]);
}
export function ThreadConversationView({
thread,
emails,
@@ -89,17 +168,10 @@ export function ThreadConversationView({
onMarkAsRead,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender);
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore();
const externalContent = useConversationExternalContent();
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [allowExternalContent, setAllowExternalContent] = useState<Set<string>>(new Set());
// Auto-expand most recent email AND all unread emails when thread opens
useEffect(() => {
@@ -132,14 +204,6 @@ export function ThreadConversationView({
});
};
const toggleAllowExternal = (emailId: string) => {
setAllowExternalContent(prev => {
const next = new Set(prev);
next.add(emailId);
return next;
});
};
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center bg-background">
@@ -176,36 +240,23 @@ export function ThreadConversationView({
{/* Email Cards */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-3" style={{ padding: 'var(--density-card-p)' }}>
{emails.map((email, index) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
return (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContentPolicy === 'allow' || senderIsTrusted || allowExternalContent.has(email.id)}
onToggleExpanded={() => toggleExpanded(email.id)}
onAllowExternal={() => toggleAllowExternal(email.id)}
onTrustSender={senderEmail ? () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
} else {
addTrustedSender(senderEmail);
}
toggleAllowExternal(email.id);
} : undefined}
onReply={onReply ? () => onReply(email) : undefined}
onReplyAll={onReplyAll ? () => onReplyAll(email) : undefined}
onForward={onForward ? () => onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
/>
);
})}
{emails.map((email, index) => (
<EmailCard
key={email.id}
email={email}
isExpanded={expandedIds.has(email.id)}
isLatest={index === 0}
allowExternal={externalContent.isAllowed(email)}
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}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
/>
))}
</div>
</div>
</div>
@@ -243,8 +294,160 @@ function EmailCard({
onMarkAsRead,
}: EmailCardProps) {
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const density = useSettingsStore((state) => state.density);
const sender = email.from?.[0];
const formatAddress = useOwnDomainAddress();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
// Mark as read when email is expanded
useMarkReadWhenShown(email, isExpanded, onMarkAsRead);
return (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start text-start transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{cleanPreview(email.preview) || t('email_viewer.no_preview_available')}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
<ConversationEmailBody
email={email}
allowExternal={allowExternal}
onAllowExternal={onAllowExternal}
onTrustSender={onTrustSender}
onDownloadAttachment={onDownloadAttachment}
/>
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<Reply className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.reply")}</span>
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<ReplyAll className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.reply_all")}</span>
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<Forward className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.forward")}</span>
</Button>
)}
</div>
</div>
)}
</div>
);
}
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<Record<string, string>>({});
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 (
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start text-start transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
>
{density !== 'extra-compact' && (
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0"
<div className={className}>
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
}}
>
{t("email_viewer.load_external_content")}
</Button>
{onTrustSender && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onTrustSender();
}}
>
{t("email_viewer.trust_sender")}
</Button>
)}
</div>
</div>
)}
{verificationCode && (
<div className="flex" style={{ paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
<VerificationCodeChip code={verificationCode} className="py-1 text-sm" />
</div>
)}
{/* Email Body */}
<div style={bodyStyle ?? { padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={cn(
"font-medium truncate",
isUnread ? "text-foreground" : "text-muted-foreground"
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
{email.hasAttachment && (
<Paperclip className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(email.receivedAt)}
</div>
{!isExpanded && density !== 'extra-compact' && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{cleanPreview(email.preview) || t('email_viewer.no_preview_available')}
</p>
)}
</div>
<div className="flex-shrink-0 p-1">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border animate-in slide-in-from-top-2 duration-200">
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
{/* Attachments */}
{visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onAllowExternal();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
{t("email_viewer.load_external_content")}
</Button>
{onTrustSender && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onTrustSender();
}}
>
{t("email_viewer.trust_sender")}
</Button>
)}
</div>
</div>
)}
{verificationCode && (
<div className="flex" style={{ paddingInline: 'var(--density-card-p)', paddingTop: 'var(--density-card-p)' }}>
<VerificationCodeChip code={verificationCode} className="py-1 text-sm" />
</div>
)}
{/* Email Body */}
<div style={{ padding: 'var(--density-card-p)' }}>
{emailContent.isHtml ? (
<iframe
ref={iframeRef}
srcDoc={emailIframeSrcDoc}
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
title="Email content"
className="w-full border-0 block"
scrolling="no"
style={{ minHeight: '60px' }}
onLoad={handleIframeLoad}
/>
) : (
<div
className={cn(
"prose prose-sm max-w-none",
!emailAlwaysLightMode && "dark:prose-invert",
"prose-p:my-2 prose-headings:my-3",
"prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
"[&_table]:border-collapse [&_td]:p-2 [&_th]:p-2",
"[&_img]:max-w-full [&_img]:h-auto"
)}
style={{ whiteSpace: 'pre-wrap', ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), fontSize: '13px' }}
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(emailContent.html) }}
/>
)}
</div>
{/* Attachments */}
{visibleAttachments.length > 0 && (
<div className="px-4 pb-4">
<div className="flex flex-wrap gap-2">
{visibleAttachments.map((attachment, idx) => {
const Icon = getFileIcon(attachment.name, attachment.type);
const isPreviewable = isFilePreviewable(attachment.name, attachment.type)
|| (!!attachment.blobId && canWopiOpen(wopiStatus, attachment.name));
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<button
key={idx}
onClick={(e) => {
e.stopPropagation();
onDownloadAttachment?.(attachment.blobId, attachment.name || 'attachment', attachment.type);
}}
title={opensPreview ? t('files.preview') : t('email_viewer.download')}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted hover:bg-muted/80 transition-colors text-sm"
>
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
);
})}
</div>
</div>
)}
{/* Action Buttons */}
<div className="px-4 pb-4 flex gap-2">
{onReply && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReply();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<Reply className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.reply")}</span>
</Button>
)}
{onReplyAll && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onReplyAll();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<ReplyAll className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.reply_all")}</span>
</Button>
)}
{onForward && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onForward();
}}
className="flex-1 min-w-0 px-2 whitespace-nowrap"
>
<Forward className="w-4 h-4 me-1.5 shrink-0" />
<span className="truncate">{t("email_viewer.forward")}</span>
</Button>
)}
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="truncate max-w-[150px]">{attachment.name || 'Attachment'}</span>
<span className="text-muted-foreground text-xs">
{formatFileSize(attachment.size)}
</span>
{opensPreview ? (
<Eye className="w-4 h-4 text-muted-foreground" />
) : (
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
);
})}
</div>
</div>
)}
+56 -5
View File
@@ -3560,6 +3560,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// the rest of the thread (list properties only) whenever another opens.
const mvConversationThreadId = mvShell && !cursorOnly ? (selectedEmail?.threadId ?? null) : null;
const mvConversationRef = mvShell && !cursorOnly ? selectedEmail : null;
const mvConversationRoute = (ref: Email, fallback: NonNullable<typeof client>) => {
const route = resolveThreadRoute({ isUnifiedView, ref, mailboxes: viewMailboxes, selectedMailbox });
const threadClient = route.clientAccountId
? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? fallback)
: fallback;
return { threadClient, accountId: route.accountId };
};
useEffect(() => {
if (!mvConversationThreadId || !client || !mvConversationRef) {
setMvConversation(null);
@@ -3567,10 +3574,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
let cancelled = false;
const ref = mvConversationRef;
const route = resolveThreadRoute({ isUnifiedView, ref, mailboxes: viewMailboxes, selectedMailbox });
const threadClient = route.clientAccountId
? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client)
: client;
const route = mvConversationRoute(ref, client);
const threadClient = route.threadClient;
(async () => {
try {
const thread = await threadClient.getThread(mvConversationThreadId, route.accountId);
@@ -3602,6 +3607,46 @@ 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, client]);
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
setMvConversation((prev) => prev?.map((e) => (e.id === id ? patch(e) : e)) ?? prev);
// An unfolded conversation message needs its body: the thread fetch above
// only carries list properties.
const handleLoadConversationEmail = async (target: Email): Promise<Email | null> => {
if (!client) return null;
const { threadClient, accountId } = mvConversationRoute(target, client);
const fetched = await threadClient.getEmail(target.id, accountId);
if (!fetched) return null;
const full: Email = {
...fetched,
accountId: target.accountId,
accountLabel: target.accountLabel,
sourceClientAccountId: target.sourceClientAccountId,
sourceAccountId: target.sourceAccountId,
};
patchMvConversation(target.id, (e) => ({ ...full, keywords: e.keywords }));
return full;
};
// Reading an unfolded message marks it read. Older messages of the thread
// are often not in the loaded list (another folder, an older page), where
// the store's markAsRead has nothing to update, so those go to the server
// directly. (#1222)
const handleMarkConversationEmailRead = async (target: Email) => {
if (!client || target.keywords?.$seen) return;
try {
if (useEmailStore.getState().emails.some((e) => e.id === target.id)) {
await markAsRead(client, target.id, true);
} else {
const { threadClient, accountId } = mvConversationRoute(target, client);
await threadClient.markAsRead(target.id, true, accountId);
}
patchMvConversation(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } }));
} catch (error) {
console.error('Failed to mark conversation message read:', error);
}
};
// Show loading state while checking auth
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
return (
@@ -4789,7 +4834,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} : undefined}
mvFolderName={mvShell && !isScheduledView && !isUnifiedView ? currentMailboxName : undefined}
mvConversation={mvShell ? mvConversation ?? undefined : undefined}
onOpenConversationEmail={mvShell ? (e) => void handleEmailSelect(e) : undefined}
onLoadConversationEmail={mvShell ? (e) => void handleLoadConversationEmail(e) : undefined}
onMarkConversationEmailRead={mvShell ? (e) => void handleMarkConversationEmailRead(e) : undefined}
onReplyConversationEmail={mvShell ? async (e) => {
// The reply quotes the message, so it needs the body.
const full = e.bodyValues ? e : await handleLoadConversationEmail(e);
await handleConversationReply(full ?? e);
} : undefined}
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
isFullscreen={fullscreenReading}