"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, }; }