index.tsx raw

   1  /**
   2   * Responsive Image Component
   3   *
   4   * Displays an image from a NIP-94 kind 1063 event with multiple resolution variants.
   5   * Automatically selects the appropriate variant based on viewport width and device pixel ratio.
   6   * Can also detect 64-character hex blob hashes and query for binding events automatically.
   7   */
   8  
   9  import Image from '@/components/Image'
  10  import {
  11    parseResponsiveImageEvent,
  12    selectVariantForViewport,
  13    getThumbnailVariant,
  14    UploadedVariant
  15  } from '@/lib/responsive-image-event'
  16  import { cn } from '@/lib/utils'
  17  import { TImetaInfo } from '@/types'
  18  import { useEffect, useMemo, useState } from 'react'
  19  import blossomService from '@/services/blossom.service'
  20  
  21  type ResponsiveImageProps = {
  22    /** The kind 1063 event containing imeta tags for all variants */
  23    event?: { kind: number; tags: string[][] }
  24    /** Or provide pre-parsed variants directly */
  25    variants?: UploadedVariant[]
  26    /** Target container width (defaults to viewport width) */
  27    containerWidth?: number
  28    /** Alt text for accessibility */
  29    alt?: string
  30    /** Pubkey for Blossom URL validation */
  31    pubkey?: string
  32    /** Additional class names */
  33    className?: string
  34    /** Class names for sub-elements */
  35    classNames?: {
  36      wrapper?: string
  37      errorPlaceholder?: string
  38      skeleton?: string
  39    }
  40    /** Hide if all variants fail to load */
  41    hideIfError?: boolean
  42    /** Custom error placeholder */
  43    errorPlaceholder?: React.ReactNode
  44    /** Force a specific variant (bypasses viewport selection) */
  45    forceVariant?: 'thumb' | 'mobile-sm' | 'mobile-lg' | 'desktop-sm' | 'desktop-md' | 'desktop-lg' | 'original'
  46    /** Use thumbnail mode (always show thumb variant) */
  47    thumbnail?: boolean
  48  }
  49  
  50  export default function ResponsiveImage({
  51    event,
  52    variants: providedVariants,
  53    containerWidth,
  54    alt,
  55    pubkey,
  56    className,
  57    classNames,
  58    hideIfError,
  59    errorPlaceholder,
  60    forceVariant,
  61    thumbnail
  62  }: ResponsiveImageProps) {
  63    const [viewportWidth, setViewportWidth] = useState(
  64      typeof window !== 'undefined' ? window.innerWidth : 1280
  65    )
  66  
  67    // Parse variants from event if provided
  68    const variants = useMemo(() => {
  69      if (providedVariants) return providedVariants
  70      if (event) {
  71        try {
  72          return parseResponsiveImageEvent(event)
  73        } catch {
  74          return []
  75        }
  76      }
  77      return []
  78    }, [event, providedVariants])
  79  
  80    // Update viewport width on resize
  81    useEffect(() => {
  82      if (typeof window === 'undefined') return
  83  
  84      const handleResize = () => {
  85        setViewportWidth(window.innerWidth)
  86      }
  87  
  88      window.addEventListener('resize', handleResize)
  89      return () => window.removeEventListener('resize', handleResize)
  90    }, [])
  91  
  92    // Select the best variant
  93    const selectedVariant = useMemo(() => {
  94      if (variants.length === 0) return null
  95  
  96      // Thumbnail mode - always use thumb
  97      if (thumbnail) {
  98        const thumb = getThumbnailVariant(variants)
  99        if (thumb) return thumb
 100      }
 101  
 102      // Force specific variant if requested
 103      if (forceVariant) {
 104        const forced = variants.find((v) => v.variant === forceVariant)
 105        if (forced) return forced
 106      }
 107  
 108      // Select based on viewport
 109      const targetWidth = containerWidth ?? viewportWidth
 110      const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1
 111  
 112      return selectVariantForViewport(variants, targetWidth, pixelRatio)
 113    }, [variants, viewportWidth, containerWidth, forceVariant, thumbnail])
 114  
 115    // Build the image info for the Image component
 116    const imageInfo: TImetaInfo | null = useMemo(() => {
 117      if (!selectedVariant) return null
 118  
 119      return {
 120        url: selectedVariant.url,
 121        blurHash: selectedVariant.blurhash,
 122        pubkey,
 123        dim: {
 124          width: selectedVariant.width,
 125          height: selectedVariant.height
 126        }
 127      }
 128    }, [selectedVariant, pubkey])
 129  
 130    if (!imageInfo) {
 131      if (hideIfError) return null
 132      return (
 133        <div
 134          className={cn(
 135            'flex items-center justify-center bg-muted rounded-xl',
 136            className,
 137            classNames?.errorPlaceholder
 138          )}
 139        >
 140          {errorPlaceholder ?? <span className="text-muted-foreground">No image</span>}
 141        </div>
 142      )
 143    }
 144  
 145    return (
 146      <Image
 147        image={imageInfo}
 148        alt={alt}
 149        className={className}
 150        classNames={classNames}
 151        hideIfError={hideIfError}
 152        errorPlaceholder={errorPlaceholder}
 153      />
 154    )
 155  }
 156  
 157  /**
 158   * Hook to fetch and parse a responsive image event
 159   */
 160  export function useResponsiveImage(eventId?: string) {
 161    const [variants, setVariants] = useState<UploadedVariant[]>([])
 162    const [loading, setLoading] = useState(false)
 163    const [error, _setError] = useState<Error | null>(null)
 164  
 165    useEffect(() => {
 166      if (!eventId) {
 167        setVariants([])
 168        return
 169      }
 170  
 171      // TODO: Fetch event from relays by ID
 172      // For now, this is a placeholder for the fetch logic
 173      setLoading(true)
 174      // client.fetchEvent(eventId).then(event => {
 175      //   if (event?.kind === 1063) {
 176      //     setVariants(parseResponsiveImageEvent(event))
 177      //   }
 178      // }).catch(setError).finally(() => setLoading(false))
 179      setLoading(false)
 180    }, [eventId])
 181  
 182    return { variants, loading, error }
 183  }
 184  
 185  /**
 186   * Hook to fetch responsive image variants from a blob hash.
 187   * Queries relays for kind 1063 binding events with matching x tag.
 188   *
 189   * @param blobHash - 64-character SHA256 hash of any variant
 190   * @returns Parsed variants, loading state, and whether binding event was found
 191   */
 192  export function useBindingEvent(blobHash?: string) {
 193    const [variants, setVariants] = useState<UploadedVariant[]>([])
 194    const [loading, setLoading] = useState(false)
 195    const [hasBindingEvent, setHasBindingEvent] = useState(false)
 196  
 197    useEffect(() => {
 198      if (!blobHash || !blossomService.isValidBlobHash(blobHash)) {
 199        setVariants([])
 200        setHasBindingEvent(false)
 201        return
 202      }
 203  
 204      let cancelled = false
 205      setLoading(true)
 206  
 207      blossomService.queryBindingEvent(blobHash).then((result) => {
 208        if (cancelled) return
 209        if (result && result.length > 0) {
 210          setVariants(result)
 211          setHasBindingEvent(true)
 212        } else {
 213          setVariants([])
 214          setHasBindingEvent(false)
 215        }
 216        setLoading(false)
 217      }).catch(() => {
 218        if (cancelled) return
 219        setVariants([])
 220        setHasBindingEvent(false)
 221        setLoading(false)
 222      })
 223  
 224      return () => {
 225        cancelled = true
 226      }
 227    }, [blobHash])
 228  
 229    return { variants, loading, hasBindingEvent }
 230  }
 231  
 232  /**
 233   * Props for ResponsiveImageFromHash component
 234   */
 235  type ResponsiveImageFromHashProps = {
 236    /** The 64-character SHA256 blob hash */
 237    hash: string
 238    /** Base Blossom server URL for fallback */
 239    baseServerUrl?: string
 240    /** Target container width in CSS pixels */
 241    containerWidth?: number
 242    /** Alt text for accessibility */
 243    alt?: string
 244    /** Pubkey for Blossom URL validation */
 245    pubkey?: string
 246    /** Additional class names */
 247    className?: string
 248    /** Class names for sub-elements */
 249    classNames?: {
 250      wrapper?: string
 251      errorPlaceholder?: string
 252      skeleton?: string
 253    }
 254    /** Hide if all variants fail to load */
 255    hideIfError?: boolean
 256    /** Custom error placeholder */
 257    errorPlaceholder?: React.ReactNode
 258    /** Use thumbnail mode (always show thumb variant) */
 259    thumbnail?: boolean
 260  }
 261  
 262  /**
 263   * Responsive image component that loads from a blob hash.
 264   *
 265   * When given a 64-character hex hash, queries relays for a kind 1063 binding event.
 266   * If found, selects the appropriate variant based on container width.
 267   * Falls back to the original blob URL if no binding event is found.
 268   */
 269  export function ResponsiveImageFromHash({
 270    hash,
 271    baseServerUrl,
 272    containerWidth,
 273    alt,
 274    pubkey,
 275    className,
 276    classNames,
 277    hideIfError,
 278    errorPlaceholder,
 279    thumbnail
 280  }: ResponsiveImageFromHashProps) {
 281    const { variants, loading, hasBindingEvent } = useBindingEvent(hash)
 282    const [viewportWidth, setViewportWidth] = useState(
 283      typeof window !== 'undefined' ? window.innerWidth : 1280
 284    )
 285  
 286    // Update viewport width on resize
 287    useEffect(() => {
 288      if (typeof window === 'undefined') return
 289  
 290      const handleResize = () => {
 291        setViewportWidth(window.innerWidth)
 292      }
 293  
 294      window.addEventListener('resize', handleResize)
 295      return () => window.removeEventListener('resize', handleResize)
 296    }, [])
 297  
 298    // Select the best variant
 299    const selectedVariant = useMemo(() => {
 300      if (variants.length === 0) return null
 301  
 302      // Thumbnail mode - always use thumb
 303      if (thumbnail) {
 304        const thumb = getThumbnailVariant(variants)
 305        if (thumb) return thumb
 306      }
 307  
 308      // Select based on viewport
 309      const targetWidth = containerWidth ?? viewportWidth
 310      const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1
 311  
 312      return selectVariantForViewport(variants, targetWidth, pixelRatio)
 313    }, [variants, viewportWidth, containerWidth, thumbnail])
 314  
 315    // Build image info
 316    const imageInfo: TImetaInfo | null = useMemo(() => {
 317      if (loading) return null
 318  
 319      // Use selected variant if available
 320      if (selectedVariant) {
 321        return {
 322          url: selectedVariant.url,
 323          blurHash: selectedVariant.blurhash,
 324          pubkey,
 325          dim: {
 326            width: selectedVariant.width,
 327            height: selectedVariant.height
 328          }
 329        }
 330      }
 331  
 332      // Fall back to original blob URL
 333      if (!hasBindingEvent && blossomService.isValidBlobHash(hash)) {
 334        const fallbackUrl = baseServerUrl
 335          ? `${baseServerUrl.replace(/\/$/, '')}/${hash}`
 336          : `https://blossom.band/${hash}`
 337  
 338        return {
 339          url: fallbackUrl,
 340          pubkey
 341        }
 342      }
 343  
 344      return null
 345    }, [selectedVariant, loading, hasBindingEvent, hash, baseServerUrl, pubkey])
 346  
 347    if (loading) {
 348      return (
 349        <div className={cn('animate-pulse bg-muted rounded-xl', className, classNames?.skeleton)} />
 350      )
 351    }
 352  
 353    if (!imageInfo) {
 354      if (hideIfError) return null
 355      return (
 356        <div
 357          className={cn(
 358            'flex items-center justify-center bg-muted rounded-xl',
 359            className,
 360            classNames?.errorPlaceholder
 361          )}
 362        >
 363          {errorPlaceholder ?? <span className="text-muted-foreground">No image</span>}
 364        </div>
 365      )
 366    }
 367  
 368    return (
 369      <Image
 370        image={imageInfo}
 371        alt={alt}
 372        className={className}
 373        classNames={classNames}
 374        hideIfError={hideIfError}
 375        errorPlaceholder={errorPlaceholder}
 376      />
 377    )
 378  }
 379