feat: image attachment thumbnails and preview chips
This commit is contained in:
@@ -897,6 +897,7 @@ export function EmailViewer({
|
|||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
|
||||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||||
|
const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled);
|
||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -926,6 +927,7 @@ export function EmailViewer({
|
|||||||
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
|
const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState<number | null>(null);
|
||||||
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
|
const belowHeaderRowRef = useRef<HTMLDivElement>(null);
|
||||||
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
|
const belowHeaderGhostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [imageThumbUrls, setImageThumbUrls] = useState<Record<string, string>>({});
|
||||||
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>>({});
|
||||||
@@ -2170,7 +2172,7 @@ export function EmailViewer({
|
|||||||
const ro = new ResizeObserver(measure);
|
const ro = new ResizeObserver(measure);
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [effectiveAttachments, attachmentPosition]);
|
}, [effectiveAttachments, attachmentPosition, imageThumbUrls]);
|
||||||
|
|
||||||
// Generate email source for viewing
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
const generateEmailSource = (email: Email): string => {
|
||||||
@@ -2617,6 +2619,65 @@ export function EmailViewer({
|
|||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||||
}, [onDownloadAttachment]);
|
}, [onDownloadAttachment]);
|
||||||
|
|
||||||
|
// Pre-fetch object URLs for image attachments so their actual contents can be
|
||||||
|
// rendered as thumbnails inside the chip. Skips images larger than 10 MB.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const createdUrls: string[] = [];
|
||||||
|
|
||||||
|
if (!attachmentImagePreviewsEnabled) {
|
||||||
|
setImageThumbUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageAttachments = effectiveAttachments.filter(
|
||||||
|
(att) => (att.type || '').startsWith('image/') && att.size <= 10_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imageAttachments.length === 0) {
|
||||||
|
setImageThumbUrls({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
await Promise.all(imageAttachments.map(async (att) => {
|
||||||
|
let url: string | undefined;
|
||||||
|
try {
|
||||||
|
if (att.blobId && client) {
|
||||||
|
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
|
||||||
|
} else if (att.decryptedAttachment) {
|
||||||
|
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
|
||||||
|
if (!bytes || bytes.byteLength === 0) return;
|
||||||
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' }));
|
||||||
|
} else if (att.tnefData) {
|
||||||
|
const buffer = att.tnefData.buffer.slice(
|
||||||
|
att.tnefData.byteOffset,
|
||||||
|
att.tnefData.byteOffset + att.tnefData.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
url = URL.createObjectURL(new Blob([buffer], { type: att.type || 'application/octet-stream' }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url) return;
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createdUrls.push(url);
|
||||||
|
next[att.id] = url;
|
||||||
|
}));
|
||||||
|
if (!cancelled) setImageThumbUrls(next);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
};
|
||||||
|
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
|
||||||
|
|
||||||
// Iframe for rendering HTML emails true-to-life
|
// Iframe for rendering HTML emails true-to-life
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
|
||||||
@@ -4343,23 +4404,47 @@ export function EmailViewer({
|
|||||||
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';
|
||||||
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="inline-flex items-center gap-1.5 px-2 py-1 bg-muted/60 rounded-md border border-border/50 group relative cursor-default"
|
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-40"
|
||||||
|
: "inline-flex items-center gap-1.5 px-2 py-1",
|
||||||
|
)}
|
||||||
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
{thumbUrl && (
|
||||||
<span className="text-xs text-foreground truncate max-w-[140px]">
|
<div className="w-full h-16 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
<img src={thumbUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</span>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
)}
|
||||||
{formatFileSize(attachment.size)}
|
<div className={cn(
|
||||||
</span>
|
"flex items-center gap-1.5",
|
||||||
<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">
|
thumbUrl && "px-2 py-1 border-t border-border/50 w-full",
|
||||||
|
)}>
|
||||||
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<span className={cn(
|
||||||
|
"text-xs text-foreground truncate",
|
||||||
|
thumbUrl ? "flex-1 min-w-0" : "max-w-[140px]",
|
||||||
|
)}>
|
||||||
|
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||||
|
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-foreground" />
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4367,7 +4452,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4396,7 +4481,9 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
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); setShowAllBesideAttachments(false); }}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4409,7 +4496,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBesideAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-foreground" />
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4417,7 +4504,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4447,6 +4534,18 @@ export function EmailViewer({
|
|||||||
>
|
>
|
||||||
{effectiveAttachments.map((attachment) => {
|
{effectiveAttachments.map((attachment) => {
|
||||||
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
const FileIcon = getFileIcon(attachment.name || undefined, attachment.type);
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`ghost-${attachment.id}`}
|
key={`ghost-${attachment.id}`}
|
||||||
@@ -4469,23 +4568,52 @@ export function EmailViewer({
|
|||||||
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';
|
||||||
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
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 flex-shrink-0"
|
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)}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
{thumbUrl && (
|
||||||
<span className="text-sm text-foreground truncate max-w-[200px]">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
<img
|
||||||
</span>
|
src={thumbUrl}
|
||||||
<span className="text-xs text-muted-foreground">
|
alt=""
|
||||||
{formatFileSize(attachment.size)}
|
className="w-full h-full object-cover"
|
||||||
</span>
|
loading="lazy"
|
||||||
<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">
|
/>
|
||||||
|
</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="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<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">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
|
||||||
|
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-r-md rounded-l-none",
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 text-foreground" />
|
<Download className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4493,7 +4621,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4 text-foreground" />
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4521,7 +4649,9 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
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); }}
|
||||||
>
|
>
|
||||||
<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-[220px]">
|
<span className="text-sm text-foreground truncate max-w-[220px]">
|
||||||
@@ -4534,7 +4664,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllBelowHeaderAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllBelowHeaderAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 text-foreground" />
|
<Download className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4542,7 +4672,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4 text-foreground" />
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4566,23 +4696,47 @@ export function EmailViewer({
|
|||||||
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';
|
||||||
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
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={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)}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
{thumbUrl && (
|
||||||
<span className="text-sm text-foreground truncate max-w-[200px]">
|
<div className="w-full h-20 bg-background/40 flex items-center justify-center overflow-hidden">
|
||||||
{getAttachmentDisplayName(attachment.name, attachment.type)}
|
<img src={thumbUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
)}
|
||||||
{formatFileSize(attachment.size)}
|
<div className={cn(
|
||||||
</span>
|
"flex items-center gap-1.5",
|
||||||
<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">
|
thumbUrl && "px-2 py-1.5 border-t border-border/50 w-full",
|
||||||
|
)}>
|
||||||
|
<FileIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
<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">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
|
||||||
|
thumbUrl ? "top-1 right-1" : "inset-y-0 right-0 rounded-l-none rounded-r-md",
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => handleEffectiveAttachmentDownload(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); }}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 text-foreground" />
|
<Download className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4590,7 +4744,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4 text-foreground" />
|
<Eye className="w-4 h-4 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4618,7 +4772,9 @@ export function EmailViewer({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md group relative cursor-default w-full"
|
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); }}
|
||||||
>
|
>
|
||||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<FileIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-xs text-foreground truncate max-w-[180px]">
|
<span className="text-xs text-foreground truncate max-w-[180px]">
|
||||||
@@ -4631,7 +4787,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={t('download')}
|
title={t('download')}
|
||||||
onClick={() => { handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentDownload(attachment); setShowAllMobileAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-foreground" />
|
<Download className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -4639,7 +4795,7 @@ export function EmailViewer({
|
|||||||
<button
|
<button
|
||||||
className="p-1 hover:bg-accent rounded transition-colors"
|
className="p-1 hover:bg-accent rounded transition-colors"
|
||||||
title={tFiles('preview')}
|
title={tFiles('preview')}
|
||||||
onClick={() => { handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
onClick={(e) => { e.stopPropagation(); handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }}
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5 text-foreground" />
|
<Eye className="w-3.5 h-3.5 text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
@@ -5173,6 +5329,7 @@ export function EmailViewer({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ export function ReadingSettings() {
|
|||||||
hoverActionsMode,
|
hoverActionsMode,
|
||||||
hoverActionsCorner,
|
hoverActionsCorner,
|
||||||
hideInlineImageAttachments,
|
hideInlineImageAttachments,
|
||||||
|
attachmentImagePreviewsEnabled,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
} = useSettingsStore();
|
} = useSettingsStore();
|
||||||
|
|
||||||
@@ -206,6 +207,13 @@ export function ReadingSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('attachment_image_previews.label')} description={t('attachment_image_previews.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={attachmentImagePreviewsEnabled}
|
||||||
|
onChange={(checked) => updateSetting('attachmentImagePreviewsEnabled', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
{isFeatureEnabled('hoverActionsConfigEnabled') && (
|
||||||
<div className="py-3 border-b border-border space-y-3">
|
<div className="py-3 border-b border-border space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1009,6 +1009,10 @@
|
|||||||
"hide_inline_image_attachments": {
|
"hide_inline_image_attachments": {
|
||||||
"label": "Hide inline images from attachments",
|
"label": "Hide inline images from attachments",
|
||||||
"description": "Images embedded in the message body are not listed as separate attachments"
|
"description": "Images embedded in the message body are not listed as separate attachments"
|
||||||
|
},
|
||||||
|
"attachment_image_previews": {
|
||||||
|
"label": "Show image previews in attachments",
|
||||||
|
"description": "Render image attachments as thumbnail cards instead of generic file icons"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ interface SettingsState {
|
|||||||
// attachment list shown above the message body.
|
// attachment list shown above the message body.
|
||||||
hideInlineImageAttachments: boolean;
|
hideInlineImageAttachments: boolean;
|
||||||
|
|
||||||
|
// Render image attachments as thumbnail cards (preview the actual image
|
||||||
|
// contents inside the chip) instead of generic file icons.
|
||||||
|
attachmentImagePreviewsEnabled: boolean;
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: SidebarApp[];
|
sidebarApps: SidebarApp[];
|
||||||
keepAppsLoaded: boolean;
|
keepAppsLoaded: boolean;
|
||||||
@@ -384,6 +388,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
] as string[],
|
] as string[],
|
||||||
|
|
||||||
hideInlineImageAttachments: true,
|
hideInlineImageAttachments: true,
|
||||||
|
attachmentImagePreviewsEnabled: true,
|
||||||
|
|
||||||
// Sidebar Apps
|
// Sidebar Apps
|
||||||
sidebarApps: [] as SidebarApp[],
|
sidebarApps: [] as SidebarApp[],
|
||||||
@@ -491,6 +496,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||||
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
attachmentReminderKeywords: state.attachmentReminderKeywords,
|
||||||
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
hideInlineImageAttachments: state.hideInlineImageAttachments,
|
||||||
|
attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled,
|
||||||
sidebarApps: state.sidebarApps,
|
sidebarApps: state.sidebarApps,
|
||||||
keepAppsLoaded: state.keepAppsLoaded,
|
keepAppsLoaded: state.keepAppsLoaded,
|
||||||
debugMode: state.debugMode,
|
debugMode: state.debugMode,
|
||||||
|
|||||||
Reference in New Issue
Block a user