Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ import {
type ComposerBannerStackContent,
type ComposerBannerStackItem,
} from "./ComposerBannerStack";
import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
import {
compressImageForStash,
isHeicImageFile,
prepareImageForAttachment,
} from "../../lib/imageCompression";
import { AttachmentImage } from "../media/AttachmentImage";
import {
fileAttachmentTooLargeMessage,
formatAttachmentSize,
Expand Down Expand Up @@ -5298,12 +5303,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
alt=""
className="size-full object-cover"
fallback={
<PierreEntryIcon
pathValue={image.name}
kind="file"
theme={resolvedTheme}
className="m-auto size-3.5"
/>
isHeicImageFile({ name: image.name, type: image.mimeType }) ? (
<AttachmentImage
name={image.name}
mimeType={image.mimeType}
src={image.previewUrl}
alt=""
className="size-full object-cover"
/>
) : (
<PierreEntryIcon
pathValue={image.name}
kind="file"
theme={resolvedTheme}
className="m-auto size-3.5"
/>
)
}
/>
) : (
Expand Down Expand Up @@ -7174,9 +7189,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
alt={image.name}
className="h-full w-full object-cover"
fallback={
<span className="flex h-full items-center justify-center px-1 text-3xs text-secondary-label">
{image.name}
</span>
isHeicImageFile({ name: image.name, type: image.mimeType }) ? (
<AttachmentImage
name={image.name}
mimeType={image.mimeType}
src={image.previewUrl}
alt={image.name}
className="h-full w-full object-cover"
/>
) : (
<span className="flex h-full items-center justify-center px-1 text-3xs text-secondary-label">
{image.name}
</span>
)
}
/>
</button>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
key={`${index}:${item.src}`}
src={item.src}
name={item.name}
mimeType={item.mimeType}
onError={() => setFailedImageSrc(item.src)}
/>
)}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/chat/ExpandedImagePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ describe("resolveMarkdownMediaPreview", () => {
});

