@@ -175,6 +175,40 @@ function ensureContrastWithWhite(hex: string): string {
175175 return darkenColor ( rgb . r , rgb . g , rgb . b , factor - 0.1 )
176176}
177177
178+ /**
179+ * For animated GIF avatars, extract the first frame as a static PNG data URL.
180+ * Returns null for non-GIF images or while loading.
181+ */
182+ function useStaticFrame ( url : string | undefined ) : string | null {
183+ const [ frame , setFrame ] = useState < string | null > ( null )
184+
185+ useEffect ( ( ) => {
186+ if ( ! url ) { setFrame ( null ) ; return }
187+ let cancelled = false
188+
189+ fetch ( url )
190+ . then ( r => r . blob ( ) )
191+ . then ( blob => {
192+ if ( cancelled || blob . type !== 'image/gif' ) return
193+ const img = new Image ( )
194+ img . onload = ( ) => {
195+ if ( cancelled ) return
196+ const c = document . createElement ( 'canvas' )
197+ c . width = img . naturalWidth
198+ c . height = img . naturalHeight
199+ c . getContext ( '2d' ) ?. drawImage ( img , 0 , 0 )
200+ setFrame ( c . toDataURL ( 'image/png' ) )
201+ }
202+ img . src = url
203+ } )
204+ . catch ( ( ) => { } )
205+
206+ return ( ) => { cancelled = true }
207+ } , [ url ] )
208+
209+ return frame
210+ }
211+
178212/**
179213 * Reusable Avatar component with XEP-0392 consistent color generation.
180214 *
@@ -184,6 +218,7 @@ function ensureContrastWithWhite(hex: string): string {
184218 * - Supports presence indicators
185219 * - Multiple size presets
186220 * - Accessible with proper alt text
221+ * - Animated GIF avatars are frozen by default, play on hover
187222 */
188223export function Avatar ( {
189224 identifier,
@@ -234,6 +269,10 @@ export function Avatar({
234269 const [ imgError , setImgError ] = useState ( false )
235270 useEffect ( ( ) => { setImgError ( false ) } , [ avatarUrl ] )
236271
272+ // Animated GIF: show static first frame by default, animate on hover
273+ const staticFrame = useStaticFrame ( avatarUrl )
274+ const [ hovered , setHovered ] = useState ( false )
275+
237276 // Determine if clickable
238277 const isClickable = clickable ?? ! ! onClick
239278
@@ -246,11 +285,15 @@ export function Avatar({
246285 ] . filter ( Boolean ) . join ( ' ' )
247286
248287 return (
249- < div className = { containerClasses } onClick = { onClick } >
288+ < div
289+ className = { containerClasses }
290+ onClick = { onClick }
291+ onMouseEnter = { staticFrame ? ( ) => setHovered ( true ) : undefined }
292+ onMouseLeave = { staticFrame ? ( ) => setHovered ( false ) : undefined }
293+ >
250294 { avatarUrl && ! imgError ? (
251- // Avatar image
252295 < img
253- src = { avatarUrl }
296+ src = { staticFrame && ! hovered ? staticFrame : avatarUrl }
254297 alt = { displayName }
255298 className = "w-full h-full rounded-full object-cover"
256299 draggable = { false }
0 commit comments