diff --git a/src/api/media-upload-client.ts b/src/api/media-upload-client.ts index 5bc0ff63..edc268b4 100644 --- a/src/api/media-upload-client.ts +++ b/src/api/media-upload-client.ts @@ -42,7 +42,9 @@ export function isRetryableMediaUploadError(error: unknown): boolean { } export function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; + return ( + typeof error === "object" && error !== null && "name" in error && error.name === "AbortError" + ); } export async function computeSha256Hex(buffer: ArrayBuffer): Promise { diff --git a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx index 523e136e..b364cc4f 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -34,10 +34,11 @@ function formatBytes(sizeBytes: number): string { return `${Math.max(1, Math.round(sizeBytes / 1_000))} KB`; } -function viewMediaItem(item: VendorMediaItem, blob: Blob): void { +function viewMediaItem(item: VendorMediaItem, blob: Blob, previewWindow: Window): void { const url = URL.createObjectURL(blob); - window.open(url, "_blank", "noopener"); - window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + previewWindow.document.title = item.originalFileName; + previewWindow.addEventListener("pagehide", () => URL.revokeObjectURL(url), { once: true }); + previewWindow.location.replace(url); } function VendorMediaItems({ @@ -148,6 +149,7 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec const [loadState, setLoadState] = useState({ loading: true }); const [progress, setProgress] = useState(); const [uploadCanceled, setUploadCanceled] = useState(false); + const [mediaActionError, setMediaActionError] = useState(); const load = useCallback(async () => { setLoadState({ loading: true }); @@ -217,14 +219,21 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec const selectFile = (file: File | undefined) => startUpload(file ?? null); const handleView = async (item: VendorMediaItem) => { + const previewWindow = window.open("about:blank", "_blank"); + if (!previewWindow) { + setMediaActionError("Allow pop-ups to view this media file."); + return; + } + previewWindow.opener = null; + previewWindow.document.title = item.originalFileName; + previewWindow.document.body.textContent = `Loading ${item.originalFileName}…`; + setMediaActionError(undefined); try { const blob = await vendorPortalMediaApi.fetchContent(token, dispatchId, item.id); - viewMediaItem(item, blob); + viewMediaItem(item, blob, previewWindow); } catch { - setLoadState((current) => ({ - ...current, - error: "Unable to open the media file. Please try again.", - })); + previewWindow.close(); + setMediaActionError("Unable to open the media file. Check your connection and try again."); } }; @@ -260,6 +269,9 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec )} void handleView(item)} /> + {Boolean(mediaActionError) && ( + {mediaActionError} + )} { }); it("aborts the active vendor upload from the visible Cancel control", async () => { - const upload = vi.spyOn(mediaUploadEngine, "uploadFileResumable").mockImplementation( - ({ signal }) => - new Promise((_resolve, reject) => { - signal?.addEventListener( - "abort", - () => reject(new DOMException("Aborted", "AbortError")), - { - once: true, - }, - ); - }), - ); + const cancel = vi.fn().mockResolvedValue(undefined); + const endpoints: MediaUploadEndpointSet = { + create: vi.fn().mockResolvedValue({ + uploadId: "upload-cancel", + state: "Created", + chunkSizeBytes: 5_242_880, + sizeBytes: 5, + receivedChunks: [], + expiresAt: "2026-09-24T00:00:00Z", + }), + putChunk: vi.fn( + ({ signal }) => + new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }), + ), + commit: vi.fn(), + getStatus: vi.fn(), + cancel, + }; + vi.mocked(vendorPortalMediaApi.endpoints).mockReturnValue(endpoints); renderVendorMedia(); const file = new File(["video"], "clip.mov", { type: "video/quicktime" }); @@ -140,10 +155,62 @@ describe("Vendor media controls", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Cancel upload" })).toBeInTheDocument(), ); - const signal = upload.mock.calls[0]?.[0].signal; fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); await waitFor(() => expect(screen.getByText("Upload canceled.")).toBeInTheDocument()); - expect(signal?.aborted).toBe(true); + expect(endpoints.putChunk).toHaveBeenCalledWith( + expect.objectContaining({ + signal: expect.objectContaining({ aborted: true }), + }), + ); + await waitFor(() => expect(cancel).toHaveBeenCalledWith("upload-cancel")); + }); + + it("opens media from the View click before fetching and revokes its object URL on close", async () => { + const item: VendorMediaItem = { + id: 14, + originalFileName: "photo.jpg", + contentType: "image/jpeg", + sizeBytes: 5, + kind: "Photo", + category: "Extra", + scanStatus: "Passed", + createdAt: "2026-09-23T00:00:00Z", + canView: true, + }; + vi.spyOn(vendorPortalMediaApi, "getMedia").mockResolvedValue({ + ...MEDIA, + items: [item], + }); + const addEventListener = vi.fn(); + const previewWindow = { + opener: window, + closed: false, + document: { title: "", body: { textContent: "" } }, + location: { replace: vi.fn() }, + addEventListener, + close: vi.fn(), + } as unknown as Window; + const open = vi.spyOn(window, "open").mockReturnValue(previewWindow); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:vendor-media"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + const fetchContent = vi + .spyOn(vendorPortalMediaApi, "fetchContent") + .mockImplementation(async (_token, _dispatchId, mediaId) => { + expect(open).toHaveBeenCalledWith("about:blank", "_blank"); + expect(mediaId).toBe(item.id); + return new Blob(["image"], { type: item.contentType }); + }); + renderVendorMedia(); + + fireEvent.click(await screen.findByRole("button", { name: "View" })); + + await waitFor(() => expect(fetchContent).toHaveBeenCalledWith("vendor-token", 9, item.id)); + expect(createObjectURL).toHaveBeenCalledOnce(); + expect(previewWindow.document.title).toBe(item.originalFileName); + expect(previewWindow.location.replace).toHaveBeenCalledWith("blob:vendor-media"); + const pageHide = addEventListener.mock.calls[0]?.[1] as EventListener | undefined; + pageHide?.(new Event("pagehide")); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:vendor-media"); }); });