@@ -70,6 +70,12 @@ export function MessageBubble({
7070 . filter ( ( part ) => part . type === "text" )
7171 . map ( ( part ) => ( part . type === "text" ? part . text : "" ) )
7272 . join ( "\n" ) ;
73+ const hasImage =
74+ message . status === "published" &&
75+ message . parts . some (
76+ ( part ) =>
77+ part . type === "attachment" && part . mediaType . startsWith ( "image/" ) ,
78+ ) ;
7379
7480 async function copyText ( ) {
7581 if ( text ) await navigator . clipboard . writeText ( text ) ;
@@ -87,7 +93,7 @@ export function MessageBubble({
8793 ) }
8894 < div className = "message-stack" >
8995 < article
90- className = { `message ${ mine ? "mine" : "" } ${ message . status === "redacted" ? "deleted" : "" } ` }
96+ className = { `message ${ mine ? "mine" : "" } ${ hasImage ? "image-message" : "" } ${ message . status === "redacted" ? "deleted" : "" } ` }
9197 onContextMenu = { ( event ) => {
9298 event . preventDefault ( ) ;
9399 setMenuOpen ( true ) ;
@@ -309,11 +315,14 @@ function Attachment({
309315 partId,
310316 } ) ;
311317
312- if ( ! url ) {
318+ if ( url === undefined ) {
319+ return < AttachmentPlaceholder mediaType = { mediaType } /> ;
320+ }
321+ if ( url === null ) {
313322 return (
314- < div className = "attachment-placeholder " >
315- < Skeleton className = "attachment-skeleton" />
316- < Skeleton className = "attachment-skeleton-line" / >
323+ < div className = "attachment-unavailable " >
324+ < FileText />
325+ < span > { fallbackText } is unavailable </ span >
317326 </ div >
318327 ) ;
319328 }
@@ -346,6 +355,47 @@ function Attachment({
346355 ) ;
347356}
348357
358+ function AttachmentPlaceholder ( { mediaType } : { mediaType : string } ) {
359+ if ( mediaType . startsWith ( "image/" ) ) {
360+ return (
361+ < div
362+ className = "attachment-placeholder image"
363+ role = "status"
364+ aria-label = "Loading image"
365+ >
366+ < Skeleton className = "attachment-skeleton" />
367+ </ div >
368+ ) ;
369+ }
370+
371+ if ( mediaType . startsWith ( "audio/" ) ) {
372+ return (
373+ < div
374+ className = "attachment-placeholder audio"
375+ role = "status"
376+ aria-label = "Loading voice message"
377+ >
378+ < Skeleton className = "attachment-skeleton-audio-button" />
379+ < div className = "attachment-skeleton-audio-track" >
380+ < Skeleton className = "attachment-skeleton-audio-label" />
381+ < Skeleton className = "attachment-skeleton-audio-line" />
382+ < Skeleton className = "attachment-skeleton-audio-time" />
383+ </ div >
384+ </ div >
385+ ) ;
386+ }
387+
388+ return (
389+ < div
390+ className = "attachment-placeholder file"
391+ role = "status"
392+ aria-label = "Loading attachment"
393+ >
394+ < Skeleton className = "attachment-skeleton-line" />
395+ </ div >
396+ ) ;
397+ }
398+
349399function ImageAttachment ( {
350400 fallbackText,
351401 message,
@@ -361,6 +411,9 @@ function ImageAttachment({
361411} ) {
362412 const [ open , setOpen ] = useState ( false ) ;
363413 const [ showReactions , setShowReactions ] = useState ( false ) ;
414+ const [ loadState , setLoadState ] = useState < "loading" | "loaded" | "error" > (
415+ "loading" ,
416+ ) ;
364417 const replyAfterClose = useRef ( false ) ;
365418
366419 function closeAndReply ( ) {
@@ -381,8 +434,30 @@ function ImageAttachment({
381434 className = "message-image-trigger"
382435 type = "button"
383436 aria-label = { `Open ${ fallbackText } ` }
437+ disabled = { loadState !== "loaded" }
384438 >
385- < img className = "message-image" src = { url } alt = { fallbackText } />
439+ { loadState === "loading" && (
440+ < div className = "message-image-loading" aria-hidden = "true" >
441+ < Skeleton />
442+ </ div >
443+ ) }
444+ { loadState === "error" && (
445+ < span className = "message-image-error" > Image unavailable</ span >
446+ ) }
447+ < img
448+ className = { `message-image ${ loadState === "loaded" ? "loaded" : "" } ` }
449+ src = { url }
450+ alt = { fallbackText }
451+ decoding = "async"
452+ onLoad = { ( event ) => {
453+ const image = event . currentTarget ;
454+ void image
455+ . decode ( )
456+ . catch ( ( ) => undefined )
457+ . then ( ( ) => setLoadState ( "loaded" ) ) ;
458+ } }
459+ onError = { ( ) => setLoadState ( "error" ) }
460+ />
386461 </ button >
387462 </ DialogTrigger >
388463 < DialogContent
0 commit comments