@@ -25,6 +25,7 @@ export function ImageBubble({
2525 const [ inView , setInView ] = useState ( false )
2626 const [ retryCount , setRetryCount ] = useState ( 0 )
2727 const [ failed , setFailed ] = useState ( false )
28+ const [ loaded , setLoaded ] = useState ( false )
2829 const retryTimerRef = useRef < ReturnType < typeof setTimeout > > ( undefined )
2930
3031 // Clean up pending retry timer on unmount.
@@ -76,10 +77,11 @@ export function ImageBubble({
7677 } , delay )
7778 } , [ imageUrl , retryCount ] )
7879
79- // Reset retry state when imageUrl changes (e.g. sender switches from blob to http).
80+ // Reset retry/loading state when imageUrl changes (e.g. sender switches from blob to http).
8081 useEffect ( ( ) => {
8182 setRetryCount ( 0 )
8283 setFailed ( false )
84+ setLoaded ( false )
8385 } , [ imageUrl ] )
8486
8587 const shouldLoad = inView || ! imageUrl
@@ -104,21 +106,38 @@ export function ImageBubble({
104106 Image unavailable
105107 </ div >
106108 ) : (
107- < Dialog >
108- < DialogTrigger asChild >
109- < img
110- src = { src }
111- alt = "image"
112- loading = "lazy"
113- onError = { handleError }
114- className = "block max-h-72 max-w-[280px] cursor-zoom-in rounded-[4px] object-cover"
115- />
116- </ DialogTrigger >
117- < DialogContent className = "max-w-4xl bg-black/95 p-2" aria-describedby = { undefined } >
118- < DialogTitle className = "sr-only" > Image preview</ DialogTitle >
119- < img src = { src } alt = "image-full" className = "max-h-[85vh] w-full rounded object-contain" />
120- </ DialogContent >
121- </ Dialog >
109+ < >
110+ { /* Spinner overlay while loading */ }
111+ { ! loaded && (
112+ < div className = "flex h-48 w-[280px] items-center justify-center rounded-[4px] bg-muted" >
113+ < svg
114+ className = "h-8 w-8 animate-spin text-muted-foreground"
115+ xmlns = "http://www.w3.org/2000/svg"
116+ fill = "none"
117+ viewBox = "0 0 24 24"
118+ >
119+ < circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
120+ < path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
121+ </ svg >
122+ </ div >
123+ ) }
124+ < Dialog >
125+ < DialogTrigger asChild >
126+ < img
127+ src = { src }
128+ alt = "image"
129+ loading = "lazy"
130+ onLoad = { ( ) => setLoaded ( true ) }
131+ onError = { handleError }
132+ className = { `block max-h-72 max-w-[280px] cursor-zoom-in rounded-[4px] object-cover${ loaded ? "" : " hidden" } ` }
133+ />
134+ </ DialogTrigger >
135+ < DialogContent className = "max-w-4xl bg-black/95 p-2" aria-describedby = { undefined } >
136+ < DialogTitle className = "sr-only" > Image preview</ DialogTitle >
137+ < img src = { src } alt = "image-full" className = "max-h-[85vh] w-full rounded object-contain" />
138+ </ DialogContent >
139+ </ Dialog >
140+ </ >
122141 )
123142 ) : (
124143 < div className = "h-48 w-[280px]" />
0 commit comments