Files
paseo/packages/app/src/hooks/use-file-picker.ts
T
Mohamed BoudraandGitHub 65f1143e6a Attach dropped files in every composer (#1750)
* fix(app): attach dropped files in composer

Composer parents had to expose attachment callbacks, so New Workspace only wired image drops and JSON files never became file attachments. Centralize dropped-file ingestion in Composer so all composer surfaces share the same image and file path.

* fix(app): handle desktop dropped attachment paths
2026-06-27 09:03:04 +08:00

131 lines
3.4 KiB
TypeScript

import { useCallback, useRef } from "react";
import * as DocumentPicker from "expo-document-picker";
import { File } from "expo-file-system";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isWeb } from "@/constants/platform";
import { getMimeTypeFromPath } from "@/attachments/file-types";
import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file";
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
const dialog = getDesktopHost()?.dialog;
const dialogOpen = dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const selection = await dialogOpen({
directory: false,
multiple: true,
});
if (!selection) {
return null;
}
const paths = Array.isArray(selection) ? selection : [selection];
if (paths.length === 0) {
return null;
}
const result: PickedFile[] = [];
for (const filePath of paths) {
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
const mimeType = getMimeTypeFromPath(filePath);
const bytes = await readDesktopFileBytes(filePath);
result.push({ fileName, mimeType, bytes });
}
return result;
}
function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", async () => {
const files = Array.from(input.files ?? []);
if (files.length === 0) {
resolve(null);
return;
}
const result: PickedFile[] = [];
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer());
result.push({
fileName: file.name,
mimeType: file.type || getMimeTypeFromPath(file.name),
bytes,
});
}
resolve(result);
});
input.addEventListener("cancel", () => {
resolve(null);
});
document.body.appendChild(input);
input.click();
// Clean up after a short delay to allow the change event to fire
setTimeout(() => {
input.remove();
}, 60_000);
});
}
async function pickFilesWithDocumentPicker(): Promise<PickedFile[] | null> {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: true,
});
if (result.canceled || result.assets.length === 0) {
return null;
}
return await Promise.all(
result.assets.map(async (asset) => ({
fileName: asset.name,
mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name),
bytes: await new File(asset.uri).bytes(),
})),
);
}
export function useFilePicker() {
const isPickingRef = useRef(false);
const pickFiles = useCallback(async (): Promise<PickedFile[] | null> => {
if (isPickingRef.current) {
return null;
}
isPickingRef.current = true;
try {
if (isWeb && isElectronRuntime()) {
return await pickFilesWithDesktopDialog();
}
if (isWeb) {
return await pickFilesWithWebInput();
}
return await pickFilesWithDocumentPicker();
} catch (error) {
console.error("[FilePicker] Failed to pick files:", error);
throw error;
} finally {
isPickingRef.current = false;
}
}, []);
return { pickFiles };
}