describe("buildExpandedImagePreview", () => {
it("keeps MIME type for stored images without a filename extension", () => {
const preview = buildExpandedImagePreview(
[
{
type: "image",
id: "heic-1",
name: "photo",
mimeType: "image/heic",
sizeBytes: 3,
previewUrl: "blob:heic",
},
],
"heic-1",
);
expect(preview?.images[0]).toMatchObject({
name: "photo",
mimeType: "image/heic",
src: "blob:heic",
});
});

it("builds a signed-asset preview for a persisted video attachment", () => {
const preview = buildAttachmentVideoPreview(EnvironmentId.make("environment-1"), {
type: "file",
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/chat/ExpandedImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ExpandedImageItem {
/** A loadable URL, or null when the dialog must mint one from `asset` first. */
src: string | null;
name: string;
mimeType?: string;
type?: "video";
source?: SnapShotSource;
autoPlay?: boolean;
Expand Down Expand Up @@ -178,7 +179,15 @@ export function buildExpandedImagePreview(
}
const previewableImages = images.flatMap((image) =>
image.type === "image" && image.previewUrl
? [{ id: image.id, src: image.previewUrl, name: image.name, source: image.source }]
? [
{
id: image.id,
src: image.previewUrl,
name: image.name,
mimeType: image.mimeType,
source: image.source,
},
]
: [],
);
if (previewableImages.length === 0) {
Expand All @@ -192,6 +201,7 @@ export function buildExpandedImagePreview(
images: previewableImages.map((image) => ({
src: image.src,
name: image.name,
mimeType: image.mimeType,
...(image.source?.kind === "snap-shot" ? { source: image.source } : {}),
})),
index: selectedIndex,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ import type {
import { Button, InlineButton } from "../ui/button";
import { MorphIcon } from "~/components/MorphIcon";
import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
import { AttachmentImage } from "../media/AttachmentImage";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
import {
Expand Down Expand Up @@ -2500,7 +2501,9 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
ctx.onImageExpand(preview);
}}
>
<img
<AttachmentImage
name={image.name}
mimeType={image.mimeType}
src={image.previewUrl}
alt={image.name}
className="block size-full object-cover"
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/chat/ZoomableImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type Ref,
} from "react";

import { AttachmentImage } from "../media/AttachmentImage";

const MAX_ZOOM = 8;

export interface ZoomableImageHandle {
Expand All @@ -18,11 +20,13 @@ export interface ZoomableImageHandle {
export function ZoomableImage({
src,
name,
mimeType,
onError,
ref,
}: {
src: string;
name: string;
mimeType?: string | undefined;
onError: () => void;
ref?: Ref<ZoomableImageHandle>;
}) {
Expand Down Expand Up @@ -222,7 +226,9 @@ export function ZoomableImage({
setDragging(false);
}}
>
<img
<AttachmentImage
name={name}
mimeType={mimeType}
src={src}
alt={name}
draggable={false}
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/components/media/AttachmentImage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { act, type SyntheticEvent } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
import { AttachmentImage } from "./AttachmentImage";
import { prepareImageForAttachment } from "../../lib/imageCompression";

vi.mock("../../lib/imageCompression", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/imageCompression")>()),
prepareImageForAttachment: vi.fn(),
}));

let renderer: ReactTestRenderer;
const fetchImage = vi.fn();
const revokeUrl = vi.fn();
const decodeError = {} as SyntheticEvent<HTMLImageElement>;
const jpeg = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("fetch", fetchImage);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:jpeg");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(revokeUrl);
fetchImage.mockResolvedValue(new Response(new Blob(["heic"])));
vi.mocked(prepareImageForAttachment).mockResolvedValue({
ok: true,
file: jpeg,
recompressed: true,
});
});

afterEach(async () => {
await act(() => renderer?.unmount());
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it.each([
{ name: "photo.HEIC", mimeType: undefined },
{ name: "photo", mimeType: "image/heif" },
])(
"converts $name after native decoding fails and releases its preview",
async ({ name, mimeType }) => {
await act(() => {
renderer = create(
<AttachmentImage src="https://environment.test/photo" name={name} mimeType={mimeType} />,
);
});
expect(fetchImage).not.toHaveBeenCalled();
await act(() => renderer.root.findByType("img").props.onError(decodeError));
expect(renderer.root.findByType("img").props.src).toBe("blob:jpeg");
expect(prepareImageForAttachment).toHaveBeenCalledWith(
expect.objectContaining({ name, type: "image/heic" }),
50 * 1024 * 1024,
);
await act(() => renderer.unmount());
expect(revokeUrl).toHaveBeenCalledWith("blob:jpeg");
},
);

it("leaves PNG errors to the caller without fetching or converting", async () => {
const onError = vi.fn();
await act(() => {
renderer = create(<AttachmentImage src="blob:png" name="photo.png" onError={onError} />);
});
await act(() => renderer.root.findByType("img").props.onError(decodeError));
expect(onError).toHaveBeenCalledWith(decodeError);
expect(fetchImage).not.toHaveBeenCalled();
});

it("reports failed HEIC conversion through the existing error handler", async () => {
const onError = vi.fn();
vi.mocked(prepareImageForAttachment).mockResolvedValue({ ok: false, reason: "unreadable" });
await act(() => {
renderer = create(<AttachmentImage src="blob:heic" name="photo.heif" onError={onError} />);
});
await act(() => renderer.root.findByType("img").props.onError(decodeError));
expect(onError).toHaveBeenCalledWith(decodeError);
expect(URL.createObjectURL).not.toHaveBeenCalled();
});

it("discards an old decode when navigating to a different source", async () => {
let finishDecode!: (result: Awaited<ReturnType<typeof prepareImageForAttachment>>) => void;
vi.mocked(prepareImageForAttachment).mockReturnValue(
new Promise((resolve) => {
finishDecode = resolve;
}),
);
await act(() => {
renderer = create(<AttachmentImage src="blob:first" name="photo.heic" />);
});
await act(() => renderer.root.findByType("img").props.onError(decodeError));
await act(() => renderer.update(<AttachmentImage src="blob:second" name="photo.heic" />));
await act(() => finishDecode({ ok: true, file: jpeg, recompressed: true }));
expect(renderer.root.findByType("img").props.src).toBe("blob:second");
expect(URL.createObjectURL).not.toHaveBeenCalled();
expect(fetchImage.mock.calls[0]?.[1].signal.aborted).toBe(true);
});
62 changes: 62 additions & 0 deletions apps/web/src/components/media/AttachmentImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
useEffect,
useEffectEvent,
useState,
type ComponentProps,
type SyntheticEvent,
} from "react";
import {
isHeicImageFile,
MAX_COMPRESSIBLE_SOURCE_BYTES,
prepareImageForAttachment,
} from "../../lib/imageCompression";

type AttachmentImageProps = ComponentProps<"img"> & { name: string; mimeType?: string | undefined };

export function AttachmentImage({ name, mimeType, ...props }: AttachmentImageProps) {
return isHeicImageFile({ name, type: mimeType ?? "" }) ? (
<HeicImage key={props.src} {...props} name={name} />
) : (
<img {...props} />
);
}

function HeicImage({ name, src, onError, ...props }: Omit<AttachmentImageProps, "mimeType">) {
const [decodeError, setDecodeError] = useState<SyntheticEvent<HTMLImageElement> | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>();

const reportError = useEffectEvent((event: SyntheticEvent<HTMLImageElement>) => onError?.(event));

useEffect(() => {
if (!decodeError || !src) return;
const controller = new AbortController();
let objectUrl: string | undefined;
const decode = async () => {
try {
const response = await fetch(src, { signal: controller.signal });
if (!response.ok) return reportError(decodeError);
const blob = await response.blob();
if (controller.signal.aborted) return;
const result = await prepareImageForAttachment(
new File([blob], name, { type: "image/heic" }),
MAX_COMPRESSIBLE_SOURCE_BYTES,
);
if (controller.signal.aborted) return;
if (!result.ok) return reportError(decodeError);
objectUrl = URL.createObjectURL(result.file);
setPreviewUrl(objectUrl);
} catch {
if (!controller.signal.aborted) reportError(decodeError);
}
};
void decode();
return () => {
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [decodeError, name, src]);

return (
<img {...props} src={previewUrl ?? src} onError={decodeError ? onError : setDecodeError} />
);
}
Loading