import { useState, useRef, useEffect } from "react"; import type { ImageAttachment } from "@/components/message-input"; import { getDesktopHost } from "@/desktop/host"; import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; import { isWeb } from "@/constants/platform"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; disabled?: boolean; } interface UseFileDropZoneReturn { isDragging: boolean; containerRef: React.RefObject; } const IS_WEB = isWeb; const IMAGE_MIME_BY_EXTENSION: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml", ".heic": "image/heic", ".heif": "image/heif", ".avif": "image/avif", ".tif": "image/tiff", ".tiff": "image/tiff", }; type DesktopDragDropPayload = | { type: "enter"; paths: string[]; } | { type: "over"; } | { type: "drop"; paths: string[]; } | { type: "leave"; }; interface DesktopDragDropEvent { payload: DesktopDragDropPayload; } function isImageFile(file: File): boolean { return file.type.startsWith("image/"); } function getFileExtension(path: string): string { const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path; const extensionIndex = normalizedPath.lastIndexOf("."); if (extensionIndex < 0) { return ""; } return normalizedPath.slice(extensionIndex).toLowerCase(); } function isImagePath(path: string): boolean { return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION; } async function filePathToImageAttachment(path: string): Promise { const extension = getFileExtension(path); const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; return await persistAttachmentFromFileUri({ uri: path, mimeType }); } async function fileToImageAttachment(file: File): Promise { return await persistAttachmentFromBlob({ blob: file, mimeType: file.type || "image/jpeg", fileName: file.name, }); } export function useFileDropZone({ onFilesDropped, disabled = false, }: UseFileDropZoneOptions): UseFileDropZoneReturn { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); const dragCounterRef = useRef(0); const onFilesDroppedRef = useRef(onFilesDropped); // Keep callback ref up to date useEffect(() => { onFilesDroppedRef.current = onFilesDropped; }, [onFilesDropped]); // Reset drag state when disabled changes useEffect(() => { if (disabled) { setIsDragging(false); dragCounterRef.current = 0; } }, [disabled]); // Set up event listeners on web useEffect(() => { if (!IS_WEB) return; let disposed = false; let cleanup: (() => void) | undefined; let didCleanup = false; function runCleanup(unlisten?: () => void | Promise) { if (didCleanup) return; const cleanupFn = unlisten ?? cleanup; if (!cleanupFn) return; didCleanup = true; try { void Promise.resolve(cleanupFn()).catch((error) => { console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error); }); } catch (error) { console.warn("[useFileDropZone] Failed to remove desktop drag-drop listener:", error); } } async function setupDesktopDragDrop(): Promise { const desktopHost = getDesktopHost(); if (desktopHost === null) { return false; } const desktopWindow = desktopHost.window?.getCurrentWindow?.(); if (!desktopWindow || typeof desktopWindow.onDragDropEvent !== "function") { return false; } try { const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => { const payload = event.payload; if (payload.type === "leave") { setIsDragging(false); return; } if (payload.type === "enter" || payload.type === "over") { if (!disabled) { setIsDragging(true); } return; } // Drop always ends the current drag operation. setIsDragging(false); if (disabled) return; const imagePaths = payload.paths.filter(isImagePath); if (imagePaths.length === 0) { return; } void Promise.all(imagePaths.map(filePathToImageAttachment)) .then((attachments) => { if (attachments.length === 0) { return; } onFilesDroppedRef.current(attachments); return; }) .catch((error) => { console.error("[useFileDropZone] Failed to persist dropped files:", error); }); }); if (disposed) { runCleanup(unlisten); return true; } cleanup = unlisten; return true; } catch (error) { console.warn("[useFileDropZone] Failed to listen for desktop drag-drop:", error); return false; } } function setupDomDragDrop() { const element = containerRef.current; if (!element) { return; } function handleDragEnter(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (disabled) return; dragCounterRef.current++; if (e.dataTransfer?.types.includes("Files")) { setIsDragging(true); } } function handleDragOver(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (disabled) return; if (e.dataTransfer) { e.dataTransfer.dropEffect = "copy"; } } function handleDragLeave(e: DragEvent) { e.preventDefault(); e.stopPropagation(); if (disabled) return; dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragging(false); } } async function handleDrop(e: DragEvent) { e.preventDefault(); e.stopPropagation(); setIsDragging(false); dragCounterRef.current = 0; if (disabled) return; const files = Array.from(e.dataTransfer?.files ?? []); const imageFiles = files.filter(isImageFile); if (imageFiles.length === 0) return; try { const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); onFilesDroppedRef.current(attachments); } catch (error) { console.error("[useFileDropZone] Failed to process dropped files:", error); } } element.addEventListener("dragenter", handleDragEnter); element.addEventListener("dragover", handleDragOver); element.addEventListener("dragleave", handleDragLeave); element.addEventListener("drop", handleDrop); cleanup = () => { element.removeEventListener("dragenter", handleDragEnter); element.removeEventListener("dragover", handleDragOver); element.removeEventListener("dragleave", handleDragLeave); element.removeEventListener("drop", handleDrop); }; } void (async () => { const desktopListenersAttached = await setupDesktopDragDrop(); if (disposed || desktopListenersAttached) { return; } setupDomDragDrop(); })(); return () => { disposed = true; runCleanup(); }; }, [disabled]); return { isDragging, containerRef, }; }