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. {item.alt ), }) 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