import React from 'react'
import { optimizeMessagingAttachmentUrl } from '../../../utils/cdnImageUrl'
import Bubble from '../_shared/Bubble'
import DismissButton from '../_shared/DismissButton'
import ImageViewer, { type ImageViewerItem } from '../_shared/ImageViewer'
import MediaStackGrid, { type MediaStackTile } from '../_shared/MediaStackGrid'
import { useSingleMediaRatio } from '../_shared/useSingleMediaRatio'
import { useViewer } from '../_shared/useViewer'
import {
bubbleVariantForState,
type ImageItem,
type ImageLoadingMode,
type MessageAttachmentBaseProps,
type MessageAttachmentState,
} from '../types'
export interface ImageAttachmentSharedProps extends MessageAttachmentBaseProps {
/** Single image — convenience for the most common case. */
src?: string
alt?: string
/** Filename used as the viewer dialog's accessible name. */
filename?: string
/**
* Stacked images. Takes precedence over `src` when set. Renders a
* 1 / 2 / 3 / 4-tile grid (5+ collapse into a `+N` overflow tile).
* Sent + Received only — the composer surface intentionally accepts
* a single attachment at a time.
*/
items?: ImageItem[]
/**
* Native lazy-load hint forwarded to every `` rendered on the
* bubble surface (Composer thumbnail, single + stacked tiles, and
* the `+N` overflow tile). Defaults to `'lazy'` — chat surfaces
* usually scroll a long history, and lazy loading prevents every
* historical image from being fetched on mount. Set `'eager'` for
* above-the-fold hero attachments. Per-tile overrides live on
* `ImageItem.loading`. The opened `ImageViewer` always eager-loads
* the active image regardless of this value.
*/
loading?: ImageLoadingMode
/**
* Forwarded to the Image viewer trigger. When omitted the click
* still opens the built-in viewer — supply this for analytics, or
* return `false` to intercept the open (e.g. switch to a route-
* based gallery / confirmation modal). Any other return value
* (including `void`/`undefined`) lets the built-in viewer open.
* For stacked attachments the `index` argument identifies the tile.
*/
onClick?: (index: number) => boolean | void
}
const tileFromItem = ({
item,
index,
totalCount,
fallbackLoading,
imgRef,
onLoad,
}: {
item: ImageItem
index: number
totalCount: number
fallbackLoading: ImageLoadingMode
/** Set on the single-image tile only — see `useSingleMediaRatio`. */
imgRef?: React.Ref
onLoad?: React.ReactEventHandler
}): MediaStackTile => ({
ariaLabel: `Open image ${index + 1} of ${totalCount}`,
content: (
// No corner radius here: `MediaStackGrid` clips the four outer
// corners of the whole cluster and keeps every inner edge square.
),
})
const resolveItems = ({
src,
alt,
items,
}: {
src?: string
alt?: string
items?: ImageItem[]
}): ImageItem[] => {
if (items && items.length > 0) return items
if (src) return [{ src, alt }]
return []
}
interface InternalImageRowProps extends ImageAttachmentSharedProps {
state: MessageAttachmentState
/**
* Renders a dismiss button on each tile (Composer only). When the
* stack has multiple tiles, the dismiss applies to the whole stack
* — that mirrors the mobile composer where attachments are removed
* as a single unit.
*/
onDismiss?: () => void
}
/**
* Project the bubble's `ImageItem`s onto the carousel-aware viewer's
* `ImageViewerItem` shape. When a shared outer `filename` is supplied
* for a stacked bubble, suffix `(N)` per sibling so each carousel page
* still gets a distinct accessible name + download default.
*/
const buildViewerItems = (
resolvedItems: ImageItem[],
filename?: string
): ImageViewerItem[] =>
resolvedItems.map((item, index) => ({
src: item.src,
alt: item.alt,
filename:
filename && resolvedItems.length === 1
? filename
: filename
? `${filename} (${index + 1})`
: undefined,
}))
/**
* Composer rendering — bare 280px-square image with a dismiss `×`
* overlay and `rounded-md` corners. Intentionally renders without the
* shared `Bubble` chrome (no border / no background / no padding) so
* the in-progress attachment looks like a draft preview, not a sent
* message. The composer surface only supports a single attachment at
* a time — `items` and `text` are ignored here.
*/
const ImageComposerInner: React.FC<{
src: string
alt?: string
filename?: string
loading?: ImageLoadingMode
onClick?: (index: number) => boolean | void
onDismiss?: () => void
}> = ({ src, alt, filename, loading = 'lazy', onClick, onDismiss }) => {
const { viewerOpen, viewerIndex, handleActivate, closeViewer } =
useViewer(onClick)
return (
{onDismiss ? (
) : null}
)
}
/**
* Box reserved for a single image whose ratio is not known yet — the
* 4:3 landscape card Figma draws for the most common case (330×250
* card, 326×246 content, `attachment-single-media` 1972:13149). A
* bounded reserve keeps a scrolling thread from jumping by an
* arbitrary amount between mount and decode.
*/
const RESERVED_SINGLE_ASPECT_RATIO = 326 / 246
/**
* Sent / Received rendering — wrapped in the shared `Bubble` chrome,
* supports single or stacked items, and renders an optional caption
* below the media.
*/
const ImageBubbleRow: React.FC = ({
state,
src,
alt,
filename,
items,
text,
groupPosition,
loading = 'lazy',
onClick,
}) => {
const resolvedItems = resolveItems({ src, alt, items })
const variant = bubbleVariantForState(state)
const { viewerOpen, viewerIndex, handleActivate, closeViewer } =
useViewer(onClick)
const isSingle = resolvedItems.length === 1
const singleItem = isSingle ? resolvedItems[0] : undefined
const single = useSingleMediaRatio({
src: singleItem?.src,
suppliedRatio:
singleItem?.width && singleItem?.height
? singleItem.width / singleItem.height
: undefined,
fallbackRatio: RESERVED_SINGLE_ASPECT_RATIO,
})
if (resolvedItems.length === 0) {
return null
}
const tiles: MediaStackTile[] = resolvedItems.map((item, index) =>
tileFromItem({
item,
index,
totalCount: resolvedItems.length,
fallbackLoading: loading,
imgRef: isSingle ? single.imgRef : undefined,
onLoad: isSingle ? single.onLoad : undefined,
})
)
return (
)
}
/**
* Composer-only props. Single image (`src`) is required; stacked
* `items` and `text` captions are not supported in the composer state.
*/
export interface ImageComposerProps {
src: string
alt?: string
filename?: string
/**
* Native lazy-load hint forwarded to the composer thumbnail ``.
* Defaults to `'lazy'`. See `ImageAttachmentSharedProps.loading` for
* the rationale.
*/
loading?: ImageLoadingMode
onClick?: (index: number) => boolean | void
onDismiss?: () => void
}
export type ImageSentProps = ImageAttachmentSharedProps
export type ImageReceivedProps = ImageAttachmentSharedProps
const ImageComposer: React.FC = (props) => (
)
const ImageSent: React.FC = (props) => (
)
const ImageReceived: React.FC = (props) => (
)
const ImageAttachment = {
Composer: ImageComposer,
Sent: ImageSent,
Received: ImageReceived,
}
export default ImageAttachment