feat: show attachments as a bar of equal tiles under the message header

This commit is contained in:
Linus Rath
2026-10-31 23:20:53 +01:00
parent beebbffe29
commit 77c23a9e56
5 changed files with 373 additions and 395 deletions
@@ -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<Parameters<typeof ReaderAttachments>[0]> = {}) {
const props = {
attachments,
thumbUrls: {},
opensPreview: () => false,
onOpen: vi.fn(),
onDownload: vi.fn(),
onDownloadAll: vi.fn(),
downloadingAll: false,
...overrides,
};
render(<ReaderAttachments {...props} />);
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(
<ReaderAttachments
attachments={[file('img', 'photo.png', 'image/png')]}
thumbUrls={{ img: 'blob:thumb' }}
opensPreview={() => true}
onOpen={vi.fn()}
onDownload={vi.fn()}
onDownloadAll={vi.fn()}
downloadingAll={false}
/>,
);
expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:thumb');
});
});
+22 -395
View File
@@ -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<number | null>(null);
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
const [imageThumbUrls, setImageThumbUrls] = useState<Record<string, string>>({});
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({
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
<div className="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
<div className="relative flex items-center gap-2">
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden flex-1 min-w-0">
{/* Hidden ghost row used purely for measuring chip widths */}
<div
ref={belowHeaderGhostRef}
aria-hidden="true"
className="absolute inset-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
>
{effectiveAttachments.map((attachment) => {
const { icon: FileIcon } = attachmentIcon(attachment.type, attachment.name);
const hasThumb = !!imageThumbUrls[attachment.id];
if (hasThumb) {
// Image chip is a fixed-width vertical card; only its width
// matters for the row-fit measurement.
return (
<div
key={`ghost-${attachment.id}`}
className="inline-block w-44 rounded-md border border-border/50 flex-shrink-0"
style={{ height: 1 }}
/>
);
}
return (
<div
key={`ghost-${attachment.id}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border border-border/50 flex-shrink-0"
>
<FileIcon className="w-4 h-4" />
<span className="text-sm truncate max-w-[200px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
);
})}
</div>
{effectiveAttachments
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
.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 (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
thumbUrl
? "inline-flex flex-col w-44"
: "inline-flex items-center gap-1.5 px-2.5 py-1.5",
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
<img
src={thumbUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
)}
<div className={cn(
"flex items-center gap-1.5",
thumbUrl && "px-2 py-1.5 border-t border-border/50 w-full",
)}>
<FileIcon className={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<span className={cn(
"text-sm text-foreground truncate",
thumbUrl ? "flex-1 min-w-0" : "max-w-[200px]",
)}>
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
</div>
<div className={cn(
"absolute rounded-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
{/* === ATTACHMENTS: a bar of tiles under the header. Beside-sender
mode keeps its own column on wide screens. === */}
{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>
);
})}
{visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<button
onClick={() => setShowAllBelowHeaderAttachments(!showAllBelowHeaderAttachments)}
className="inline-flex items-center px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-md border border-border/50 transition-colors flex-shrink-0"
>
+{effectiveAttachments.length - visibleBelowHeaderCount} {t('attachments').toLowerCase()}
</button>
</>)}
</DraggableAttachmentChip>
)}
</div>
{downloadAllButton}
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
<div className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[260px] max-h-[60vh] overflow-y-auto">
{effectiveAttachments.slice(visibleBelowHeaderCount).map((attachment) => {
const { icon: FileIcon, className: fileIconClass } = attachmentIcon(attachment.type, attachment.name);
const isPreviewable = isAttachmentPreviewable(attachment);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
<FileIcon className={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<span className="text-sm text-foreground truncate max-w-[220px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBelowHeaderAttachments(false); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
/>
</div>
)}
{/* Mobile/Tablet Attachments */}
{effectiveAttachments.length > 0 && (
<div className="lg:hidden bg-background border-b border-border px-4 py-2">
<div className="relative flex items-center gap-1.5 flex-wrap">
{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 (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
thumbUrl
? "inline-flex flex-col w-44"
: "inline-flex items-center gap-1.5 px-2.5 py-1.5",
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
{thumbUrl && (
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
<img src={thumbUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
</div>
)}
<div className={cn(
"flex items-center gap-1.5",
thumbUrl && "px-2 py-1.5 border-t border-border/50 w-full",
)}>
<FileIcon className={cn("w-4 h-4 flex-shrink-0", fileIconClass)} />
<span className={cn(
"text-sm text-foreground truncate",
thumbUrl ? "flex-1 min-w-0" : "max-w-[200px]",
)}>
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
aria-label={t('download_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
>
<Download className="w-4 h-4 text-foreground" />
</button>
{opensPreview && (
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={tFiles('preview')}
aria-label={t('preview_named', { name: getAttachmentDisplayName(attachment.name, attachment.type) })}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
>
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
{effectiveAttachments.length > 2 && (
<button
onClick={() => setShowAllMobileAttachments(!showAllMobileAttachments)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-0.5"
>
+{effectiveAttachments.length - 2} {t('more')}
</button>
)}
{downloadAllButton}
{showAllMobileAttachments && effectiveAttachments.length > 2 && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowAllMobileAttachments(false)} />
<div className="absolute top-full start-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 flex flex-col gap-1 min-w-[220px]">
{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 (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
onDragEnd={dragProps.onDragEnd}
>
<FileIcon className={cn("w-3.5 h-3.5 flex-shrink-0", fileIconClass)} />
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[0.625rem] text-muted-foreground ms-auto flex-shrink-0">
<bdi>{formatFileSize(attachment.size)}</bdi>
</span>
{canSaveToFiles && attachment.blobId && (
<IconButton label={t("save_to_files.title")} onClick={(e) => { e.stopPropagation(); setSaveToFilesTarget(attachment); }}>
<FolderInput className="w-4 h-4" />
</IconButton>
)}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<IconButton
label={t('download')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
>
<Download />
</IconButton>
{opensPreview && (
<IconButton
label={tFiles('preview')}
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
>
<Eye />
</IconButton>
)}
</div>
</div>
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
</div>
</>
)}
</div>
</div>
)}
</>)}
<div className="grow shrink-0 flex flex-col">
+234
View File
@@ -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<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => 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<T extends ReaderAttachmentItem> {
attachments: T[];
/** 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. */
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<T extends ReaderAttachmentItem>({
attachments,
thumbUrls,
opensPreview,
onOpen,
onDownload,
onDownloadAll,
downloadingAll,
onSaveToFiles,
pluginMenu,
renderItem,
className,
}: ReaderAttachmentsProps<T>) {
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 (
<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 h-16 min-w-0 items-center gap-3 rounded-lg border border-border bg-card ps-2 pe-1 transition-colors hover:bg-muted/50"
>
<AttachmentThumb attachment={attachment} url={thumbUrls[attachment.id]} label={kind} />
{/* The button covers the whole tile, so a click on the preview opens the file too. */}
<button
type="button"
onClick={() => onOpen(attachment)}
aria-label={preview ? t("preview_named", { name }) : t("download_named", { name })}
className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 self-stretch 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>
<div className="relative z-10 flex shrink-0 items-center">
{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")}
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={() => onDownload(attachment)}
>
<Download className="h-4 w-4" />
</IconButton>
</div>
</div>
);
};
return (
<section aria-label={t("attachments")} className={cn("flex flex-col gap-3", className)} data-testid="reader-attachments">
<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" />
<span className="truncate">
<span className="font-medium text-foreground">{t("attachments_count", { count: attachments.length })}</span>
{" · "}
<bdi>{formatFileSize(totalSize)}</bdi>
</span>
</div>
{attachments.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={onDownloadAll}
disabled={downloadingAll}
title={t("download_all")}
className="shrink-0 gap-1.5 disabled:cursor-wait"
>
{downloadingAll ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{t("download_all")}
</Button>
)}
</div>
<div className="grid 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)}
</div>
))}
{hidden.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex h-16 min-w-0 items-center justify-between gap-3 rounded-lg border border-border px-4 text-start transition-colors hover:bg-muted/50"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm text-foreground">{t("attachments_show_more", { count: hidden.length })}</span>
<span className="truncate text-xs text-muted-foreground" dir="auto">
{hidden.map((a) => getAttachmentDisplayName(a.name, a.type)).join(", ")}
</span>
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</button>
)}
{collapses && expanded && (
<button
type="button"
onClick={() => setExpanded(false)}
className="flex h-16 min-w-0 items-center justify-between gap-3 rounded-lg border border-border px-4 text-start text-sm text-foreground transition-colors hover:bg-muted/50"
>
{t("attachments_show_fewer")}
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</button>
)}
</div>
</section>
);
}
/**
* 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 <img src={url} alt="" loading="lazy" className="h-12 w-12 shrink-0 rounded-md bg-muted object-cover" />;
}
const { icon: Icon, className } = attachmentIcon(attachment.type, attachment.name);
return (
<div aria-hidden="true" className="flex h-12 w-12 shrink-0 flex-col items-center justify-center gap-0.5 rounded-md bg-muted">
<Icon className={cn("h-5 w-5", className)} />
{label && label.length <= 5 && (
<span className={cn("text-[0.625rem] font-semibold leading-none", className)}>{label}</span>
)}
</div>
);
}
+3
View File
@@ -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",
+3
View File
@@ -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",