From d3f45dbda4146811f6c3e77540e749baaf5f7c8e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 17:05:51 +0200 Subject: [PATCH] feat(web): saveImage routes through transferStore --- packages/web/src/utils/imageActions.test.ts | 85 +++++++++++++++------ packages/web/src/utils/imageActions.ts | 39 ++++++---- 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/packages/web/src/utils/imageActions.test.ts b/packages/web/src/utils/imageActions.test.ts index bffb7394..2524faaf 100644 --- a/packages/web/src/utils/imageActions.test.ts +++ b/packages/web/src/utils/imageActions.test.ts @@ -8,58 +8,95 @@ vi.mock('../stores/uiStore', () => ({ }, })); +const mockStartDownload = vi.fn(); +const mockGet = vi.fn(); + +vi.mock('../stores/transferStore', () => ({ + useTransferStore: { + getState: vi.fn(() => ({ + startDownload: mockStartDownload, + get: mockGet, + })), + }, +})); + import { saveImage, copyImageToClipboard } from './imageActions'; import { useUIStore } from '../stores/uiStore'; describe('saveImage', () => { - let createElementSpy: ReturnType; - let mockAnchor: { href: string; download: string; click: ReturnType; remove: ReturnType }; - beforeEach(() => { - mockAnchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() }; - createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any); - vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node); - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + mockStartDownload.mockReset(); + mockGet.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); - it('fetches image as blob and triggers download for same-origin URLs', async () => { - const mockBlob = new Blob(['img'], { type: 'image/png' }); - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob)); + it('routes downloads through transferStore.startDownload with derived filename', async () => { + mockStartDownload.mockResolvedValue('transfer-id-1'); + mockGet.mockReturnValue({ id: 'transfer-id-1', state: 'completed' }); await saveImage('/api/uploads/abc123_photo.png'); - expect(fetch).toHaveBeenCalledWith('/api/uploads/abc123_photo.png'); - expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); - expect(mockAnchor.download).toBe('abc123_photo.png'); - expect(mockAnchor.click).toHaveBeenCalled(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(mockStartDownload).toHaveBeenCalledWith('/api/uploads/abc123_photo.png', { + filename: 'abc123_photo.png', + mimetype: 'image/*', + tray: true, + }); }); it('uses provided filename when given', async () => { - const mockBlob = new Blob(['img'], { type: 'image/png' }); - vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob)); + mockStartDownload.mockResolvedValue('transfer-id-2'); + mockGet.mockReturnValue({ id: 'transfer-id-2', state: 'completed' }); await saveImage('/api/uploads/abc123.png', 'my-photo.png'); - expect(mockAnchor.download).toBe('my-photo.png'); + expect(mockStartDownload).toHaveBeenCalledWith('/api/uploads/abc123.png', { + filename: 'my-photo.png', + mimetype: 'image/*', + tray: true, + }); }); - it('falls back to window.open on CORS/fetch error and shows toast', async () => { - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); + it('falls back to window.open and toast when startDownload throws', async () => { + mockStartDownload.mockRejectedValue(new TypeError('Failed to fetch')); const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const mockAddToast = vi.fn(); - vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any); + vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType); await saveImage('https://media.tenor.com/abc/tenor.gif'); expect(openSpy).toHaveBeenCalledWith('https://media.tenor.com/abc/tenor.gif', '_blank', 'noopener'); expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000); }); + + it('falls back to window.open when transfer ends in failed state', async () => { + mockStartDownload.mockResolvedValue('transfer-id-3'); + mockGet.mockReturnValue({ + id: 'transfer-id-3', + state: 'failed', + error: { message: 'HTTP 404', permanent: true }, + }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const mockAddToast = vi.fn(); + vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType); + + await saveImage('https://external.com/img.png'); + + expect(openSpy).toHaveBeenCalledWith('https://external.com/img.png', '_blank', 'noopener'); + expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000); + }); + + it('stays silent when user cancels (state aborted)', async () => { + mockStartDownload.mockResolvedValue('transfer-id-4'); + mockGet.mockReturnValue({ id: 'transfer-id-4', state: 'aborted' }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + await saveImage('/api/uploads/cancelled.png'); + + expect(openSpy).not.toHaveBeenCalled(); + }); }); describe('copyImageToClipboard', () => { @@ -89,7 +126,7 @@ describe('copyImageToClipboard', () => { clipboard: { write: vi.fn(), writeText: mockWriteText }, }); const mockAddToast = vi.fn(); - vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any); + vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType); await copyImageToClipboard('https://media.tenor.com/abc/tenor.gif'); @@ -104,7 +141,7 @@ describe('copyImageToClipboard', () => { clipboard: { write: vi.fn(), writeText: mockWriteText }, }); const mockAddToast = vi.fn(); - vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any); + vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType); await copyImageToClipboard('https://external.com/image.jpg'); diff --git a/packages/web/src/utils/imageActions.ts b/packages/web/src/utils/imageActions.ts index b0276b16..b72d647a 100644 --- a/packages/web/src/utils/imageActions.ts +++ b/packages/web/src/utils/imageActions.ts @@ -1,24 +1,35 @@ import { useUIStore } from '../stores/uiStore'; +import { useTransferStore } from '../stores/transferStore'; + +function deriveFilename(url: string): string { + try { + const u = new URL(url, window.location.origin); + const last = u.pathname.split('/').pop() || 'image'; + return last.split('?')[0] || 'image'; + } catch { + return url.split('/').pop()?.split('?')[0] ?? 'image'; + } +} /** - * Downloads an image by fetching it as a blob and triggering a download. - * Falls back to opening in a new tab if CORS blocks the fetch. + * Downloads an image via the transfer manager. Falls back to opening in a new + * tab if the transfer pipeline can't fetch the URL (e.g., CORS). */ export async function saveImage(url: string, filename?: string): Promise { - const derivedFilename = filename ?? url.split('/').pop()?.split('?')[0] ?? 'image'; + const fname = filename ?? deriveFilename(url); try { - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const blob = await res.blob(); - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = derivedFilename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(blobUrl); + const transferId = await useTransferStore.getState().startDownload(url, { + filename: fname, + mimetype: 'image/*', + tray: true, + }); + // startDownload never throws on user-cancel — it sets state to 'aborted' + // and resolves with the id. Detect that and stay silent. + const t = useTransferStore.getState().get(transferId); + if (t?.state === 'failed') { + throw new Error(t.error?.message ?? 'Download failed'); + } } catch { window.open(url, '_blank', 'noopener'); useUIStore.getState().addToast('Opened in new tab', 'info', 3000);