diff --git a/packages/ui/package.json b/packages/ui/package.json
index a93ec5c3a5..60d7dea2b9 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -95,6 +95,7 @@
"react-hotkeys-hook": "^4.4.4",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
+ "react-zoom-pan-pinch": "^4.0.3",
"reflect-metadata": "catalog:",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx
index aa7a904c15..4946346cf3 100644
--- a/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx
+++ b/packages/ui/src/features/sessions/components/ArtifactPreview.test.tsx
@@ -1,8 +1,9 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ArtifactPreview } from "./ArtifactPreview";
import {
artifactHtmlDocument,
+ artifactPreviewBlob,
markdownDocument,
} from "./artifactPreviewDocument";
@@ -102,6 +103,118 @@ describe("ArtifactPreview", () => {
expect(frame).toHaveAttribute("sandbox", "");
});
+ it.each([
+ ["image.png", "image/png"],
+ ["image.jpg", "image/jpeg"],
+ ["image.gif", "image/gif"],
+ ["image.webp", "image/webp"],
+ ["image.bmp", "image/bmp"],
+ ["image.ico", "image/x-icon"],
+ ["image.tiff", "image/tiff"],
+ ["image.avif", "image/avif"],
+ ])("normalizes %s served as octet-stream", async (name, mimeType) => {
+ const blob = await artifactPreviewBlob(
+ new Blob(["image"], { type: "application/octet-stream" }),
+ name,
+ );
+
+ expect(blob.type).toBe(mimeType);
+ });
+
+ it("shows working image controls instead of an iframe", () => {
+ useQuery.mockReturnValue({
+ data: new Blob(["image"], { type: "image/png" }),
+ isLoading: false,
+ isError: false,
+ });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("img", { name: "image.png" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Zoom in" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Zoom out" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Fit to view" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByTitle("Preview of image.png")).not.toBeInTheDocument();
+
+ const zoomOut = screen.getByRole("button", { name: "Zoom out" });
+ fireEvent.click(zoomOut);
+ fireEvent.click(zoomOut);
+ fireEvent.click(zoomOut);
+ fireEvent.click(zoomOut);
+ expect(screen.getByText("10%")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Fit to view" }));
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+
+ it("zooms with a trackpad pinch gesture", async () => {
+ useQuery.mockReturnValue({
+ data: new Blob(["image"], { type: "image/png" }),
+ isLoading: false,
+ isError: false,
+ });
+
+ render(
+ ,
+ );
+
+ const image = screen.getByRole("img", { name: "image.png" });
+ const viewport = image.closest(".react-transform-wrapper");
+ expect(viewport).not.toBeNull();
+ fireEvent.wheel(viewport as Element, {
+ ctrlKey: true,
+ deltaY: -100,
+ clientX: 100,
+ clientY: 100,
+ });
+
+ await waitFor(() => {
+ const percentage = Number.parseInt(
+ screen.getByText(/%$/).textContent ?? "0",
+ 10,
+ );
+ expect(percentage).toBeGreaterThan(100);
+ });
+ });
+
+ it("shows the preview error when an image cannot be decoded", () => {
+ useQuery.mockReturnValue({
+ data: new Blob(["not an image"], { type: "image/png" }),
+ isLoading: false,
+ isError: false,
+ });
+
+ render(
+ ,
+ );
+
+ fireEvent.error(screen.getByRole("img", { name: "broken.png" }));
+ expect(
+ screen.getByText("This artifact can’t be previewed."),
+ ).toBeInTheDocument();
+ });
+
it("renders GFM Markdown while escaping embedded HTML", () => {
const document = markdownDocument(
"# Report\n\n**Ready**\n\n| Name | Value |\n| --- | --- |\n| Cost | 12 |\n\n",
diff --git a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx
index ba1d4ccb26..c13bb02d41 100644
--- a/packages/ui/src/features/sessions/components/ArtifactPreview.tsx
+++ b/packages/ui/src/features/sessions/components/ArtifactPreview.tsx
@@ -4,22 +4,38 @@ import {
} from "@posthog/core/sessions/sessionService";
import { useService } from "@posthog/di/react";
import { Spinner } from "@posthog/quill";
+import { isAllowedImageMimeType } from "@posthog/shared";
import {
getAuthIdentity,
useAuthStateValue,
} from "@posthog/ui/features/auth/store";
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
import { useQuery } from "@tanstack/react-query";
-import { useEffect, useMemo } from "react";
-import {
- artifactHtmlDocument,
- markdownDocument,
-} from "./artifactPreviewDocument";
+import { useEffect, useMemo, useState } from "react";
+import { ZoomableImage } from "../../../primitives/SafeImagePreview";
+import { artifactPreviewBlob } from "./artifactPreviewDocument";
+
+function ArtifactPreviewError() {
+ return (
+
+ This artifact can’t be previewed.
+
+ );
+}
-const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown"]);
+function ArtifactImagePreview({ src, name }: { src: string; name: string }) {
+ const [hasError, setHasError] = useState(false);
-function extension(filename: string): string {
- return filename.split(".").pop()?.toLowerCase() ?? "";
+ if (hasError) return ;
+ return (
+ setHasError(true)}
+ />
+ );
}
export function ArtifactPreview({
@@ -46,18 +62,7 @@ export function ArtifactPreview({
if (!url) throw new Error("Artifact is unavailable");
const response = await fetch(url);
if (!response.ok) throw new Error("Artifact preview failed");
- const blob = await response.blob();
- if (MARKDOWN_EXTENSIONS.has(extension(name))) {
- return new Blob([markdownDocument(await blob.text())], {
- type: "text/html",
- });
- }
- if (extension(name) === "html" || extension(name) === "htm") {
- return new Blob([artifactHtmlDocument(await blob.text())], {
- type: "text/html",
- });
- }
- return blob;
+ return artifactPreviewBlob(await response.blob(), name);
},
enabled: authIdentity !== null,
staleTime: Infinity,
@@ -83,11 +88,10 @@ export function ArtifactPreview({
);
}
if (isError || !previewUrl) {
- return (
-
- This artifact can’t be previewed.
-
- );
+ return ;
+ }
+ if (data && isAllowedImageMimeType(data.type)) {
+ return ;
}
return (