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
39 changes: 17 additions & 22 deletions src/components/message/generated-images-block.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"use client"

import { memo, useCallback, useState } from "react"
import { memo, useState } from "react"
import Image from "next/image"
import { AlertCircle, Download, ImagePlus } from "lucide-react"
import { useTranslations } from "next-intl"
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
import type { ToolCallStatus } from "@/lib/types"
import { ImagePreviewDialog } from "@/components/ui/image-preview-dialog"
import { downloadImage } from "@/lib/image-download"
import { toErrorMessage } from "@/lib/app-error"
import { ImageActions, useImageActions } from "./image-actions"
import { cn } from "@/lib/utils"

interface GeneratedImagesBlockProps {
Expand Down Expand Up @@ -73,21 +72,7 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
const isFailed =
image === null && (status === "failed" || status === "completed")

const handleDownload = useCallback(
async (img: UserImageDisplay) => {
try {
await downloadImage({
data: img.data,
mime_type: img.mime_type,
suggestedName: img.name,
})
} catch (err) {
const message = toErrorMessage(err)
window.alert(t("downloadFailed", { message }))
}
},
[t]
)
const { canCopy, copy, download } = useImageActions()

const trimmedPrompt =
typeof revisedPrompt === "string" ? revisedPrompt.trim() : ""
Expand All @@ -112,7 +97,10 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
) : null}

{image ? (
<div className="group relative inline-block shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30">
<ImageActions
image={image}
className="group relative inline-block shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
>
<button
type="button"
onClick={() => setPreviewOpen(true)}
Expand All @@ -131,15 +119,15 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
type="button"
onClick={(e) => {
e.stopPropagation()
void handleDownload(image)
void download(image)
}}
className="absolute right-1 top-1 rounded-full bg-background/80 p-1 text-foreground/80 opacity-0 shadow-sm transition-opacity hover:bg-background hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t("downloadImage")}
title={t("downloadImage")}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</ImageActions>
) : isFailed ? (
<div
className="flex h-64 w-64 max-w-full shrink-0 items-center justify-center rounded-md border border-dashed border-destructive/40 bg-destructive/5 text-xs text-destructive"
Expand Down Expand Up @@ -170,8 +158,15 @@ export const GeneratedImagesBlock = memo(function GeneratedImagesBlock({
alt={image?.name ?? ""}
open={previewOpen && image !== null}
onOpenChange={(open) => setPreviewOpen(open)}
onDownload={image ? () => void handleDownload(image) : undefined}
onDownload={image ? () => void download(image) : undefined}
downloadLabel={t("downloadImage")}
onCopy={image && canCopy ? () => void copy(image) : undefined}
copyLabel={t("copyImage")}
renderImage={
image
? (preview) => <ImageActions image={image}>{preview}</ImageActions>
: undefined
}
/>
</div>
)
Expand Down
205 changes: 205 additions & 0 deletions src/components/message/image-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { beforeEach, describe, expect, it, vi } from "vitest"

import enMessages from "@/i18n/messages/en.json"
import { ClipboardImageUnsupportedError } from "@/lib/copy-image"

const mocks = vi.hoisted(() => ({
canCopyImageToClipboard: vi.fn(() => true),
copyImageToClipboard: vi.fn(async () => {}),
downloadImage: vi.fn(async () => true),
toastSuccess: vi.fn(),
toastError: vi.fn(),
ancestorContextMenu: vi.fn(),
ancestorPointerDown: vi.fn(),
}))

vi.mock("@/lib/copy-image", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/copy-image")>()
return {
...actual,
canCopyImageToClipboard: mocks.canCopyImageToClipboard,
copyImageToClipboard: mocks.copyImageToClipboard,
}
})

vi.mock("@/lib/image-download", () => ({ downloadImage: mocks.downloadImage }))

vi.mock("sonner", () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError },
}))

import { ImageActions } from "./image-actions"

const IMAGE = {
data: "QQ==",
mime_type: "image/png",
name: "shot.png",
uri: null,
}

function renderActions() {
return render(
// The outer handlers stand in for the conversation panel's own context
// menu, which wraps the whole transcript.
<NextIntlClientProvider locale="en" messages={enMessages}>
<div
onContextMenu={mocks.ancestorContextMenu}
onPointerDown={mocks.ancestorPointerDown}
>
<ImageActions image={IMAGE}>
<button type="button" data-testid="thumb">
thumbnail
</button>
</ImageActions>
</div>
</NextIntlClientProvider>
)
}

