mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: show attachments as a bar of equal tiles under the message header
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user