index.tsx raw
1 import { Skeleton } from '@/components/ui/skeleton'
2 import { cn } from '@/lib/utils'
3 import blossomService from '@/services/blossom.service'
4 import { TImetaInfo } from '@/types'
5 import { decode } from 'blurhash'
6 import { ImageOff } from 'lucide-react'
7 import { HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react'
8 import { thumbHashToDataURL } from 'thumbhash'
9
10 export default function Image({
11 image: { url, blurHash, thumbHash, pubkey, dim, variant, sha256 },
12 alt,
13 className = '',
14 classNames = {},
15 hideIfError = false,
16 errorPlaceholder = <ImageOff />,
17 originalUrl,
18 ...props
19 }: HTMLAttributes<HTMLDivElement> & {
20 classNames?: {
21 wrapper?: string
22 errorPlaceholder?: string
23 skeleton?: string
24 }
25 image: TImetaInfo
26 alt?: string
27 hideIfError?: boolean
28 errorPlaceholder?: React.ReactNode
29 originalUrl?: string
30 }) {
31 const [isLoading, setIsLoading] = useState(true)
32 const [displaySkeleton, setDisplaySkeleton] = useState(true)
33 const [hasError, setHasError] = useState(false)
34 const [imageUrl, setImageUrl] = useState<string>()
35 const [naturalDim, setNaturalDim] = useState<{ width: number; height: number } | null>(null)
36 const [showTooltip, setShowTooltip] = useState(false)
37 const timeoutRef = useRef<NodeJS.Timeout | null>(null)
38
39 useEffect(() => {
40 setIsLoading(true)
41 setHasError(false)
42 setDisplaySkeleton(true)
43
44 if (pubkey) {
45 // BlossomService now actively validates URLs and races mirrors.
46 // The promise will resolve with the best available URL.
47 blossomService.getValidUrl(url, pubkey).then((validUrl) => {
48 setImageUrl(validUrl)
49 if (timeoutRef.current) {
50 clearTimeout(timeoutRef.current)
51 timeoutRef.current = null
52 }
53 })
54 // Fallback timeout in case something goes wrong with the service
55 timeoutRef.current = setTimeout(() => {
56 if (!imageUrl) {
57 setImageUrl(url)
58 }
59 }, 3000)
60 } else {
61 setImageUrl(url)
62 }
63
64 return () => {
65 if (timeoutRef.current) {
66 clearTimeout(timeoutRef.current)
67 timeoutRef.current = null
68 }
69 }
70 }, [url])
71
72 if (hideIfError && hasError) return null
73
74 const handleError = async () => {
75 const nextUrl = await blossomService.tryNextUrl(url)
76 if (nextUrl) {
77 setImageUrl(nextUrl)
78 } else {
79 setIsLoading(false)
80 setHasError(true)
81 }
82 }
83
84 const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
85 const img = e.currentTarget
86 setNaturalDim({ width: img.naturalWidth, height: img.naturalHeight })
87 setIsLoading(false)
88 setHasError(false)
89 setTimeout(() => setDisplaySkeleton(false), 600)
90 blossomService.markAsSuccess(url, imageUrl || url)
91 }
92
93 return (
94 <div
95 className={cn('relative overflow-hidden rounded-xl group/imgdebug', classNames.wrapper)}
96 onMouseEnter={() => setShowTooltip(true)}
97 onMouseLeave={() => setShowTooltip(false)}
98 {...props}
99 >
100 {/* Debug tooltip overlay */}
101 {showTooltip && !isLoading && !hasError && (
102 <div className="absolute top-2 left-2 z-50 max-w-[90%] pointer-events-none">
103 <div className="bg-black/85 text-white text-xs rounded-lg px-3 py-2 shadow-lg backdrop-blur-sm space-y-1 font-mono">
104 {variant && (
105 <div className="flex items-center gap-1.5">
106 <span className="inline-block bg-blue-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide">
107 variant
108 </span>
109 <span className="text-blue-300">{variant}</span>
110 </div>
111 )}
112 <div className="truncate">
113 <span className="text-gray-400">url: </span>
114 <span className="text-green-300">{imageUrl || url}</span>
115 </div>
116 {naturalDim && (
117 <div>
118 <span className="text-gray-400">rendered: </span>
119 <span className="text-yellow-300">{naturalDim.width}x{naturalDim.height}</span>
120 </div>
121 )}
122 {dim && (
123 <div>
124 <span className="text-gray-400">declared: </span>
125 <span className="text-yellow-300">{dim.width}x{dim.height}</span>
126 </div>
127 )}
128 {variant && originalUrl && (
129 <div className="truncate">
130 <span className="text-gray-400">original: </span>
131 <span className="text-orange-300">{originalUrl}</span>
132 </div>
133 )}
134 {sha256 && (
135 <div className="truncate">
136 <span className="text-gray-400">sha256: </span>
137 <span className="text-purple-300">{sha256}</span>
138 </div>
139 )}
140 </div>
141 </div>
142 )}
143 {/* Spacer: transparent image to maintain dimensions when image is loading */}
144 {isLoading && dim?.width && dim?.height && (
145 <img
146 src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
147 className={cn(
148 'object-cover transition-opacity pointer-events-none w-full h-full',
149 className
150 )}
151 alt=""
152 />
153 )}
154 {displaySkeleton && (
155 <div className="absolute inset-0 z-10">
156 {thumbHash ? (
157 <ThumbHashPlaceholder
158 thumbHash={thumbHash}
159 className={cn(
160 'w-full h-full transition-opacity',
161 isLoading ? 'opacity-100' : 'opacity-0'
162 )}
163 />
164 ) : blurHash ? (
165 <BlurHashCanvas
166 blurHash={blurHash}
167 className={cn(
168 'w-full h-full transition-opacity',
169 isLoading ? 'opacity-100' : 'opacity-0'
170 )}
171 />
172 ) : (
173 <Skeleton
174 className={cn(
175 'w-full h-full transition-opacity',
176 isLoading ? 'opacity-100' : 'opacity-0',
177 classNames.skeleton
178 )}
179 />
180 )}
181 </div>
182 )}
183 {!hasError && (
184 <img
185 src={imageUrl}
186 alt={alt}
187 decoding="async"
188 draggable={false}
189 {...props}
190 onLoad={handleLoad}
191 onError={handleError}
192 className={cn(
193 'object-cover transition-opacity pointer-events-none w-full h-full',
194 isLoading ? 'opacity-0 absolute inset-0' : '',
195 className
196 )}
197 />
198 )}
199 {hasError &&
200 (typeof errorPlaceholder === 'string' ? (
201 <img
202 src={errorPlaceholder}
203 alt={alt}
204 decoding="async"
205 loading="lazy"
206 className={cn('object-cover w-full h-full transition-opacity', className)}
207 />
208 ) : (
209 <div
210 className={cn(
211 'object-cover flex flex-col items-center justify-center w-full h-full bg-muted',
212 className,
213 classNames.errorPlaceholder
214 )}
215 >
216 {errorPlaceholder}
217 </div>
218 ))}
219 </div>
220 )
221 }
222
223 const blurHashWidth = 32
224 const blurHashHeight = 32
225 function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; className?: string }) {
226 const canvasRef = useRef<HTMLCanvasElement>(null)
227
228 const pixels = useMemo(() => {
229 if (!blurHash) return null
230 try {
231 return decode(blurHash, blurHashWidth, blurHashHeight)
232 } catch (error) {
233 console.warn('Failed to decode blurhash:', error)
234 return null
235 }
236 }, [blurHash])
237
238 useEffect(() => {
239 if (!pixels || !canvasRef.current) return
240
241 const canvas = canvasRef.current
242 const ctx = canvas.getContext('2d')
243 if (!ctx) return
244
245 const imageData = ctx.createImageData(blurHashWidth, blurHashHeight)
246 imageData.data.set(pixels)
247 ctx.putImageData(imageData, 0, 0)
248 }, [pixels])
249
250 if (!blurHash) return null
251
252 return (
253 <canvas
254 ref={canvasRef}
255 width={blurHashWidth}
256 height={blurHashHeight}
257 className={cn('w-full h-full object-cover rounded-xl', className)}
258 style={{
259 imageRendering: 'auto',
260 filter: 'blur(0.5px)'
261 }}
262 />
263 )
264 }
265
266 function ThumbHashPlaceholder({
267 thumbHash,
268 className = ''
269 }: {
270 thumbHash: Uint8Array
271 className?: string
272 }) {
273 const dataUrl = useMemo(() => {
274 if (!thumbHash) return null
275 try {
276 return thumbHashToDataURL(thumbHash)
277 } catch (error) {
278 console.warn('failed to decode thumbhash:', error)
279 return null
280 }
281 }, [thumbHash])
282
283 if (!dataUrl) return null
284
285 return (
286 <div
287 className={cn('w-full h-full object-cover rounded-lg', className)}
288 style={{
289 backgroundImage: `url(${dataUrl})`,
290 backgroundSize: 'cover',
291 backgroundPosition: 'center',
292 filter: 'blur(1px)'
293 }}
294 />
295 )
296 }
297