mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: open several messages of a conversation at once in mountain view #1222
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"><{sender.email}></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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user