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