diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx new file mode 100644 index 000000000..15aa67822 --- /dev/null +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ReaderAttachments, splitFileName, type ReaderAttachmentItem } from '../reader-attachments'; + +// The reader's attachment bar: equal tiles that keep the extension in view, +// a download button that needs no hover, and a "Show N more" tile once a +// message carries more files than fit in two rows. + +const file = (id: string, name: string, type = 'application/pdf', size = 1000): ReaderAttachmentItem => + ({ id, name, type, size, blobId: `blob-${id}` }); + +function renderBar(attachments: ReaderAttachmentItem[], overrides: Partial[0]> = {}) { + const props = { + attachments, + thumbUrls: {}, + opensPreview: () => false, + onOpen: vi.fn(), + onDownload: vi.fn(), + onDownloadAll: vi.fn(), + downloadingAll: false, + ...overrides, + }; + render(); + return props; +} + +describe('splitFileName', () => { + it('splits off a short extension', () => { + expect(splitFileName('prototype-v3.png')).toEqual(['prototype-v3', '.png']); + }); + + it('keeps names without a usable extension whole', () => { + expect(splitFileName('README')).toEqual(['README', '']); + expect(splitFileName('.env')).toEqual(['.env', '']); + expect(splitFileName('report.averyverylongsuffix')).toEqual(['report.averyverylongsuffix', '']); + }); +}); + +describe('ReaderAttachments bar', () => { + it('truncates the stem but never the extension', () => { + renderBar([file('a', 'Brand-guidelines-2026-final-v2.pdf')]); + const stem = screen.getByText('Brand-guidelines-2026-final-v2'); + const ext = screen.getByText('.pdf'); + expect(stem.className).toContain('truncate'); + expect(ext.className).toContain('shrink-0'); + expect(ext.className).not.toContain('truncate'); + }); + + it('shows type and size under the name', () => { + renderBar([file('a', 'Invoice-2026-089.pdf', 'application/pdf', 51200)]); + const tile = screen.getByTestId('attachment'); + expect(tile.textContent).toContain('PDF · '); + expect(tile.textContent).toContain('50 KB'); + }); + + it('opens the file from the tile and downloads it from its own button', () => { + const a = file('a', 'Invoice.pdf'); + const props = renderBar([a]); + const tile = screen.getByTestId('attachment'); + const [open, download] = within(tile).getAllByRole('button'); + fireEvent.click(open); + expect(props.onOpen).toHaveBeenCalledWith(a); + expect(props.onDownload).not.toHaveBeenCalled(); + fireEvent.click(download); + expect(props.onDownload).toHaveBeenCalledWith(a); + }); + + it('offers Download all only for two or more files', () => { + renderBar([file('a', 'one.pdf')]); + expect(screen.queryByTitle('download_all')).toBeNull(); + }); + + it('bundles every file from the summary line', () => { + const props = renderBar([file('a', 'one.pdf'), file('b', 'two.pdf')]); + fireEvent.click(screen.getByTitle('download_all')); + expect(props.onDownloadAll).toHaveBeenCalledTimes(1); + }); + + it('collapses past six files into a "Show N more" tile and expands in place', () => { + const files = Array.from({ length: 8 }, (_, i) => file(String(i), `file-${i}.pdf`)); + renderBar(files); + expect(screen.getAllByTestId('attachment')).toHaveLength(5); + const more = screen.getByRole('button', { name: /attachments_show_more/ }); + expect(more.textContent).toContain('file-5.pdf, file-6.pdf, file-7.pdf'); + fireEvent.click(more); + expect(screen.getAllByTestId('attachment')).toHaveLength(8); + fireEvent.click(screen.getByRole('button', { name: /attachments_show_fewer/ })); + expect(screen.getAllByTestId('attachment')).toHaveLength(5); + }); + + it('shows six files without collapsing', () => { + renderBar(Array.from({ length: 6 }, (_, i) => file(String(i), `file-${i}.pdf`))); + expect(screen.getAllByTestId('attachment')).toHaveLength(6); + expect(screen.queryByRole('button', { name: /attachments_show_more/ })).toBeNull(); + }); + + it('draws the image itself when there is a thumbnail', () => { + const { container } = render( + true} + onOpen={vi.fn()} + onDownload={vi.fn()} + onDownloadAll={vi.fn()} + downloadingAll={false} + />, + ); + expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:thumb'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1f61e39a9..5a071996b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -14,6 +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 { ReaderViewDialog } from "./reader-view-dialog"; import { ReadingMask } from "./reading-mask"; import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; @@ -1544,12 +1545,7 @@ export function EmailViewer({ const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false); - const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false); - const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false); const [isDownloadingAll, setIsDownloadingAll] = useState(false); - const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState(null); - const belowHeaderRowRef = useRef(null); - const belowHeaderGhostRef = useRef(null); const [imageThumbUrls, setImageThumbUrls] = useState>({}); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); @@ -2630,43 +2626,6 @@ export function EmailViewer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [email?.attachments, email?.htmlBody, email?.textBody, email?.bodyValues, pluginRenderedAttachments, pluginRenderedHtml, tnefHtml, tnefText, tnefAttachments, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); - // Measure attachment chips in the below-header row to determine how many fit - // on a single line; the rest collapse into a "+N attachments" overflow pill. - useLayoutEffect(() => { - if (attachmentPosition !== 'below-header' || effectiveAttachments.length === 0) { - setVisibleBelowHeaderCount(null); - return; - } - const container = belowHeaderRowRef.current; - const ghost = belowHeaderGhostRef.current; - if (!container || !ghost) return; - - const measure = () => { - const containerWidth = container.clientWidth; - const chips = Array.from(ghost.children) as HTMLElement[]; - if (chips.length === 0) return; - const ghostLeft = ghost.getBoundingClientRect().left; - // Reserve space for the "+N attachments" overflow pill - const RESERVED = 140; - let count = chips.length; - for (let i = 0; i < chips.length; i++) { - const right = chips[i].getBoundingClientRect().right - ghostLeft; - const isLast = i === chips.length - 1; - const limit = isLast ? containerWidth : containerWidth - RESERVED; - if (right > limit) { - count = i; - break; - } - } - setVisibleBelowHeaderCount(count >= chips.length ? null : Math.max(1, count)); - }; - - measure(); - const ro = new ResizeObserver(measure); - ro.observe(container); - return () => ro.disconnect(); - }, [effectiveAttachments, attachmentPosition, imageThumbUrls]); - // Whether external resources must be blocked for the message on screen. // Shared by every body path - the message's own HTML, TNEF, unwrapped // message/rfc822, and plugin-rendered (decrypted) bodies - so the user's @@ -5910,364 +5869,32 @@ export function EmailViewer({ - {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} - {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && ( -
-
-
- {/* Hidden ghost row used purely for measuring chip widths */} - )} - {/* Mobile/Tablet Attachments */} - {effectiveAttachments.length > 0 && ( -
-
- {effectiveAttachments.slice(0, 2).map((attachment) => { - const { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name); - const isPreviewable = isAttachmentPreviewable(attachment); - const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; - const thumbUrl = imageThumbUrls[attachment.id]; - return ( - - {(dragProps) => (<> -
handleEffectiveAttachmentOpen(attachment)} - data-testid="attachment" - data-attachment-name={attachment.name} - draggable={dragProps.draggable} - onPointerEnter={dragProps.onPointerEnter} - onDragStart={dragProps.onDragStart} - onDragEnd={dragProps.onDragEnd} - > - {thumbUrl && ( -
- -
- )} -
- - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - - {/* Phones have no hover: the plugin menu stays visible. */} - {canSaveToFiles && attachment.blobId && ( - { e.stopPropagation(); setSaveToFilesTarget(attachment); }}> - - - )} - -
-
- - {opensPreview && ( - - )} -
-
- - )} -
- ); - })} - {effectiveAttachments.length > 2 && ( - - )} - {downloadAllButton} - {showAllMobileAttachments && effectiveAttachments.length > 2 && ( - <> -
setShowAllMobileAttachments(false)} /> -
- {effectiveAttachments.slice(2).map((attachment) => { - const { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name); - const isPreviewable = isAttachmentPreviewable(attachment); - const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; - return ( - - {(dragProps) => (<> -
{ handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }} - draggable={dragProps.draggable} - onPointerEnter={dragProps.onPointerEnter} - onDragStart={dragProps.onDragStart} - onDragEnd={dragProps.onDragEnd} - > - - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - - {canSaveToFiles && attachment.blobId && ( - { e.stopPropagation(); setSaveToFilesTarget(attachment); }}> - - - )} - -
- { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }} - > - - - {opensPreview && ( - { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }} - > - - - )} -
-
- - )} -
- ); - })} -
- - )} -
-
- )} - )}
diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx new file mode 100644 index 000000000..4eae6ccd6 --- /dev/null +++ b/components/email/reader-attachments.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState, type DragEvent, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; +import { getAttachmentDisplayName } from "@/lib/attachment-name"; +import { cn, formatFileSize } from "@/lib/utils"; +import { attachmentIcon } from "./attachment-chips"; + +export interface ReaderAttachmentItem { + id: string; + name: string | null; + type: string; + size: number; + blobId?: string; +} + +export interface AttachmentDragProps { + draggable: boolean; + onPointerEnter: () => void; + onDragStart: (e: DragEvent) => void; + onDragEnd: (e: DragEvent) => void; +} + +/** Past this many tiles the bar shows one row less and a "Show N more" tile. */ +export const BAR_COLLAPSE_AT = 6; + +/** + * Splits a file name so the extension can stay visible while the rest is + * cut short: "prototype-v3.png" → ["prototype-v3", ".png"]. Names without a + * short extension come back whole. + */ +export function splitFileName(name: string): [stem: string, ext: string] { + const dot = name.lastIndexOf("."); + if (dot <= 0 || name.length - dot > 8) return [name, ""]; + return [name.slice(0, dot), name.slice(dot)]; +} + +interface ReaderAttachmentsProps { + attachments: T[]; + /** Object URLs of image attachments, keyed by attachment id. */ + thumbUrls: Record; + /** Whether a click on the attachment previews it instead of downloading it. */ + opensPreview: (attachment: T) => boolean; + onOpen: (attachment: T) => void; + onDownload: (attachment: T) => void; + onDownloadAll: () => void; + downloadingAll: boolean; + /** Set when the Files app can take attachments. */ + onSaveToFiles?: (attachment: T) => void; + /** Plugin actions for one attachment; renders nothing when no plugin offers any. */ + pluginMenu?: (attachment: T) => ReactNode; + /** + * Wraps one tile, so the viewer can add drag-out and the plugin frames + * without this component knowing about blobs or plugins. + */ + renderItem?: (attachment: T, tile: (drag: AttachmentDragProps) => ReactNode) => ReactNode; + className?: string; +} + +const NO_DRAG: AttachmentDragProps = { + draggable: false, + onPointerEnter: () => {}, + onDragStart: () => {}, + onDragEnd: () => {}, +}; + +/** + * 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. + */ +export function ReaderAttachments({ + attachments, + thumbUrls, + opensPreview, + onOpen, + onDownload, + onDownloadAll, + downloadingAll, + onSaveToFiles, + pluginMenu, + renderItem, + className, +}: ReaderAttachmentsProps) { + const t = useTranslations("email_viewer"); + const tFiles = useTranslations("files"); + const [expanded, setExpanded] = useState(false); + + 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 shown = collapses && !expanded ? attachments.slice(0, BAR_COLLAPSE_AT - 1) : attachments; + const hidden = collapses && !expanded ? attachments.slice(BAR_COLLAPSE_AT - 1) : []; + + const tile = (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); + return ( +
+ + {/* The button covers the whole tile, so a click on the preview opens the file too. */} + +
+ {onSaveToFiles && attachment.blobId && ( + onSaveToFiles(attachment)}> + + + )} + {pluginMenu?.(attachment)} + onDownload(attachment)} + > + + +
+
+ ); + }; + + return ( +
+
+
+
+ {attachments.length > 1 && ( + + )} +
+ +
+ {shown.map((attachment) => ( +
+ {renderItem + ? renderItem(attachment, (drag) => tile(attachment, drag)) + : tile(attachment, NO_DRAG)} +
+ ))} + {hidden.length > 0 && ( + + )} + {collapses && expanded && ( + + )} +
+
+ ); +} + +/** + * The image itself when there is a thumbnail, else the file type's icon over + * its extension, in the type's colour. + */ +function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { + if (url) { + return ; + } + const { icon: Icon, className } = attachmentIcon(attachment.type, attachment.name); + return ( + + ); +} diff --git a/locales/de/common.json b/locales/de/common.json index 058ba86f9..e9b61c783 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -473,6 +473,9 @@ "source_truncated": "Angezeigt werden die ersten {size}. Exportieren Sie die Nachricht für den Rest.", "source_copied": "Quelltext in Zwischenablage kopiert", "attachments": "Anhänge", + "attachments_count": "{count, plural, one {# Anhang} other {# Anhänge}}", + "attachments_show_more": "{count} weitere anzeigen", + "attachments_show_fewer": "Weniger anzeigen", "important": "Wichtig", "download": "Herunterladen", "download_all": "Alle herunterladen", diff --git a/locales/en/common.json b/locales/en/common.json index ff2b1d98f..9e7128ca9 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -473,6 +473,9 @@ "source_truncated": "Showing the first {size}. Export the message for the rest.", "source_copied": "Source copied to clipboard", "attachments": "Attachments", + "attachments_count": "{count, plural, one {# attachment} other {# attachments}}", + "attachments_show_more": "Show {count} more", + "attachments_show_fewer": "Show fewer", "important": "Important", "download": "Download", "download_all": "Download all",