From 1e535e96a21fe765f8224daf625f2eff4a9f4a19 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 1 May 2026 17:03:45 +0200 Subject: [PATCH] feat: image attachment thumbnails and preview chips --- components/email/email-viewer.tsx | 243 +++++++++++++++++++---- components/settings/reading-settings.tsx | 8 + locales/en/common.json | 4 + stores/settings-store.ts | 6 + 4 files changed, 218 insertions(+), 43 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index fa539625..234e401c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -897,6 +897,7 @@ export function EmailViewer({ const mailLayout = useSettingsStore((state) => state.mailLayout); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); + const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; @@ -926,6 +927,7 @@ export function EmailViewer({ const [visibleBelowHeaderCount, setVisibleBelowHeaderCount] = useState(null); const belowHeaderRowRef = useRef(null); const belowHeaderGhostRef = useRef(null); + const [imageThumbUrls, setImageThumbUrls] = useState>({}); const [allowExternalContent, setAllowExternalContent] = useState(false); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); @@ -2170,7 +2172,7 @@ export function EmailViewer({ const ro = new ResizeObserver(measure); ro.observe(container); return () => ro.disconnect(); - }, [effectiveAttachments, attachmentPosition]); + }, [effectiveAttachments, attachmentPosition, imageThumbUrls]); // Generate email source for viewing const generateEmailSource = (email: Email): string => { @@ -2617,6 +2619,65 @@ export function EmailViewer({ setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); }, [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 = {}; + 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 const iframeRef = useRef(null); @@ -4343,23 +4404,47 @@ export function EmailViewer({ const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + const thumbUrl = imageThumbUrls[attachment.id]; return (
handleEffectiveAttachmentOpen(attachment)} > - - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - -
+ {thumbUrl && ( +
+ +
+ )} +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+
@@ -4367,7 +4452,7 @@ export function EmailViewer({ @@ -4396,7 +4481,9 @@ export function EmailViewer({ return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }} > @@ -4409,7 +4496,7 @@ export function EmailViewer({ @@ -4417,7 +4504,7 @@ export function EmailViewer({ @@ -4447,6 +4534,18 @@ export function EmailViewer({ > {effectiveAttachments.map((attachment) => { 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 ( +
+ ); + } return (
handleEffectiveAttachmentOpen(attachment)} > - - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - -
+ {thumbUrl && ( +
+ +
+ )} +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+
@@ -4493,7 +4621,7 @@ export function EmailViewer({ @@ -4521,7 +4649,9 @@ export function EmailViewer({ return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }} > @@ -4534,7 +4664,7 @@ export function EmailViewer({ @@ -4542,7 +4672,7 @@ export function EmailViewer({ @@ -4566,23 +4696,47 @@ export function EmailViewer({ const FileIcon = getFileIcon(attachment.name || undefined, attachment.type); const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + const thumbUrl = imageThumbUrls[attachment.id]; return (
handleEffectiveAttachmentOpen(attachment)} > - - - {getAttachmentDisplayName(attachment.name, attachment.type)} - - - {formatFileSize(attachment.size)} - -
+ {thumbUrl && ( +
+ +
+ )} +
+ + + {getAttachmentDisplayName(attachment.name, attachment.type)} + + + {formatFileSize(attachment.size)} + +
+
@@ -4590,7 +4744,7 @@ export function EmailViewer({ @@ -4618,7 +4772,9 @@ export function EmailViewer({ return (
{ handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }} > @@ -4631,7 +4787,7 @@ export function EmailViewer({ @@ -4639,7 +4795,7 @@ export function EmailViewer({ @@ -5173,6 +5329,7 @@ export function EmailViewer({ }} /> )} +
); } \ No newline at end of file diff --git a/components/settings/reading-settings.tsx b/components/settings/reading-settings.tsx index dba33f49..96ae55dd 100644 --- a/components/settings/reading-settings.tsx +++ b/components/settings/reading-settings.tsx @@ -34,6 +34,7 @@ export function ReadingSettings() { hoverActionsMode, hoverActionsCorner, hideInlineImageAttachments, + attachmentImagePreviewsEnabled, updateSetting, } = useSettingsStore(); @@ -206,6 +207,13 @@ export function ReadingSettings() { /> + + updateSetting('attachmentImagePreviewsEnabled', checked)} + /> + + {isFeatureEnabled('hoverActionsConfigEnabled') && (
diff --git a/locales/en/common.json b/locales/en/common.json index 39cb5af0..91b0326c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1009,6 +1009,10 @@ "hide_inline_image_attachments": { "label": "Hide inline images from 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": { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 3c1bf5aa..a4256647 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -211,6 +211,10 @@ interface SettingsState { // attachment list shown above the message body. 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 sidebarApps: SidebarApp[]; keepAppsLoaded: boolean; @@ -384,6 +388,7 @@ const DEFAULT_SETTINGS = { ] as string[], hideInlineImageAttachments: true, + attachmentImagePreviewsEnabled: true, // Sidebar Apps sidebarApps: [] as SidebarApp[], @@ -491,6 +496,7 @@ export const useSettingsStore = create()( attachmentReminderEnabled: state.attachmentReminderEnabled, attachmentReminderKeywords: state.attachmentReminderKeywords, hideInlineImageAttachments: state.hideInlineImageAttachments, + attachmentImagePreviewsEnabled: state.attachmentImagePreviewsEnabled, sidebarApps: state.sidebarApps, keepAppsLoaded: state.keepAppsLoaded, debugMode: state.debugMode,