mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user