fix: keep the message details panel readable in a narrow reader, with addresses, dates and sign-in results that fit

This commit is contained in:
Linus Rath
2026-11-04 05:58:47 +01:00
parent cb183bc45f
commit 53eb91ab0e
6 changed files with 198 additions and 21 deletions
@@ -0,0 +1,84 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
// The message details panel in a narrow reader: the two columns followed the
// window's width, not the panel's, so they stayed side by side in a narrow pane;
// the sender's address went letter by letter down a sliver of a column, dates took
// three lines, and long sign-in results ran off the side.
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() }),
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net';
const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net';
const email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello',
hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } },
} as unknown as Email;
function openDetails() {
render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" isLoading={false} />);
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
const heading = screen.getByText('details.recipients_routing');
return heading.closest('section')!;
}
describe('message details panel', () => {
it('lays out by its own width, not the window', () => {
const routing = openDetails();
const grid = routing.parentElement!;
expect(grid.className).toMatch(/@3xl:grid-cols-2/);
expect(grid.className).not.toMatch(/\blg:grid-cols-2/);
expect(grid.parentElement!.className).toMatch(/@container/);
// The value column may shrink below its content's width.
expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)');
});
it('shows the sender name and address apart, the address breakable at its dots', () => {
const routing = openDetails();
expect(routing.textContent).not.toContain(`<${ADDRESS}>`);
const address = routing.querySelector(`[title="${ADDRESS}"]`)!;
expect(address.textContent).toBe(ADDRESS);
expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2);
});
it('shows dates short, with the full date on hover', () => {
const routing = openDetails();
const times = routing.querySelectorAll('time');
expect(times.length).toBe(2);
for (const time of Array.from(times)) {
expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length);
}
});
it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => {
openDetails();
const domain = screen.getByText(SPF_DOMAIN);
expect(domain.className).toContain('truncate');
expect(domain.getAttribute('title')).toBe(SPF_DOMAIN);
expect(domain.parentElement!.className).toContain('max-w-full');
});
});
+32 -19
View File
@@ -155,6 +155,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { BreakableAddress } from "@/components/ui/breakable-address";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -5413,6 +5414,11 @@ ${printWatermark ? printWatermark.html : ''}
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
// A long date wraps over three lines in the narrow column: the short one
// shows, the full one is on hover.
const shownDate = (iso?: string) => iso
? <time dateTime={iso} title={fullDate(iso)}>{formatDateTime(iso, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' })}</time>
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
@@ -5423,7 +5429,7 @@ ${printWatermark ? printWatermark.html : ''}
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dt className="text-muted-foreground text-xs @sm:pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
@@ -5440,7 +5446,7 @@ ${printWatermark ? printWatermark.html : ''}
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
@@ -5450,14 +5456,14 @@ ${printWatermark ? printWatermark.html : ''}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[0.6875rem]", status.color)}>
<span className="font-medium text-foreground whitespace-nowrap">{name}</span>
<span className={cn("text-[0.6875rem] whitespace-nowrap", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{extra}</span>
<span className="text-muted-foreground truncate min-w-0" title={typeof extra === 'string' ? extra : undefined}>{extra}</span>
</>
)}
</span>
@@ -5478,17 +5484,24 @@ ${printWatermark ? printWatermark.html : ''}
);
return (
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<div className="@container bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 @3xl:grid-cols-2 gap-x-10 gap-y-5">
<section className="min-w-0">
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
label={sender?.name && sender?.email ? (
<>
<bdi className="block">{sender.name}</bdi>
<bdi className="block text-muted-foreground" title={sender.email}>
<BreakableAddress text={sender.email} />
</bdi>
</>
) : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
@@ -5542,10 +5555,10 @@ ${printWatermark ? printWatermark.html : ''}
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
<Row label={t('details.sent')}>{shownDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{shownDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
@@ -5590,7 +5603,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
@@ -5613,7 +5626,7 @@ ${printWatermark ? printWatermark.html : ''}
{email.spamStatus && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
<span className="text-muted-foreground truncate min-w-0" title={email.spamStatus}>{email.spamStatus}</span>
</>
)}
</span>
@@ -5644,9 +5657,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasIdentifiers && (
<section className="min-w-0">
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
<Row label={t('headers.message_id')} mono><BreakableAddress text={email.messageId} /></Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
@@ -5678,7 +5691,7 @@ ${printWatermark ? printWatermark.html : ''}
<section className="min-w-0">
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
@@ -5701,9 +5714,9 @@ ${printWatermark ? printWatermark.html : ''}
</section>
{hasListInfo && (
<section className="lg:col-span-2 min-w-0">
<section className="@3xl:col-span-2 min-w-0">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
@@ -5730,7 +5743,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<section className="@3xl:col-span-2 min-w-0">
<DetailsCategorySlot email={pluginEmailView} category={null} />
</section>
)}
+4 -2
View File
@@ -251,12 +251,14 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Rich content in place of the text label, such as a name over its address. */
label?: React.ReactNode;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
<bdi>{displayLabel || name || shownEmail}</bdi>
{label ?? <bdi>{displayLabel || name || shownEmail}</bdi>}
</button>
{anchor && (
+17
View File
@@ -0,0 +1,17 @@
import { Fragment } from "react";
import { addressBreakPieces } from "@/lib/text-breaks";
/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */
export function BreakableAddress({ text }: { text: string }) {
const pieces = addressBreakPieces(text);
return (
<>
{pieces.map((piece, index) => (
<Fragment key={index}>
{index > 0 && <wbr />}
{piece}
</Fragment>
))}
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { addressBreakPieces } from '../text-breaks';
describe('addressBreakPieces', () => {
it('breaks after the @ and before dots', () => {
expect(addressBreakPieces('dana.with.long@subdomain.example.net')).toEqual(['dana', '.with', '.long@', 'subdomain', '.example.net']);
});
it('breaks after + - and _', () => {
expect(addressBreakPieces('bounces+318-0dcc_x@em.example.org')).toEqual(['bounces+', '318-', '0dcc_', 'x@em', '.example.org']);
});
it('keeps short runs whole and loses no character', () => {
const text = 'a.b.c@d.io';
const pieces = addressBreakPieces(text);
expect(pieces.join('')).toBe(text);
expect(pieces.every((piece) => piece.length >= 3)).toBe(true);
});
it('never leaves the top-level domain alone on a line', () => {
expect(addressBreakPieces('someone@notifications.mail.example-service.example.net').at(-1)).toBe('.example.net');
});
it('leaves a name without break points as one piece', () => {
expect(addressBreakPieces('Anthropic')).toEqual(['Anthropic']);
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Where a long address may wrap: after "@", "+", "-" and "_", and before a dot, as
* printed addresses do. A browser on its own wraps such a string anywhere once it
* doesn't fit, which leaves ragged two-letter lines in a narrow column.
*
* Returns the pieces; a line break opportunity (<wbr>) goes between each two.
* Pieces are never shorter than MIN_PIECE characters: "a.b.c" stays whole. The
* last few characters (".net") stay with the piece before them.
*/
const MIN_PIECE = 3;
const MAX_TAIL = 4;
export function addressBreakPieces(text: string): string[] {
const pieces: string[] = [];
let current = '';
for (const ch of text) {
if (ch === '.' && current.length >= MIN_PIECE) {
pieces.push(current);
current = ch;
continue;
}
current += ch;
if ((ch === '@' || ch === '+' || ch === '-' || ch === '_') && current.length >= MIN_PIECE) {
pieces.push(current);
current = '';
}
}
if (current) {
// A short tail, such as ".net", joins the piece before it: no line holds only that.
if (current.length <= MAX_TAIL && pieces.length) pieces[pieces.length - 1] += current;
else pieces.push(current);
}
return pieces;
}