flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(frontend): keep avatar images stable

Session-Id: d3bed945-de3e-47bd-b8b7-07e0daebfc82

+117 -15
+117 -15
apps/frontend/src/components/ui/avatar.tsx
··· 3 3 4 4 import { cn } from '@/lib/utils' 5 5 6 + type AvatarImageStatus = 'idle' | 'loading' | 'loaded' | 'error' 7 + 8 + type AvatarContextValue = { 9 + status: AvatarImageStatus 10 + setStatus: (status: AvatarImageStatus) => void 11 + } 12 + 13 + const AvatarContext = React.createContext<AvatarContextValue | null>(null) 14 + const loadedAvatarImages = new Set<string>() 15 + 16 + function useAvatarContext() { 17 + const context = React.useContext(AvatarContext) 18 + if (!context) { 19 + throw new Error('Avatar components must be wrapped in <Avatar>.') 20 + } 21 + return context 22 + } 23 + 6 24 function Avatar({ 7 25 className, 8 26 size = 'default', ··· 10 28 }: AvatarPrimitive.Root.Props & { 11 29 size?: 'default' | 'sm' | 'lg' 12 30 }) { 31 + const [status, setStatus] = React.useState<AvatarImageStatus>('idle') 32 + const value = React.useMemo(() => ({ status, setStatus }), [status]) 33 + 13 34 return ( 14 - <AvatarPrimitive.Root 15 - data-slot='avatar' 16 - data-size={size} 17 - className={cn( 18 - 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', 19 - className 20 - )} 21 - {...props} 22 - /> 35 + <AvatarContext.Provider value={value}> 36 + <AvatarPrimitive.Root 37 + data-slot='avatar' 38 + data-size={size} 39 + className={cn( 40 + 'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten', 41 + className 42 + )} 43 + {...props} 44 + /> 45 + </AvatarContext.Provider> 23 46 ) 24 47 } 25 48 26 - function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { 49 + type AvatarImageProps = React.ComponentProps<'img'> 50 + 51 + function AvatarImage( 52 + { className, src, onLoad, onError, loading, decoding, ...props }: AvatarImageProps 53 + ) { 54 + const { setStatus } = useAvatarContext() 55 + const cached = Boolean(src && loadedAvatarImages.has(src)) 56 + const [isLoaded, setIsLoaded] = React.useState(cached) 57 + const [hasError, setHasError] = React.useState(false) 58 + 59 + React.useLayoutEffect(() => { 60 + if (!src) { 61 + setIsLoaded(false) 62 + setHasError(false) 63 + setStatus('idle') 64 + return 65 + } 66 + 67 + const isCached = loadedAvatarImages.has(src) 68 + setIsLoaded(isCached) 69 + setHasError(false) 70 + setStatus(isCached ? 'loaded' : 'loading') 71 + }, [setStatus, src]) 72 + 73 + const handleLoad = (event: React.SyntheticEvent<HTMLImageElement>) => { 74 + if (src) { 75 + loadedAvatarImages.add(src) 76 + } 77 + setIsLoaded(true) 78 + setHasError(false) 79 + setStatus('loaded') 80 + onLoad?.(event) 81 + } 82 + 83 + const handleError = (event: React.SyntheticEvent<HTMLImageElement>) => { 84 + setIsLoaded(false) 85 + setHasError(true) 86 + setStatus('error') 87 + onError?.(event) 88 + } 89 + 90 + if (!src || hasError) { 91 + return null 92 + } 93 + 27 94 return ( 28 - <AvatarPrimitive.Image 95 + <img 29 96 data-slot='avatar-image' 97 + data-loaded={isLoaded} 30 98 className={cn( 31 - 'aspect-square size-full rounded-full object-cover', 99 + 'absolute inset-0 aspect-square size-full rounded-full object-cover transition-opacity duration-150', 100 + isLoaded ? 'opacity-100' : 'opacity-0', 32 101 className 33 102 )} 103 + src={src} 104 + loading={loading ?? 'eager'} 105 + decoding={decoding ?? 'async'} 106 + onLoad={handleLoad} 107 + onError={handleError} 34 108 {...props} 35 109 /> 36 110 ) 37 111 } 38 112 113 + type AvatarFallbackProps = React.ComponentProps<'span'> & { 114 + delay?: number 115 + } 116 + 39 117 function AvatarFallback({ 40 118 className, 119 + delay = 150, 41 120 ...props 42 - }: AvatarPrimitive.Fallback.Props) { 121 + }: AvatarFallbackProps) { 122 + const { status } = useAvatarContext() 123 + const [delayPassed, setDelayPassed] = React.useState(delay === undefined) 124 + 125 + React.useEffect(() => { 126 + if (status === 'loaded') { 127 + setDelayPassed(false) 128 + return 129 + } 130 + 131 + if (delay === undefined) { 132 + setDelayPassed(true) 133 + return 134 + } 135 + 136 + setDelayPassed(false) 137 + const timeout = window.setTimeout(() => setDelayPassed(true), delay) 138 + return () => window.clearTimeout(timeout) 139 + }, [delay, status]) 140 + 141 + if (status === 'loaded' || !delayPassed) { 142 + return null 143 + } 144 + 43 145 return ( 44 - <AvatarPrimitive.Fallback 146 + <span 45 147 data-slot='avatar-fallback' 46 148 className={cn( 47 - 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs', 149 + 'absolute inset-0 flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs', 48 150 className 49 151 )} 50 152 {...props}