fix: drag attachments and messages out of the desktop app with their bytes in hand

This commit is contained in:
Linus Rath
2026-11-03 10:30:37 +01:00
parent 947743cc9e
commit b805571dec
6 changed files with 101 additions and 38 deletions
+9
View File
@@ -685,6 +685,15 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlob: async () => {
if (attachment.blobId && client) {
return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
}
const bytes = attachment.tnefData ?? (attachment.decryptedAttachment ? getAttachmentContentBytes(attachment.decryptedAttachment) : null);
if (!bytes || bytes.byteLength === 0) return null;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return new Blob([buffer], { type: inertBlobType(attachment.type) });
},
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
@@ -734,11 +734,10 @@ describe('message row drag hover wiring', () => {
it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => {
const email = mail({ subject: 'Hello world' });
const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
await warm(result);
const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true);
window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never;
vi.stubGlobal('fetch', vi.fn(async (url: string) => new Response(await readBlob(objects.get(url)!))));
const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' }));
await warm(result);
try {
startDrag.mockClear();
// Plain: still a move to a folder, still DownloadURL for Explorer.
+31 -12
View File
@@ -34,6 +34,8 @@ export interface AttachmentDragSource {
name: string;
type: string;
getBlobUrl: () => Promise<string | null>;
/** The bytes themselves, for the desktop app's native drag. */
getBlob?: () => Promise<Blob | null>;
}
export interface UseAttachmentDragResult {
@@ -59,6 +61,8 @@ export function useAttachmentDrag(
const urlRef = useRef<string | null>(null);
const ownedRef = useRef<boolean>(false);
const inFlightRef = useRef<Promise<string | null> | null>(null);
const blobRef = useRef<Blob | null>(null);
const nativeDrag = canDragNativeFiles() && !!source.getBlob;
useEffect(() => {
return () => {
@@ -73,6 +77,19 @@ export function useAttachmentDrag(
const prefetch = useCallback(() => {
if (!enabled) return;
if (nativeDrag) {
if (blobRef.current || inFlightRef.current) return;
inFlightRef.current = source.getBlob!()
.then((blob) => {
blobRef.current = blob;
return null;
})
.catch(() => null)
.finally(() => {
inFlightRef.current = null;
});
return;
}
if (urlRef.current || inFlightRef.current) return;
inFlightRef.current = source
.getBlobUrl()
@@ -90,7 +107,7 @@ export function useAttachmentDrag(
.finally(() => {
inFlightRef.current = null;
});
}, [enabled, source]);
}, [enabled, source, nativeDrag]);
const handleDragStart = useCallback(
(e: DragEvent<HTMLDivElement>) => {
@@ -98,6 +115,15 @@ export function useAttachmentDrag(
const name = source.name || "download";
const type = source.type || "application/octet-stream";
// In the desktop app the OS's own drag, with the real file on disk first. The bytes
// aren't here yet: fetch them for the next try, as below.
if (nativeDrag) {
e.preventDefault();
if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]);
else prefetch();
return;
}
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
@@ -107,13 +133,6 @@ export function useAttachmentDrag(
return;
}
// In the desktop app the OS's own drag, with the real file on disk first.
if (canDragNativeFiles()) {
e.preventDefault();
void startNativeFileDrag([{ name, url }]);
return;
}
// `DownloadURL` format: <mime>:<filename>:<url>. The filename must be
// raw - URL-encoding it lands literally on disk (`%20` instead of a
// space). Callers are expected to sanitise reserved chars (`:` etc.)
@@ -121,7 +140,7 @@ export function useAttachmentDrag(
e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`);
e.dataTransfer.effectAllowed = "copyMove";
},
[source.name, source.type, prefetch],
[source.name, source.type, prefetch, nativeDrag],
);
const handleDragEnd = useCallback(() => {
@@ -136,8 +155,8 @@ export function useAttachmentDrag(
}, []);
const copyAsFile = useCallback(async () => {
const url = urlRef.current ?? await source.getBlobUrl();
return url ? copyFilesToClipboard([{ name: source.name || "download", url }]) : false;
const blob = blobRef.current ?? await source.getBlob?.();
return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false;
}, [source]);
if (!enabled) return NOOP_HANDLERS;
@@ -147,6 +166,6 @@ export function useAttachmentDrag(
onPointerEnter: prefetch,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
...(canDragNativeFiles() ? { onCopyAsFile: copyAsFile } : {}),
...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}),
};
}
+17 -9
View File
@@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useUIStore } from "@/stores/ui-store";
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
import { canDragNativeFiles, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag";
import { canExport } from "@/lib/device-policy";
import { appUrl, buildMailPath } from "@/lib/deep-links";
import {
@@ -104,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions
function revokeLater(url: string): void {
// Chromium may read the URL asynchronously after the drop completes.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
setTimeout(() => {
URL.revokeObjectURL(url);
forgetDragBlob(url);
}, 60_000);
}
async function buildEmailZip(emails: RoutedEmail[]): Promise<string> {
@@ -127,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise<string> {
}),
);
const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" });
return URL.createObjectURL(zipBlob);
return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob);
}
function prefetchEmailBundle(
@@ -225,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
if (previous?.url) revokeLater(previous.url);
const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null };
singleRef.current = entry;
entry.promise = route.client
.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId)
// The desktop app's native drag needs the bytes as well as the address.
const fetched = canDragNativeFiles()
? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob))
: route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId);
entry.promise = fetched
.then((url) => {
if (url) {
if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url;
@@ -298,9 +304,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
// Desktop app, Alt held: the OS's own drag with the real file. A plain drag has
// to stay the page's, because it also moves the message to a folder.
if (e.altKey && canDragNativeFiles()) {
const blob = e.altKey ? keptDragBlob(url) : undefined;
if (blob) {
e.preventDefault();
void startNativeFileDrag([{ name, url }]);
void startNativeFileDrag([{ name, blob }]);
return;
}
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
@@ -321,9 +328,10 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions));
const ready = getReadyBundle(routed, bundleOptions);
if (ready) {
if (e.altKey && canDragNativeFiles()) {
const blob = e.altKey ? keptDragBlob(ready.url) : undefined;
if (blob) {
e.preventDefault();
void startNativeFileDrag([{ name: ready.name, url: ready.url }]);
void startNativeFileDrag([{ name: ready.name, blob }]);
return;
}
e.dataTransfer.setData(
+23 -7
View File
@@ -200,23 +200,23 @@ describe('files on the window', () => {
describe('dragging an attachment out', () => {
const blobUrl = 'blob:https://app/1';
const source = { name: 'report.pdf', type: 'application/pdf', getBlobUrl: async () => blobUrl };
const source = {
name: 'report.pdf',
type: 'application/pdf',
getBlobUrl: async () => blobUrl,
getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
};
async function ready() {
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const hook = renderHook(() => useAttachmentDrag(source, true));
act(() => hook.result.current.onPointerEnter());
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
return hook;
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array([1, 2, 3]))));
});
it('in the app: the OS\'s own drag, with the bytes for a real file', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
@@ -231,6 +231,22 @@ describe('dragging an attachment out', () => {
expect([...files[0].data]).toEqual([1, 2, 3]);
});
it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => {
const startDrag = vi.fn(async () => true);
setBridge({ startDrag, copyFiles: vi.fn() as never });
const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag');
const hook = renderHook(() => useAttachmentDrag(source, true));
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
hook.result.current.onDragStart(event as never);
expect(event.preventDefault).toHaveBeenCalled();
expect(startDrag).not.toHaveBeenCalled();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
hook.result.current.onDragStart(event as never);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
});
it('in a browser: still DownloadURL, and no copy button', async () => {
const hook = await ready();
const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } };
+19 -7
View File
@@ -6,11 +6,26 @@
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { setOwnDragActive } from '@/lib/desktop/window-files';
/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */
export interface DragFile {
name: string;
/** A blob: URL the page already holds. */
url?: string;
data?: Uint8Array;
blob: Blob;
}
const keptBlobs = new Map<string, Blob>();
/** In the app, remembers what's behind an object URL made for dragging. */
export function keepDragBlob(url: string, blob: Blob): string {
if (canDragNativeFiles()) keptBlobs.set(url, blob);
return url;
}
export function keptDragBlob(url: string): Blob | undefined {
return keptBlobs.get(url);
}
export function forgetDragBlob(url: string): void {
keptBlobs.delete(url);
}
export function canDragNativeFiles(): boolean {
@@ -18,10 +33,7 @@ export function canDragNativeFiles(): boolean {
}
async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> {
if (file.data) return { name: file.name, data: file.data };
if (!file.url) throw new Error('A file needs its bytes or a URL');
const response = await fetch(file.url);
return { name: file.name, data: new Uint8Array(await response.arrayBuffer()) };
return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) };
}
/**