Skip to content

Commit 21f8062

Browse files
committed
feat(avatar): freeze animated GIF avatars, play only on hover
Adds a useStaticFrame hook that detects GIF blob URLs and extracts the first frame via canvas. Static avatars (PNG/JPEG) are unaffected.
1 parent caf9dfa commit 21f8062

1 file changed

Lines changed: 46 additions & 3 deletions

File tree

apps/fluux/src/components/Avatar.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
188223
export 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

Comments
 (0)