From 887b9c728c058ee090a48c2aa899347d90626a72 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 17:34:24 +0200 Subject: [PATCH] feat: drag attachments out to local file system #267 --- components/email/email-viewer.tsx | 99 ++++++++++++++++++++++-- hooks/use-attachment-drag.ts | 122 ++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 hooks/use-attachment-drag.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5f6fd253..bb682c2f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -96,6 +96,8 @@ import { usePluginStore } from "@/stores/plugin-store"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { emailHooks, uiHooks } from "@/lib/plugin-hooks"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; +import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; interface EmailViewerProps { email: Email | null; @@ -791,6 +793,48 @@ function ContactSidebarPanel({ ); } +interface DraggableAttachmentChipProps { + attachment: EffectiveAttachment; + client: IJMAPClient | null; + enabled: boolean; + children: (dragProps: { + draggable: boolean; + onPointerEnter: () => void; + onDragStart: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; + }) => React.ReactNode; +} + +function DraggableAttachmentChip({ attachment, client, enabled, children }: DraggableAttachmentChipProps) { + const source = useMemo(() => ({ + name: attachment.name || 'download', + type: attachment.type || 'application/octet-stream', + getBlobUrl: async () => { + if (attachment.blobId && client) { + try { + return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type); + } catch { + return null; + } + } + if (attachment.tnefData) { + const bytes = attachment.tnefData; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); + } + if (attachment.decryptedAttachment) { + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return URL.createObjectURL(new Blob([buffer], { type: attachment.type || 'application/octet-stream' })); + } + return null; + }, + }), [attachment, client]); + const drag = useAttachmentDrag(source, enabled); + return <>{children(drag)}; +} + function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return (
@@ -854,6 +898,7 @@ export function EmailViewer({ const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); + const dragOutActive = useMemo(() => isDragOutSupported(), []); const timeFormat = useSettingsStore((state) => state.timeFormat); const isFocusedMailLayout = mailLayout === 'focus'; @@ -4404,8 +4449,9 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( + + {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > {thumbUrl && (
@@ -4457,6 +4507,8 @@ export function EmailViewer({ )}
+ )} +
); })} {effectiveAttachments.length > 2 && ( @@ -4477,11 +4529,16 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( + + {(dragProps) => (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBesideAttachments(false); }} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > @@ -4509,6 +4566,8 @@ export function EmailViewer({ )}
+ )} + ); })} @@ -4762,8 +4821,9 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( + + {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > {thumbUrl && (
@@ -4820,6 +4884,8 @@ export function EmailViewer({ )}
+ )} +
); })} {visibleBelowHeaderCount !== null && effectiveAttachments.length > visibleBelowHeaderCount && ( @@ -4840,11 +4906,16 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( + + {(dragProps) => (
{ handleEffectiveAttachmentOpen(attachment); setShowAllBelowHeaderAttachments(false); }} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > @@ -4872,6 +4943,8 @@ export function EmailViewer({ )}
+ )} +
); })} @@ -4891,8 +4964,9 @@ export function EmailViewer({ const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; const thumbUrl = imageThumbUrls[attachment.id]; return ( + + {(dragProps) => (
handleEffectiveAttachmentOpen(attachment)} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > {thumbUrl && (
@@ -4944,6 +5022,8 @@ export function EmailViewer({ )}
+ )} +
); })} {effectiveAttachments.length > 2 && ( @@ -4963,11 +5043,16 @@ export function EmailViewer({ const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; return ( + + {(dragProps) => (
{ handleEffectiveAttachmentOpen(attachment); setShowAllMobileAttachments(false); }} + draggable={dragProps.draggable} + onPointerEnter={dragProps.onPointerEnter} + onDragStart={dragProps.onDragStart} + onDragEnd={dragProps.onDragEnd} > @@ -4995,6 +5080,8 @@ export function EmailViewer({ )}
+ )} +
); })} diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts new file mode 100644 index 00000000..ea564192 --- /dev/null +++ b/hooks/use-attachment-drag.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useCallback, useEffect, useRef, DragEvent } from "react"; + +// Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on +// drop to materialize a real file. Firefox and Safari ignore it, so we only +// enable drag-out where it actually works. +export function isDragOutSupported(): boolean { + if (typeof navigator === "undefined") return false; + const uaData = (navigator as { userAgentData?: { brands?: { brand: string }[] } }).userAgentData; + if (uaData?.brands?.length) { + return uaData.brands.some((b) => /Chromium|Google Chrome|Microsoft Edge|Brave|Opera/i.test(b.brand)); + } + const ua = navigator.userAgent || ""; + if (/Firefox|FxiOS/.test(ua)) return false; + if (/^((?!chrome|android).)*safari/i.test(ua)) return false; + return /Chrome|Chromium|Edg\//.test(ua); +} + +export interface AttachmentDragSource { + name: string; + type: string; + getBlobUrl: () => Promise; +} + +export interface UseAttachmentDragResult { + draggable: boolean; + onPointerEnter: () => void; + onDragStart: (e: DragEvent) => void; + onDragEnd: (e: DragEvent) => void; +} + +const NOOP_HANDLERS: UseAttachmentDragResult = { + draggable: false, + onPointerEnter: () => {}, + onDragStart: () => {}, + onDragEnd: () => {}, +}; + +export function useAttachmentDrag( + source: AttachmentDragSource, + enabled: boolean, +): UseAttachmentDragResult { + const urlRef = useRef(null); + const ownedRef = useRef(false); + const inFlightRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (urlRef.current && ownedRef.current) { + URL.revokeObjectURL(urlRef.current); + } + urlRef.current = null; + ownedRef.current = false; + inFlightRef.current = null; + }; + }, []); + + const prefetch = useCallback(() => { + if (!enabled) return; + if (urlRef.current || inFlightRef.current) return; + inFlightRef.current = source + .getBlobUrl() + .then((url) => { + if (url && !urlRef.current) { + urlRef.current = url; + // Mark as owned so we revoke on unmount. Callers that hand back a + // shared URL (e.g. a cached thumbnail blob URL) can return the same + // string each time — we still revoke once on unmount. + ownedRef.current = true; + } + return url; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + }, [enabled, source]); + + const handleDragStart = useCallback( + (e: DragEvent) => { + const url = urlRef.current; + const name = source.name || "download"; + const type = source.type || "application/octet-stream"; + + if (!url) { + // Blob isn't materialized yet. Kick off the fetch so the next attempt + // works, but cancel this drag so the user doesn't get a silent failure + // where the OS receives no file. + prefetch(); + e.preventDefault(); + return; + } + + // `DownloadURL` format: ::. Chromium reads this on + // drop and writes a real file at the destination. + e.dataTransfer.setData("DownloadURL", `${type}:${encodeURIComponent(name)}:${url}`); + e.dataTransfer.effectAllowed = "copyMove"; + }, + [source.name, source.type, prefetch], + ); + + const handleDragEnd = useCallback(() => { + // Keep the blob URL around briefly — Chromium asynchronously fetches the + // blob: URL after dragend fires, so revoking immediately races the OS. + if (urlRef.current && ownedRef.current) { + const url = urlRef.current; + urlRef.current = null; + ownedRef.current = false; + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + }, []); + + if (!enabled) return NOOP_HANDLERS; + + return { + draggable: true, + onPointerEnter: prefetch, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, + }; +}