mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
fix: open mountain view threads as a conversation on the phone #1224
This commit is contained in:
@@ -131,7 +131,7 @@ describe('Mountain View conversation folding (#1222)', () => {
|
||||
/>,
|
||||
);
|
||||
const header = container.querySelector('[data-mv-conversation-expanded][data-email-id="m1"] [data-mv-conversation-header]')!;
|
||||
fireEvent.click(header.querySelector('button')!);
|
||||
fireEvent.click(header.querySelector('button[aria-label]')!);
|
||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'm1' }));
|
||||
expect(expandedIds(container)).toEqual(['m1', 'm2']);
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
// #1224: on the phone Mountain View showed threads in a separate view, newest
|
||||
// first and without the to/cc/bcc details. The phone viewer now draws the same
|
||||
// conversation as the desktop one: oldest first, every message foldable, and
|
||||
// each unfolded message has its own details.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
|
||||
usePluginSlotOffers: () => [],
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes: Mailbox[] = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
function mail(n: number, overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: `m${n}`,
|
||||
blobId: `b${n}`,
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: `Sender ${n}`, email: `s${n}@example.com` }],
|
||||
to: [{ name: 'Me', email: 'me@example.com' }],
|
||||
subject: 'Subject',
|
||||
receivedAt: `2026-01-0${n}T10:00:00Z`,
|
||||
preview: `Preview ${n}`,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
bodyValues: { '1': { value: `Body ${n}`, isTruncated: false } },
|
||||
...overrides,
|
||||
} as unknown as Email;
|
||||
}
|
||||
|
||||
const cardIds = (c: HTMLElement) =>
|
||||
[...c.querySelectorAll('[data-mv-conversation-card], [data-mv-conversation-expanded]')].map((e) => e.getAttribute('data-email-id'));
|
||||
|
||||
describe('Mountain View conversation on the phone (#1224)', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: -1 });
|
||||
});
|
||||
|
||||
it('draws the conversation oldest first around the open message', () => {
|
||||
const open = mail(3);
|
||||
const { container } = render(
|
||||
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
mvConversation={[mail(1), mail(2), open]} />,
|
||||
);
|
||||
expect(cardIds(container)).toEqual(['m1', 'm2']);
|
||||
const before = container.querySelector('[data-mv-conversation-before]')!;
|
||||
const openHeader = container.querySelector('[data-mv-open-header-mobile]')!;
|
||||
expect(before.compareDocumentPosition(openHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows cc and bcc of an unfolded message in its details', () => {
|
||||
const older = mail(1, {
|
||||
cc: [{ name: 'Carol', email: 'carol@example.com' }],
|
||||
bcc: [{ name: 'Dave', email: 'dave@example.com' }],
|
||||
});
|
||||
const open = mail(2);
|
||||
const { container } = render(
|
||||
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
mvConversation={[older, open]} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Sender 1'));
|
||||
expect(container.querySelector('[data-mv-conversation-details]')).toBeNull();
|
||||
|
||||
fireEvent.click(container.querySelector('[data-mv-conversation-details-toggle]')!);
|
||||
const details = container.querySelector('[data-mv-conversation-details]') as HTMLElement;
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.textContent).toContain('Carol <carol@example.com>');
|
||||
expect(details.textContent).toContain('Dave <dave@example.com>');
|
||||
// The toggle does not fold the message it sits in.
|
||||
expect(cardIds(container)).toEqual(['m1']);
|
||||
expect(container.querySelector('[data-mv-conversation-expanded]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('folds the open message from its phone sender line', () => {
|
||||
const open = mail(2);
|
||||
const { container } = render(
|
||||
<EmailViewer email={open} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
|
||||
mvConversation={[mail(1), open]} />,
|
||||
);
|
||||
fireEvent.click(container.querySelector('[data-mv-open-header-mobile]')!);
|
||||
expect(container.querySelector('[data-mv-open-folded]')).not.toBeNull();
|
||||
expect(cardIds(container)).toEqual(['m1', 'm2']);
|
||||
});
|
||||
});
|
||||
@@ -256,6 +256,46 @@ describe('ThreadListItem row content', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem opening a thread on the phone', () => {
|
||||
const twoMessageThread = () => groupEmailsByThread([
|
||||
makeEmail({ id: 'older', receivedAt: '2024-01-14T10:00:00Z' }),
|
||||
makeEmail({ id: 'newer', receivedAt: '2024-01-15T10:00:00Z' }),
|
||||
])[0];
|
||||
|
||||
function tap(interfaceLayout: 'mountain-view' | 'default') {
|
||||
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
|
||||
useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
|
||||
useEmailStore.setState({ selectedEmailKeys: new Set<string>(), selectedMailbox: 'inbox' });
|
||||
const onEmailSelect = vi.fn();
|
||||
const onOpenConversation = vi.fn();
|
||||
render(
|
||||
<ThreadListItem
|
||||
thread={twoMessageThread()}
|
||||
isExpanded={false}
|
||||
onToggleExpand={() => {}}
|
||||
onEmailSelect={onEmailSelect}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('Test Subject'));
|
||||
return { onEmailSelect, onOpenConversation };
|
||||
}
|
||||
|
||||
// #1224: the phone thread view lists newest first and has no details, so
|
||||
// Mountain View opens the thread in the viewer, as on the desktop.
|
||||
it('opens the latest message in the viewer in Mountain View', () => {
|
||||
const { onEmailSelect, onOpenConversation } = tap('mountain-view');
|
||||
expect(onOpenConversation).not.toHaveBeenCalled();
|
||||
expect(onEmailSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'newer' }));
|
||||
});
|
||||
|
||||
it('keeps the phone thread view in the default layout', () => {
|
||||
const { onEmailSelect, onOpenConversation } = tap('default');
|
||||
expect(onOpenConversation).toHaveBeenCalledTimes(1);
|
||||
expect(onEmailSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThreadListItem Mountain View phone row layout', () => {
|
||||
it('sets the star at the end of the preview line, not under the time', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never);
|
||||
|
||||
@@ -678,7 +678,7 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string {
|
||||
* One message of a Mountain View conversation, folded to its sender, date and
|
||||
* snippet. Clicking it unfolds the message in place.
|
||||
*/
|
||||
function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h' }) {
|
||||
function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) {
|
||||
const sender = email.from?.[0];
|
||||
const unread = !email.keywords?.$seen;
|
||||
return (
|
||||
@@ -687,7 +687,7 @@ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpe
|
||||
onClick={() => onOpen?.(email)}
|
||||
data-mv-conversation-card=""
|
||||
data-email-id={email.id}
|
||||
className="w-full flex items-start gap-4 px-6 py-3 border-t border-border text-start hover:bg-muted/40 transition-colors"
|
||||
className={cn("w-full flex items-start py-3 border-t border-border text-start hover:bg-muted/40 transition-colors", compact ? "gap-3 px-4" : "gap-4 px-6")}
|
||||
>
|
||||
<Avatar name={sender?.name} email={sender?.email} size="md" className="w-10 h-10 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
@@ -696,7 +696,7 @@ function MvConversationCard({ email, onOpen, timeFormat }: { email: Email; onOpe
|
||||
{sender?.name || sender?.email || ''}
|
||||
</span>
|
||||
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{mvSenderDate(email.receivedAt, timeFormat)}
|
||||
{compact ? formatDate(email.receivedAt) : mvSenderDate(email.receivedAt, timeFormat)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block text-sm text-muted-foreground truncate">{cleanPreview(email.preview)}</span>
|
||||
@@ -711,6 +711,39 @@ function isInteractiveClick(target: EventTarget | null, container: HTMLElement):
|
||||
return !!el && el !== container && container.contains(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sender, recipients, dates and ids of an unfolded conversation message,
|
||||
* as the open message's details panel lists them.
|
||||
*/
|
||||
function MvConversationDetails({ email, timeFormat, className }: { email: Email; timeFormat: '12h' | '24h'; className?: string }) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const addresses = (list?: { name?: string | null; email?: string | null }[]) =>
|
||||
(list ?? []).map((a) => (a.name && a.email ? `${a.name} <${a.email}>` : a.email || a.name || '')).join(', ');
|
||||
const fullDate = (iso: string) =>
|
||||
formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const rows: [string, string | undefined][] = [
|
||||
[t('from'), addresses(email.from)],
|
||||
[t('reply_to_label').replace(':', ''), addresses(email.replyTo)],
|
||||
[t('to'), addresses(email.to)],
|
||||
[t('cc'), addresses(email.cc)],
|
||||
[t('bcc'), addresses(email.bcc)],
|
||||
[t('details.sent'), email.sentAt ? fullDate(email.sentAt) : undefined],
|
||||
[t('details.received'), fullDate(email.receivedAt)],
|
||||
[t('subject'), email.subject],
|
||||
[t('headers.message_id'), email.messageId],
|
||||
];
|
||||
return (
|
||||
<dl className={cn("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 pb-3 text-xs", className)} data-mv-conversation-details="">
|
||||
{rows.filter(([, value]) => value).map(([label, value]) => (
|
||||
<Fragment key={label}>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground min-w-0 break-words">{value}</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A message of a Mountain View conversation unfolded in place: its sender line,
|
||||
* which folds it again when clicked, and its body. The list fetch carries no
|
||||
@@ -724,9 +757,11 @@ export function MvExpandedConversationCard({
|
||||
onMarkRead,
|
||||
onReply,
|
||||
onDownloadAttachment,
|
||||
compact,
|
||||
}: {
|
||||
email: Email;
|
||||
timeFormat: '12h' | '24h';
|
||||
compact?: boolean;
|
||||
onCollapse: (email: Email) => void;
|
||||
onLoadBody?: (email: Email) => void;
|
||||
onMarkRead?: (email: Email) => void;
|
||||
@@ -737,6 +772,7 @@ export function MvExpandedConversationCard({
|
||||
const externalContent = useConversationExternalContent();
|
||||
const sender = email.from?.[0];
|
||||
const hasBody = !!email.bodyValues;
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const loadRequested = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasBody || loadRequested.current || !onLoadBody) return;
|
||||
@@ -765,24 +801,33 @@ export function MvExpandedConversationCard({
|
||||
}
|
||||
}}
|
||||
data-mv-conversation-header=""
|
||||
className="flex items-start gap-4 px-6 pt-3 pb-2 cursor-pointer"
|
||||
className={cn("flex items-start pt-3 pb-2 cursor-pointer", compact ? "gap-3 px-4" : "gap-4 px-6")}
|
||||
>
|
||||
<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 && (
|
||||
{!compact && 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)}
|
||||
{compact ? formatDate(email.receivedAt) : 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(', ')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails((v) => !v)}
|
||||
aria-expanded={showDetails}
|
||||
data-mv-conversation-details-toggle=""
|
||||
className="flex items-center gap-0.5 max-w-full text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="truncate">
|
||||
{email.to && email.to.length > 0
|
||||
? `${t('to')} ${email.to.map((a) => a.name || a.email).join(', ')}`
|
||||
: t(showDetails ? 'hide_details' : 'show_details')}
|
||||
</span>
|
||||
)}
|
||||
{showDetails ? <ChevronUp className="w-3 h-3 flex-shrink-0" /> : <ChevronDown className="w-3 h-3 flex-shrink-0" />}
|
||||
</button>
|
||||
</span>
|
||||
{onReply && (
|
||||
<button
|
||||
@@ -796,6 +841,7 @@ export function MvExpandedConversationCard({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showDetails && <MvConversationDetails email={email} timeFormat={timeFormat} className={compact ? "px-4" : "ps-[80px] pe-6"} />}
|
||||
{hasBody ? (
|
||||
<ConversationEmailBody
|
||||
email={email}
|
||||
@@ -803,11 +849,11 @@ export function MvExpandedConversationCard({
|
||||
onAllowExternal={() => externalContent.allow(email.id)}
|
||||
onTrustSender={externalContent.trustSender(email)}
|
||||
onDownloadAttachment={onDownloadAttachment}
|
||||
className="ps-[80px] pe-6 pb-4"
|
||||
className={cn("pb-4", compact ? "px-4" : "ps-[80px] pe-6")}
|
||||
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={cn("pb-4 pt-1 space-y-2 animate-pulse", compact ? "px-4" : "ps-[80px] pe-6")} 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" />
|
||||
@@ -822,8 +868,9 @@ export function MvExpandedConversationCard({
|
||||
* 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 }: {
|
||||
export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun, expandedIds, renderExpanded, compact }: {
|
||||
emails: Email[];
|
||||
compact?: boolean;
|
||||
/** A folded message was clicked. */
|
||||
onOpen?: (email: Email) => void;
|
||||
timeFormat: '12h' | '24h';
|
||||
@@ -836,7 +883,7 @@ export function MvConversationRun({ emails, onOpen, timeFormat, collapseLongRun,
|
||||
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} />;
|
||||
: <MvConversationCard key={e.id} email={e} onOpen={onOpen} timeFormat={timeFormat} compact={compact} />;
|
||||
const middle = emails.slice(1, -1);
|
||||
if (!collapseLongRun || showAll || emails.length <= 3 || middle.some(isExpanded)) {
|
||||
return <>{emails.map(renderOne)}</>;
|
||||
@@ -1050,7 +1097,7 @@ export function EmailViewer({
|
||||
const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile;
|
||||
const mvReplyRow = mvViewer || mvMobileViewer;
|
||||
// The open conversation around the open message, split at it.
|
||||
const mvOpenIndex = mvViewer && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
|
||||
const mvOpenIndex = (mvViewer || mvMobileViewer) && 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
|
||||
@@ -1089,6 +1136,7 @@ export function EmailViewer({
|
||||
onMarkRead={onMarkConversationEmailRead}
|
||||
onReply={onReplyConversationEmail}
|
||||
onDownloadAttachment={onDownloadAttachment}
|
||||
compact={mvMobileViewer}
|
||||
/>
|
||||
);
|
||||
// Mountain View's toolbars are icon-only, on the phone as on the desktop.
|
||||
@@ -4320,16 +4368,16 @@ export function EmailViewer({
|
||||
<div className="min-h-full flex flex-col">
|
||||
|
||||
{/* 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={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} collapseLongRun />
|
||||
{mvBefore.length > 0 && (
|
||||
<div data-mv-conversation-before="">
|
||||
<MvConversationRun emails={mvBefore} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} 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 data-mv-open-folded="">
|
||||
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
</div>
|
||||
) : (<>
|
||||
{/* === SENDER INFO (Desktop) === */}
|
||||
@@ -4653,7 +4701,16 @@ export function EmailViewer({
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet Sender Info - scrolls with content */}
|
||||
<div className="lg:hidden bg-background border-b border-border px-4" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div
|
||||
className={cn("lg:hidden bg-background border-b border-border px-4", mvOpenIndex >= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
|
||||
style={{ paddingBlock: 'var(--density-header-py)' }}
|
||||
data-mv-open-header-mobile={mvOpenIndex >= 0 ? "" : undefined}
|
||||
onClick={mvOpenIndex >= 0 ? (e) => {
|
||||
if (isInteractiveClick(e.target, e.currentTarget)) return;
|
||||
if (window.getSelection()?.toString()) return;
|
||||
setMvOpenCollapsed(true);
|
||||
} : undefined}
|
||||
>
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<button
|
||||
onClick={() => sender?.email && handleViewContactSidebar(null, sender.email)}
|
||||
@@ -5731,9 +5788,9 @@ export function EmailViewer({
|
||||
<PluginSlot name="email-footer" />
|
||||
|
||||
{/* 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={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} />
|
||||
{mvAfter.length > 0 && (
|
||||
<div className="mt-4 border-b border-border" data-mv-conversation-after="">
|
||||
<MvConversationRun emails={mvAfter} onOpen={toggleMvExpanded} expandedIds={mvExpandedIds} renderExpanded={renderMvExpanded} timeFormat={timeFormat} compact={mvMobileViewer} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -962,14 +962,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile && onOpenConversation) {
|
||||
// Mountain View opens the conversation in the reading pane, as Gmail
|
||||
// does, rather than unfolding it inside the list. On the phone too: the
|
||||
// separate phone thread view would list it newest first. (#1224)
|
||||
if (isMobile && onOpenConversation && !mvMobileRow) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mountain View opens the conversation in the reading pane, as Gmail
|
||||
// does, rather than unfolding it inside the list.
|
||||
if (mvRow) {
|
||||
if (mvRow || mvMobileRow) {
|
||||
if (selectedEmailKeys.size > 0) clearSelection();
|
||||
onEmailSelect(latestEmail);
|
||||
return;
|
||||
|
||||
@@ -379,6 +379,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// one-pane flow, with Mountain View's own app chrome.
|
||||
const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isTablet && !isEmbedded;
|
||||
const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded;
|
||||
// Mountain View reads a thread as one conversation in the viewer, on the
|
||||
// phone as on the desktop, so both show the same order and details. (#1224)
|
||||
const mvConversationView = mvShell || mvMobile;
|
||||
// Pane hosting (Pro shell). When this app renders inside a Pro pane, the
|
||||
// pane publishes its width and id; `isMobile` above is then pane-based,
|
||||
// and layout that would use viewport-fixed positioning must scope itself
|
||||
@@ -3558,8 +3561,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
// Mountain View reads a message inside its conversation, as Gmail does: load
|
||||
// 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 mvConversationThreadId = mvConversationView && !cursorOnly ? (selectedEmail?.threadId ?? null) : null;
|
||||
const mvConversationRef = mvConversationView && !cursorOnly ? selectedEmail : null;
|
||||
const mvConversationRoute = (ref: Email, fallback: NonNullable<typeof client>) => {
|
||||
const route = resolveThreadRoute({ isUnifiedView, ref, mailboxes: viewMailboxes, selectedMailbox });
|
||||
const threadClient = route.clientAccountId
|
||||
@@ -4833,10 +4836,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
total: Math.max(activeEmails.length, mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails ?? 0),
|
||||
} : undefined}
|
||||
mvFolderName={mvShell && !isScheduledView && !isUnifiedView ? currentMailboxName : undefined}
|
||||
mvConversation={mvShell ? mvConversation ?? undefined : undefined}
|
||||
onLoadConversationEmail={mvShell ? (e) => void handleLoadConversationEmail(e) : undefined}
|
||||
onMarkConversationEmailRead={mvShell ? (e) => void handleMarkConversationEmailRead(e) : undefined}
|
||||
onReplyConversationEmail={mvShell ? async (e) => {
|
||||
mvConversation={mvConversationView ? mvConversation ?? undefined : undefined}
|
||||
onLoadConversationEmail={mvConversationView ? (e) => void handleLoadConversationEmail(e) : undefined}
|
||||
onMarkConversationEmailRead={mvConversationView ? (e) => void handleMarkConversationEmailRead(e) : undefined}
|
||||
onReplyConversationEmail={mvConversationView ? async (e) => {
|
||||
// The reply quotes the message, so it needs the body.
|
||||
const full = e.bodyValues ? e : await handleLoadConversationEmail(e);
|
||||
await handleConversationReply(full ?? e);
|
||||
|
||||
Reference in New Issue
Block a user