From 417f51276590c8e216bc744508e33bc06b531ed1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:28:20 +0100 Subject: [PATCH] feat: add preview cards under the message as an attachment position --- .../__tests__/reader-attachments.test.tsx | 13 +++ .../viewer-attachment-layout.test.tsx | 92 +++++++++++++++++++ components/email/email-viewer.tsx | 71 +++++++++----- components/email/reader-attachments.tsx | 88 ++++++++++++++++-- components/settings/reading-settings.tsx | 5 +- locales/de/common.json | 5 +- locales/en/common.json | 5 +- stores/settings-store.ts | 3 +- 8 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 components/email/__tests__/viewer-attachment-layout.test.tsx diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 15aa67822..8e0f1fae4 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -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( ({ + 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, { + 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(); + 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(); + 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(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5a071996b..ddb75f8b4 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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) => , + renderItem: (attachment: EffectiveAttachment, tile: (drag: AttachmentDragProps) => React.ReactNode) => ( + + {(dragProps) => (<> + {tile(dragProps)} + + )} + + ), + }; + // Shared "Download all" chip, shown only when bundling is worthwhile (2+). const downloadAllButton = effectiveAttachments.length > 1 ? ( + )} {/* Attachments on the right (beside-sender mode) */} {attachmentPosition === 'beside-sender' && effectiveAttachments.length > 0 && (
@@ -5870,28 +5907,11 @@ export function EmailViewer({ {/* === 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 && (
- isAttachmentPreviewable(attachment) && mailAttachmentAction === 'preview'} - onOpen={handleEffectiveAttachmentOpen} - onDownload={handleEffectiveAttachmentDownload} - onDownloadAll={handleDownloadAllAttachments} - downloadingAll={isDownloadingAll} - onSaveToFiles={canSaveToFiles ? setSaveToFilesTarget : undefined} - pluginMenu={(attachment) => } - renderItem={(attachment, tile) => ( - - {(dragProps) => (<> - {tile(dragProps)} - - )} - - )} - /> +
)} @@ -5987,6 +6007,13 @@ export function EmailViewer({ )}
+ {/* === ATTACHMENTS as cards after the body (below-message mode) === */} + {attachmentPosition === 'below-message' && effectiveAttachments.length > 0 && !mvOpenFolded && !mvAwaitingConversation && ( +
+ +
+ )} + {/* Mountain View: the conversation's later messages, folded. */} diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 4eae6ccd6..231958bec 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -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 { attachments: T[]; + layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; /** 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({ attachments, + layout = "bar", thumbUrls, opensPreview, onOpen, @@ -92,7 +98,7 @@ export function ReaderAttachments({ 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({ ); }; + 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 ( +
+ + + {/* Over the preview, never over the name. Always shown where there is no hover. */} +
+ {onSaveToFiles && attachment.blobId && ( + onSaveToFiles(attachment)}> + + + )} + {pluginMenu?.(attachment)} + onDownload(attachment)}> + + +
+
+ ); + }; + + const draw = layout === "cards" ? card : tile; + return ( -
+
-
+
{shown.map((attachment) => (
{renderItem - ? renderItem(attachment, (drag) => tile(attachment, drag)) - : tile(attachment, NO_DRAG)} + ? renderItem(attachment, (drag) => draw(attachment, drag)) + : draw(attachment, NO_DRAG)}
))} {hidden.length > 0 && ( diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index 8f015cb2f..39cbd2790 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -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() {