mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: drag attachments and messages out of the desktop app with their bytes in hand
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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: '' } };
|
||||
|
||||
@@ -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()) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user