Skip to content
Open
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
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import {
type ComposerBannerStackItem,
} from "./ComposerBannerStack";
import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
import { AttachmentImage } from "../media/AttachmentImage";
import {
fileAttachmentTooLargeMessage,
formatAttachmentSize,
Expand Down Expand Up @@ -3762,7 +3763,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}}
>
{image.previewUrl ? (
<img src={image.previewUrl} alt="" className="size-full object-cover" />
<AttachmentImage
name={image.name}
mimeType={image.mimeType}
src={image.previewUrl}
alt=""
className="size-full object-cover"
/>
) : (
<FileIcon className="m-auto size-3.5 text-secondary-label" />
)}
Expand Down Expand Up @@ -5216,7 +5223,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onExpandImage(preview);
}}
>
<img
<AttachmentImage
name={image.name}
mimeType={image.mimeType}
src={image.previewUrl}
alt={image.name}
className="h-full w-full object-cover"
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls";
import { OpenMediaLink } from "../media/OpenMediaLink";
import { MediaActions, type MediaActionSource } from "../media/MediaActions";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { AttachmentImage } from "../media/AttachmentImage";
import { isContextMenuOpen } from "../../contextMenuFallback";
import { composerFloatingLayerProps } from "./composerEventScope";

Expand Down Expand Up @@ -178,7 +179,9 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
{openOriginalLink}
</ExpandedMediaFailure>
) : (
<img
<AttachmentImage
name={item.name}
mimeType={item.mimeType}
src={item.src}
alt={item.name}
className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl"
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/ExpandedImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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";
autoPlay?: boolean;
/** Authored remote destination to open when embedding fails, never a generated asset URL. */
Expand Down Expand Up @@ -141,7 +142,7 @@ export function buildExpandedImagePreview(
}
const previewableImages = images.flatMap((image) =>
image.type === "image" && image.previewUrl
? [{ id: image.id, src: image.previewUrl, name: image.name }]
? [{ id: image.id, src: image.previewUrl, name: image.name, mimeType: image.mimeType }]
: [],
);
if (previewableImages.length === 0) {
Expand All @@ -155,6 +156,7 @@ export function buildExpandedImagePreview(
images: previewableImages.map((image) => ({
src: image.src,
name: image.name,
mimeType: image.mimeType,
})),
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 @@ -104,6 +104,7 @@ import {
} from "lucide-react";
import { Button } from "../ui/button";
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 @@ -1359,7 +1360,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
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