fix: collapse below-header attachments to single row with overflow pill
This commit is contained in:
@@ -922,6 +922,10 @@ export function EmailViewer({
|
|||||||
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
const [showFullHeaders, setShowFullHeaders] = useState(false);
|
||||||
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
|
||||||
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
|
||||||
|
const [showAllBelowHeaderAttachments, setShowAllBelowHeaderAttachments] = useState(false);
|
||||||
|
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
|
||||||
|
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
|
||||||
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
const [allowExternalContent, setAllowExternalContent] = useState(false);
|
||||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||||
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||||
@@ -2131,6 +2135,43 @@ export function EmailViewer({
|
|||||||
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted];
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]);
|
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, 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]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
let source = '';
|
let source = '';
|
||||||
@@ -4397,15 +4438,41 @@ export function EmailViewer({
|
|||||||
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
|
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
|
||||||
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
|
{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="hidden lg:block bg-background border-b border-border px-4 lg:px-6 py-2">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div ref={belowHeaderRowRef} className="relative flex items-center gap-2 overflow-hidden">
|
||||||
{effectiveAttachments.map((attachment) => {
|
{/* Hidden ghost row used purely for measuring chip widths */}
|
||||||
|
<div
|
||||||
|
ref={belowHeaderGhostRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-y-0 left-0 right-0 flex items-center gap-2 invisible pointer-events-none whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{effectiveAttachments.map((attachment) => {
|
||||||
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
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">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{effectiveAttachments
|
||||||
|
.slice(0, visibleBelowHeaderCount ?? effectiveAttachments.length)
|
||||||
|
.map((attachment) => {
|
||||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/60 rounded-md border border-border/50 group relative cursor-default flex-shrink-0"
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm text-foreground truncate max-w-[200px]">
|
<span className="text-sm text-foreground truncate max-w-[200px]">
|
||||||
@@ -4435,6 +4502,58 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{showAllBelowHeaderAttachments && visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowAllBelowHeaderAttachments(false)} />
|
||||||
|
<div className="absolute top-full right-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 FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
||||||
|
>
|
||||||
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-auto flex-shrink-0">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 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')}
|
||||||
|
onClick={() => { 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')}
|
||||||
|
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user