'use client'
/**
* Click-to-expand handler for chat-attachment images in the chat thread.
*
* Returns `{ panelRef, modal }`:
* - `panelRef` — attach to the chat content wrapper. The hook
* installs a delegated `click` listener on this ref.
* - `modal` — render as a sibling of the chat panel (so it lives
* OUTSIDE any overflow-hidden message bubbles). Returns `null`
* while closed.
*
* Behavior on click:
* 1. Filter for `
` whose `src` starts with the runtime's
* `attachmentViewUrlPrefix` (so non-chat images don't intercept).
* 2. Walk up to the nearest `[data-message-role]` ancestor — the
* boundary the chat-shell wraps each message bubble with.
* 3. Collect ALL chat-attachment `
`s inside that boundary in
* DOM order. Compute the clicked index for `initialIndex`.
* 4. Open `ImageGalleryModal` (UI-Kit, also used by
* `roadmap-card.tsx` for screenshot expansion).
*
* The hook deliberately owns the click DOM-walk + modal state so
* the host component consumes a single `{panelRef, modal}` API.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { ImageGalleryModal } from '../../ui/image-gallery-modal'
import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
interface GalleryState {
isOpen: boolean
images: string[]
initialIndex: number
}
const CLOSED: GalleryState = { isOpen: false, images: [], initialIndex: 0 }
export function useChatAttachmentImageGallery() {
const runtime = useRequiredChatRuntime()
const viewUrlPrefix = runtime.endpoints.attachmentViewUrlPrefix
const panelRef = useRef(null)
const [state, setState] = useState(CLOSED)
const handleClose = useCallback(() => setState(CLOSED), [])
useEffect(() => {
const root = panelRef.current
if (!root) return
const onClick = (ev: Event) => {
const target = ev.target as HTMLElement | null
if (!target) return
// Match an
whose `src` starts with the proxy URL prefix.
const img = target.closest('img') as HTMLImageElement | null
if (!img || !img.src.startsWith(absolutize(viewUrlPrefix))) return
// Walk up to the message-bubble boundary.
const bubble = img.closest('[data-message-role]')
if (!bubble) {
// Fallback: open the modal with just the clicked image.
ev.preventDefault()
setState({ isOpen: true, images: [img.src], initialIndex: 0 })
return
}
// Collect all chat-attachment images inside this bubble in DOM
// order. The `closest` lookup matched img.src.startsWith already,
// but we re-check below because `querySelectorAll` returns ALL
//
s in the bubble (avatar images etc.).
const allImgs = Array.from(bubble.querySelectorAll('img')) as HTMLImageElement[]
const matching = allImgs.filter((el) => el.src.startsWith(absolutize(viewUrlPrefix)))
if (matching.length === 0) return
const idx = Math.max(0, matching.indexOf(img))
ev.preventDefault()
setState({
isOpen: true,
images: matching.map((el) => el.src),
initialIndex: idx,
})
}
root.addEventListener('click', onClick)
return () => root.removeEventListener('click', onClick)
}, [viewUrlPrefix])
// `ImageGalleryModal` doesn't accept `null` for `images` — render
// it only when open. Closing returns `null` so the modal unmounts
// and React garbage-collects any blob URLs it might hold.
const modal = state.isOpen ? (
) : null
return { panelRef, modal }
}
/** Absolutize a prefix for comparison against `
.src`. Browsers
* resolve `
` to an absolute URL when
* reading `.src` — we need the same shape on the prefix to match.
*
* Embedded apps that supply an absolute prefix already work as-is
* (the prefix matches `.src` directly). For relative prefixes (host
* mode), prefix with `window.location.origin`. */
function absolutize(prefix: string): string {
if (prefix.startsWith('http://') || prefix.startsWith('https://')) return prefix
if (typeof window === 'undefined') return prefix
return `${window.location.origin}${prefix.startsWith('/') ? '' : '/'}${prefix}`
}