feat: image attachment thumbnails and preview chips

This commit is contained in:
Linus Rath
2026-05-01 17:03:45 +02:00
parent 32135ddb95
commit 1e535e96a2
4 changed files with 218 additions and 43 deletions
+200 -43
View File
@@ -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>
); );
} }
+8
View File
@@ -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>
+4
View File
@@ -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": {
+6
View File
@@ -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,