feat: add preview cards under the message as an attachment position

This commit is contained in:
Linus Rath
2026-10-31 23:28:20 +01:00
parent 77c23a9e56
commit 417f512765
8 changed files with 245 additions and 37 deletions
@@ -94,6 +94,19 @@ describe('ReaderAttachments bar', () => {
expect(screen.queryByRole('button', { name: /attachments_show_more/ })).toBeNull();
});
it('shows every file as a card, without collapsing, in cards layout', () => {
const files = Array.from({ length: 8 }, (_, i) => file(String(i), `file-${i}.pdf`));
const props = renderBar(files, { layout: 'cards' });
expect(screen.getByTestId('reader-attachments').dataset.layout).toBe('cards');
expect(screen.getAllByTestId('attachment')).toHaveLength(8);
expect(screen.queryByRole('button', { name: /attachments_show_more/ })).toBeNull();
const card = screen.getAllByTestId('attachment')[2];
const buttons = within(card).getAllByRole('button');
fireEvent.click(buttons[buttons.length - 1]);
expect(props.onDownload).toHaveBeenCalledWith(files[2]);
expect(props.onOpen).not.toHaveBeenCalled();
});
it('draws the image itself when there is a thumbnail', () => {
const { container } = render(
<ReaderAttachments
@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The attachment position setting decides where the reader draws the
// attachments: a bar of tiles above the body, or cards after it with a
// paperclip in the header that jumps down to them.
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 { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const email = {
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ name: 'Sarah Kim', email: 'sarah@example.com' }],
to: [{ email: 'me@example.com' }],
subject: 'Invoice and prototype',
receivedAt: '2026-01-01T00:00:00Z',
preview: 'Here is the invoice',
hasAttachment: true,
size: 100,
textBody: [{ partId: '1', type: 'text/plain' }],
htmlBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: 'Here is the invoice.' } },
attachments: [
{ partId: '2', blobId: 'blob-pdf', name: 'Invoice-2026-089.pdf', type: 'application/pdf', size: 51200, disposition: 'attachment' },
{ partId: '3', blobId: 'blob-zip', name: 'icons.zip', type: 'application/zip', size: 2048, disposition: 'attachment' },
],
} as unknown as Email;
const client = new Proxy({ getAccountId: () => 'me' } as Record<string, unknown>, {
get: (target, prop) => (prop in target ? target[prop as string] : prop === 'then' ? undefined : vi.fn().mockResolvedValue(null)),
}) as unknown as IJMAPClient;
const follows = (a: Element, b: Element) => !!(b.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING);
describe('attachment position in the reader', () => {
beforeEach(() => {
useAuthStore.setState({ client } as never);
useSettingsStore.setState({ markAsReadDelay: -1 });
});
it('draws a bar above the body below the header', async () => {
useSettingsStore.setState({ attachmentPosition: 'below-header' });
const { container } = render(<EmailViewer email={email} mailboxes={[]} selectedMailbox="inbox" />);
const body = await screen.findByText('Here is the invoice.');
const sections = container.querySelectorAll('[data-testid="reader-attachments"]');
expect(sections).toHaveLength(1);
expect((sections[0] as HTMLElement).dataset.layout).toBe('bar');
expect(follows(body, sections[0])).toBe(true);
expect(screen.queryByTestId('attachments-jump')).toBeNull();
});
it('draws cards after the body, with a jump link in the header', async () => {
useSettingsStore.setState({ attachmentPosition: 'below-message' });
const { container } = render(<EmailViewer email={email} mailboxes={[]} selectedMailbox="inbox" />);
const body = await screen.findByText('Here is the invoice.');
const sections = container.querySelectorAll('[data-testid="reader-attachments"]');
expect(sections).toHaveLength(1);
expect((sections[0] as HTMLElement).dataset.layout).toBe('cards');
expect(follows(sections[0], body)).toBe(true);
expect(sections[0].querySelectorAll('[data-testid="attachment"]')).toHaveLength(2);
const jump = screen.getByTestId('attachments-jump');
const scrollIntoView = vi.fn();
sections[0].parentElement!.scrollIntoView = scrollIntoView;
jump.click();
expect(scrollIntoView).toHaveBeenCalled();
});
});
+49 -22
View File
@@ -14,7 +14,7 @@ import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { attachmentIcon } from "./attachment-chips";
import { ReaderAttachments } from "./reader-attachments";
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
import { ReaderViewDialog } from "./reader-view-dialog";
import { ReadingMask } from "./reading-mask";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
@@ -113,6 +113,7 @@ import {
BookOpen,
ArrowLeft,
Pencil,
Paperclip,
} from "@/components/icons";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -3040,6 +3041,28 @@ export function EmailViewer({
}
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email, t, tFiles]);
// What the attachment bar and the attachment cards share.
const attachmentCardsRef = useRef<HTMLDivElement>(null);
const readerAttachmentProps = {
attachments: effectiveAttachments,
thumbUrls: imageThumbUrls,
opensPreview: (attachment: EffectiveAttachment) => isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview',
onOpen: handleEffectiveAttachmentOpen,
onDownload: handleEffectiveAttachmentDownload,
onDownloadAll: handleDownloadAllAttachments,
downloadingAll: isDownloadingAll,
onSaveToFiles: canSaveToFiles ? setSaveToFilesTarget : undefined,
pluginMenu: (attachment: EffectiveAttachment) => <AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="p-2 rounded-md" />,
renderItem: (attachment: EffectiveAttachment, tile: (drag: AttachmentDragProps) => React.ReactNode) => (
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
{tile(dragProps)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
),
};
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
<button
@@ -5031,6 +5054,20 @@ export function EmailViewer({
</div>
{/* Cards mode: a paperclip with the count jumps down to the cards. */}
{attachmentPosition === 'below-message' && effectiveAttachments.length > 0 && (
<button
type="button"
onClick={() => attachmentCardsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
title={t('attachments')}
aria-label={t('attachments_count', { count: effectiveAttachments.length })}
className="self-start flex-shrink-0 inline-flex items-center gap-1.5 h-8 px-2 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
data-testid="attachments-jump"
>
<Paperclip className="w-4 h-4" />
<bdi>{effectiveAttachments.length}</bdi>
</button>
)}
{/* Attachments on the right (beside-sender mode) */}
{attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
<div className="relative flex flex-col items-end justify-start gap-1 flex-shrink-0 max-w-[50%]">
@@ -5870,28 +5907,11 @@ export function EmailViewer({
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
{/* === ATTACHMENTS: a bar of tiles under the header. Beside-sender
mode keeps its own column on wide screens. === */}
{effectiveAttachments.length > 0 && (
mode keeps its own column on wide screens; cards mode draws them
after the body instead. === */}
{attachmentPosition !== 'below-message' && effectiveAttachments.length > 0 && (
<div className={cn("bg-background border-b border-border px-4 lg:px-6 py-3", attachmentPosition === 'beside-sender' && "lg:hidden")}>
<ReaderAttachments
attachments={effectiveAttachments}
thumbUrls={imageThumbUrls}
opensPreview={(attachment) => isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview'}
onOpen={handleEffectiveAttachmentOpen}
onDownload={handleEffectiveAttachmentDownload}
onDownloadAll={handleDownloadAllAttachments}
downloadingAll={isDownloadingAll}
onSaveToFiles={canSaveToFiles ? setSaveToFilesTarget : undefined}
pluginMenu={(attachment) => <AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="p-2 rounded-md" />}
renderItem={(attachment, tile) => (
<DraggableAttachmentChip attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
{tile(dragProps)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
)}
/>
<ReaderAttachments {...readerAttachmentProps} layout="bar" />
</div>
)}
@@ -5987,6 +6007,13 @@ export function EmailViewer({
)}
</div>
{/* === ATTACHMENTS as cards after the body (below-message mode) === */}
{attachmentPosition === 'below-message' && effectiveAttachments.length > 0 && !mvOpenFolded && !mvAwaitingConversation && (
<div ref={attachmentCardsRef} className={cn("bg-background border-t border-border px-4 lg:px-6 py-4 scroll-mt-4", mvViewer && "lg:ps-[60px]")}>
<ReaderAttachments {...readerAttachmentProps} layout="cards" />
</div>
)}
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* Mountain View: the conversation's later messages, folded. */}
+80 -8
View File
@@ -17,6 +17,9 @@ export interface ReaderAttachmentItem {
blobId?: string;
}
/** "bar": tiles under the header. "cards": larger previews under the message. */
export type ReaderAttachmentLayout = "bar" | "cards";
export interface AttachmentDragProps {
draggable: boolean;
onPointerEnter: () => void;
@@ -40,6 +43,7 @@ export function splitFileName(name: string): [stem: string, ext: string] {
interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
attachments: T[];
layout?: ReaderAttachmentLayout;
/** Object URLs of image attachments, keyed by attachment id. */
thumbUrls: Record<string, string>;
/** Whether a click on the attachment previews it instead of downloading it. */
@@ -68,12 +72,14 @@ const NO_DRAG: AttachmentDragProps = {
};
/**
* The attachments of an open message as a bar of equal tiles: a summary line
* with "Download all", then one tile per file with its preview, name, type,
* size and a download button that is always there.
* The attachments of an open message: a summary line with "Download all",
* then either a bar of equal tiles (preview, name, type, size and a download
* button that is always there) or larger preview cards whose actions show
* on hover, focus and touch screens.
*/
export function ReaderAttachments<T extends ReaderAttachmentItem>({
attachments,
layout = "bar",
thumbUrls,
opensPreview,
onOpen,
@@ -92,7 +98,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
if (attachments.length === 0) return null;
const totalSize = attachments.reduce((sum, a) => sum + (a.size || 0), 0);
const collapses = attachments.length > BAR_COLLAPSE_AT;
const collapses = layout === "bar" && attachments.length > BAR_COLLAPSE_AT;
const shown = collapses && !expanded ? attachments.slice(0, BAR_COLLAPSE_AT - 1) : attachments;
const hidden = collapses && !expanded ? attachments.slice(BAR_COLLAPSE_AT - 1) : [];
@@ -150,8 +156,71 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
);
};
const card = (attachment: T, drag: AttachmentDragProps) => {
const name = getAttachmentDisplayName(attachment.name, attachment.type);
const [stem, ext] = splitFileName(name);
const kind = ext ? ext.slice(1).toUpperCase() : "";
const preview = opensPreview(attachment);
const url = thumbUrls[attachment.id];
const { icon: Icon, className: iconClass } = attachmentIcon(attachment.type, attachment.name);
return (
<div
data-testid="attachment"
data-attachment-name={attachment.name ?? undefined}
title={`${preview ? tFiles("preview") : t("download")} ${name}`}
draggable={drag.draggable}
onPointerEnter={drag.onPointerEnter}
onDragStart={drag.onDragStart}
onDragEnd={drag.onDragEnd}
className="group relative flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card transition-colors hover:bg-muted/50"
>
<div aria-hidden="true" className="h-32 bg-muted/60">
{url ? (
<img src={url} alt="" loading="lazy" className="h-full w-full object-cover" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-2">
<Icon className={cn("h-10 w-10", iconClass)} />
{kind && kind.length <= 5 && (
<span className={cn("text-xs font-semibold leading-none", iconClass)}>{kind}</span>
)}
</div>
)}
</div>
<button
type="button"
onClick={() => onOpen(attachment)}
aria-label={preview ? t("preview_named", { name }) : t("download_named", { name })}
className="flex min-w-0 flex-col gap-0.5 border-t border-border px-3 py-2.5 text-start outline-none after:absolute after:inset-0 after:rounded-lg focus-visible:after:ring-2 focus-visible:after:ring-ring"
>
<span className="flex min-w-0 text-sm text-foreground" dir="auto">
<span className="truncate">{stem}</span>
{ext && <span className="shrink-0">{ext}</span>}
</span>
<span className="truncate text-xs text-muted-foreground">
{kind && <>{kind} · </>}
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</button>
{/* Over the preview, never over the name. Always shown where there is no hover. */}
<div className="absolute end-2 top-2 z-10 flex items-center gap-0.5 rounded-lg border border-border bg-background/90 p-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
{onSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={() => onSaveToFiles(attachment)}>
<FolderInput className="h-4 w-4" />
</IconButton>
)}
{pluginMenu?.(attachment)}
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
<Download className="h-4 w-4" />
</IconButton>
</div>
</div>
);
};
const draw = layout === "cards" ? card : tile;
return (
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments">
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments" data-layout={layout}>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Paperclip className="h-4 w-4 shrink-0" aria-hidden="true" />
@@ -176,12 +245,15 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
)}
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2">
<div className={cn(
"grid",
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
)}>
{shown.map((attachment) => (
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
{renderItem
? renderItem(attachment, (drag) => tile(attachment, drag))
: tile(attachment, NO_DRAG)}
? renderItem(attachment, (drag) => draw(attachment, drag))
: draw(attachment, NO_DRAG)}
</div>
))}
{hidden.length > 0 && (
+3 -2
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useSettingsStore, type AttachmentPosition } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { DownloadsSettings } from './downloads-settings';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
@@ -163,10 +163,11 @@ export function ReadingSettings() {
<SettingItem label={t('attachment_position.label')} description={t('attachment_position.description')}>
<Select
value={attachmentPosition}
onChange={(value) => updateSetting('attachmentPosition', value as 'beside-sender' | 'below-header')}
onChange={(value) => updateSetting('attachmentPosition', value as AttachmentPosition)}
options={[
{ value: 'beside-sender', label: t('attachment_position.beside-sender') },
{ value: 'below-header', label: t('attachment_position.below-header') },
{ value: 'below-message', label: t('attachment_position.below-message') },
]}
/>
</SettingItem>
+3 -2
View File
@@ -1902,9 +1902,10 @@
},
"attachment_position": {
"label": "Anhangsposition",
"description": "Wo Anhänge im E-Mail-Header angezeigt werden",
"description": "Wo die Anhänge einer geöffneten E-Mail angezeigt werden",
"beside-sender": "Neben dem Absender",
"below-header": "Unter dem Header"
"below-header": "Unter dem Header",
"below-message": "Als Karten unter der Nachricht"
},
"emails_per_page": {
"10": "10 E-Mails",
+3 -2
View File
@@ -1905,9 +1905,10 @@
},
"attachment_position": {
"label": "Attachment position",
"description": "Where to display attachments in the email header",
"description": "Where to display the attachments of an open email",
"beside-sender": "Next to sender",
"below-header": "Below header"
"below-header": "Below header",
"below-message": "Cards below the message"
},
"emails_per_page": {
"10": "10 emails",
+2 -1
View File
@@ -302,7 +302,8 @@ export type FirstDayOfWeek = 0 | 1 | 6; // 0 = Sunday, 1 = Monday, 6 = Saturday
export type TimeZoneSetting = 'auto' | (string & {});
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header';
/** 'below-message' shows larger preview cards after the body. */
export type AttachmentPosition = 'beside-sender' | 'below-header' | 'below-message';
export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus' | 'horizontal';