import type { ContextMenuBindings } from '@deepseek-ai/dsh-client-ui-context-menu/client' import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './MessageImage.module.css' export type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' /** Loads a Gateway-authorized durable image URL and may expose a cached URL synchronously. */ export type ImageLoader = ((attachment: ImageAttachmentRef) => Promise) & { peek?: (attachment: ImageAttachmentRef) => string | undefined } /** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ export type MessageImageSpec = | { readonly attachment: ImageAttachmentRef /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ readonly label?: string } | { readonly preview: { readonly url: string readonly name?: string readonly width?: number readonly height?: number } } /** Display box for a lone image (DeepSeek Chat rule): long edge 240px with * the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ function singleFit( dimensions: { readonly width: number; readonly height: number }, ): { width: number; height: number; objectPosition: string } { const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center', } } /** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { if ('attachment' in image) return image.attachment return image.preview.width !== undefined && image.preview.height !== undefined ? { width: image.preview.width, height: image.preview.height } : undefined } /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among * several renders as a fixed 64px square tile. The preview arm displays its * local URL directly — no loader round-trip, no failure/retry surface. * List thumbnails use status icons with localized tooltips and accessible names. * * @param props.image - the durable reference to load, or the local preview to display. * @param props.load - Gateway-authorized URL loader for the durable arm. * @param props.variant - lone image, cropped gallery tile, or uncropped list thumbnail. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ export function MessageImage({ image, load, variant, labels, fileDrag, getTriggerProps }: { image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' | 'thumbnail' fileDrag: FileDragActions labels: MessageImageLabels getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { const preview = 'preview' in image ? image.preview : undefined const attachment = 'attachment' in image ? image.attachment : undefined const [loaded, setLoaded] = useState(() => attachment === undefined ? null : (load.peek?.(attachment) ?? null)) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or // retry — runs under the same liveness guard and the same reset. const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( () => { if (variant !== 'single') return undefined // A preview whose intake probe has not resolved sizes as a square crop; // the durable replacement restores the exact fit. return dimensions === undefined ? { width: 240, height: 240, objectPosition: 'center' } : singleFit(dimensions) }, [dimensions, variant], ) useEffect(() => { if (attachment === undefined) return let live = true setError(false) setLoaded(load.peek?.(attachment) ?? null) void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) const src = preview?.url ?? loaded const label = ('attachment' in image ? image.label : undefined) ?? preview?.name ?? attachment?.name ?? labels.image const contextMenuProps = getTriggerProps(src === null ? undefined : { kind: 'image', value: { url: src, name: label, ...(attachment === undefined ? {} : { mediaType: attachment.mediaType }), } }) const loadingThumbnail = variant === 'thumbnail' && src === null if (error) return ( ) return ( <> {open && src !== null && } ) } /** Wrapping image group shared by user and assistant history: a lone image * renders large unless its owner requests compact tiles or contained list thumbnails. */ export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag, getTriggerProps }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean thumbnail?: boolean fileDrag: FileDragActions labels: MessageImageLabels getTriggerProps: ContextMenuBindings['getTriggerProps'] }) { if (images.length === 0) return null const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' return (
{images.map((image, index) => ( ))}
) }