/** The context-menu trigger wrapped around the image. */
function trigger(): HTMLElement {
const element = document.querySelector<HTMLElement>("[data-image-actions]")
if (!element) throw new Error("expected a context-menu trigger on the image")
return element
}

function item(name: string): HTMLElement {
return screen.getByRole("menuitem", { name })
}

describe("ImageActions", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.canCopyImageToClipboard.mockReturnValue(true)
mocks.copyImageToClipboard.mockResolvedValue(undefined)
mocks.downloadImage.mockResolvedValue(true)
})

it("opens on right-click, and only on right-click", () => {
renderActions()
// A left click belongs to the thumbnail (it opens the preview).
fireEvent.click(trigger())
expect(screen.queryByRole("menu")).toBeNull()

// Radix suppresses the native menu to put its own in that place — the
// contrast with the no-clipboard case below, which leaves it alone.
expect(fireEvent.contextMenu(trigger())).toBe(false)
expect(screen.getByRole("menu")).toBeInTheDocument()
})

it("keeps the right-click from also opening the conversation menu", () => {
renderActions()
fireEvent.contextMenu(trigger())

expect(screen.getByRole("menu")).toBeInTheDocument()
// The transcript is wrapped in the conversation panel's own context menu;
// both opening at once is what this stopPropagation prevents.
expect(mocks.ancestorContextMenu).not.toHaveBeenCalled()
})

it("keeps a touch long-press from arming the conversation menu too", () => {
renderActions()
// jsdom's fireEvent.pointerDown drops `pointerType`, so pin it by hand.
const event = new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
})
Object.defineProperty(event, "pointerType", { value: "touch" })
fireEvent(trigger(), event)

expect(mocks.ancestorPointerDown).not.toHaveBeenCalled()
})

it("copies the image and reports success", async () => {
renderActions()
fireEvent.contextMenu(trigger())
fireEvent.click(item("Copy image"))

await waitFor(() => {
expect(mocks.copyImageToClipboard).toHaveBeenCalledWith({
data: IMAGE.data,
mime_type: IMAGE.mime_type,
})
})
await waitFor(() => {
expect(mocks.toastSuccess).toHaveBeenCalledWith("Image copied")
})
})

it("reports an unsupported clipboard in the user's language, not ours", async () => {
mocks.copyImageToClipboard.mockRejectedValue(
new ClipboardImageUnsupportedError()
)
renderActions()
fireEvent.contextMenu(trigger())
fireEvent.click(item("Copy image"))

await waitFor(() => {
expect(mocks.toastError).toHaveBeenCalledWith(
enMessages.Folder.chat.messageList.copyImageUnsupported
)
})
// The typed error's own English text must not reach the toast.
expect(mocks.toastError).not.toHaveBeenCalledWith(
expect.stringContaining("This environment cannot")
)
})

it("passes any other failure through with the browser's message", async () => {
mocks.copyImageToClipboard.mockRejectedValue(new Error("Denied"))
renderActions()
fireEvent.contextMenu(trigger())
fireEvent.click(item("Copy image"))

await waitFor(() => {
expect(mocks.toastError).toHaveBeenCalledWith(
"Could not copy image: Denied"
)
})
})

it("downloads from the menu", async () => {
renderActions()
fireEvent.contextMenu(trigger())
fireEvent.click(item("Download image"))

await waitFor(() => {
expect(mocks.downloadImage).toHaveBeenCalledWith({
data: IMAGE.data,
mime_type: IMAGE.mime_type,
suggestedName: IMAGE.name,
})
})
})

describe("without a usable clipboard (non-secure web context)", () => {
beforeEach(() => {
mocks.canCopyImageToClipboard.mockReturnValue(false)
})

it("offers no menu of its own, so the native image menu can appear", () => {
renderActions()
const event = fireEvent.contextMenu(trigger())

expect(screen.queryByRole("menu")).toBeNull()
// Not preventing the default is what lets the browser's own menu — which
// copies images with no secure-context requirement — take over.
expect(event).toBe(true)
expect(mocks.ancestorContextMenu).not.toHaveBeenCalled()
})

it("still shields the ancestor from a touch long-press", () => {
renderActions()
const event = new MouseEvent("pointerdown", {
bubbles: true,
cancelable: true,
})
Object.defineProperty(event, "pointerType", { value: "touch" })
fireEvent(trigger(), event)

expect(mocks.ancestorPointerDown).not.toHaveBeenCalled()
})
})
})
Loading
Loading