responsive-image.service.ts raw

   1  /**
   2   * Responsive Image Service
   3   *
   4   * Looks up kind 1063 binding events for images and provides variant selection
   5   * for responsive image display. Caches results to avoid repeated queries.
   6   */
   7  
   8  import { RECOMMENDED_SEARCH_RELAYS } from '@/constants'
   9  import { UploadedVariant, parseResponsiveImageEvent } from '@/lib/responsive-image-event'
  10  import clientService from './client.service'
  11  import { Event as NEvent } from 'nostr-tools'
  12  
  13  /** Cache of binding events by sha256 hash */
  14  const variantCache = new Map<string, UploadedVariant[] | null>()
  15  
  16  /** Pending lookups to avoid duplicate queries */
  17  const pendingLookups = new Map<string, Promise<UploadedVariant[] | null>>()
  18  
  19  /** File metadata event kind (NIP-94) */
  20  const FILE_METADATA_KIND = 1063
  21  
  22  /**
  23   * Extract sha256 hash from a blossom URL
  24   * Blossom URLs are typically: https://domain/sha256.ext or https://domain/sha256
  25   */
  26  export function extractSha256FromUrl(url: string): string | null {
  27    try {
  28      const urlObj = new URL(url)
  29      const path = urlObj.pathname
  30      // Get the last path segment
  31      const segments = path.split('/').filter(Boolean)
  32      if (segments.length === 0) return null
  33  
  34      const lastSegment = segments[segments.length - 1]
  35      // Remove extension if present
  36      const hashPart = lastSegment.replace(/\.[^.]+$/, '')
  37  
  38      // Validate it looks like a sha256 (64 hex chars)
  39      if (/^[a-fA-F0-9]{64}$/.test(hashPart)) {
  40        return hashPart.toLowerCase()
  41      }
  42      return null
  43    } catch {
  44      return null
  45    }
  46  }
  47  
  48  /**
  49   * Look up responsive variants for an image by its sha256 hash
  50   *
  51   * @param sha256 - The sha256 hash of the image
  52   * @returns Array of variants sorted by width, or null if no binding found
  53   */
  54  export async function getVariantsForHash(sha256: string): Promise<UploadedVariant[] | null> {
  55    // Check cache first
  56    if (variantCache.has(sha256)) {
  57      return variantCache.get(sha256) ?? null
  58    }
  59  
  60    // Check if there's already a pending lookup
  61    const pending = pendingLookups.get(sha256)
  62    if (pending) {
  63      return pending
  64    }
  65  
  66    // Start new lookup
  67    const lookupPromise = doLookup(sha256)
  68    pendingLookups.set(sha256, lookupPromise)
  69  
  70    try {
  71      const result = await lookupPromise
  72      variantCache.set(sha256, result)
  73      return result
  74    } finally {
  75      pendingLookups.delete(sha256)
  76    }
  77  }
  78  
  79  /**
  80   * Look up responsive variants for an image by its URL
  81   */
  82  export async function getVariantsForUrl(url: string): Promise<UploadedVariant[] | null> {
  83    const sha256 = extractSha256FromUrl(url)
  84    if (!sha256) return null
  85    return getVariantsForHash(sha256)
  86  }
  87  
  88  /**
  89   * Select the best variant for a given display width
  90   *
  91   * @param variants - Available variants
  92   * @param targetWidth - Target display width in pixels
  93   * @param pixelRatio - Device pixel ratio (default 1)
  94   * @returns Best matching variant - smallest that covers the target width
  95   */
  96  export function selectVariant(
  97    variants: UploadedVariant[],
  98    targetWidth: number,
  99    pixelRatio: number = 1
 100  ): UploadedVariant | null {
 101    if (variants.length === 0) return null
 102  
 103    const effectiveWidth = targetWidth * pixelRatio
 104  
 105    // Sort by width ascending
 106    const sorted = [...variants].sort((a, b) => a.width - b.width)
 107  
 108    // Find smallest variant >= effective width (covers target without waste)
 109    for (const variant of sorted) {
 110      if (variant.width >= effectiveWidth) {
 111        return variant
 112      }
 113    }
 114  
 115    // If none large enough, return largest available
 116    return sorted[sorted.length - 1]
 117  }
 118  
 119  /**
 120   * Get the original (largest) variant
 121   */
 122  export function getOriginalVariant(variants: UploadedVariant[]): UploadedVariant | null {
 123    const original = variants.find((v) => v.variant === 'original')
 124    if (original) return original
 125  
 126    // Fall back to largest by width
 127    if (variants.length === 0) return null
 128    return variants.reduce((a, b) => (a.width > b.width ? a : b))
 129  }
 130  
 131  /**
 132   * Get the thumbnail variant
 133   */
 134  export function getThumbnailVariant(variants: UploadedVariant[]): UploadedVariant | null {
 135    return variants.find((v) => v.variant === 'thumb') ?? null
 136  }
 137  
 138  /**
 139   * Clear the cache (useful for testing or memory management)
 140   */
 141  export function clearCache(): void {
 142    variantCache.clear()
 143  }
 144  
 145  /**
 146   * Prefetch variants for multiple image hashes
 147   */
 148  export async function prefetchVariants(sha256s: string[]): Promise<void> {
 149    await Promise.all(sha256s.map((hash) => getVariantsForHash(hash)))
 150  }
 151  
 152  // Internal lookup function
 153  async function doLookup(sha256: string): Promise<UploadedVariant[] | null> {
 154    try {
 155      // Combine user's relays with recommended search relays for better discovery
 156      const relaysToQuery = Array.from(new Set([
 157        ...clientService.currentRelays,
 158        ...RECOMMENDED_SEARCH_RELAYS
 159      ]))
 160  
 161      // Query for kind 1063 events with x tag matching this hash
 162      const events = await clientService.fetchEvents(
 163        relaysToQuery,
 164        {
 165          kinds: [FILE_METADATA_KIND],
 166          '#x': [sha256],
 167          limit: 5
 168        }
 169      )
 170  
 171      if (!events || events.length === 0) return null
 172  
 173      // Use the most recent event
 174      const eventsArray = Array.from(events) as NEvent[]
 175      const latest = eventsArray.reduce((a, b) =>
 176        a.created_at > b.created_at ? a : b
 177      )
 178  
 179      try {
 180        const variants = parseResponsiveImageEvent({
 181          kind: latest.kind,
 182          tags: latest.tags
 183        })
 184        return variants.length > 0 ? variants : null
 185      } catch {
 186        return null
 187      }
 188    } catch (err) {
 189      console.warn('Failed to lookup responsive variants:', err)
 190      return null
 191    }
 192  }
 193  
 194  // Export as default object for consistency with other services
 195  const responsiveImageService = {
 196    extractSha256FromUrl,
 197    getVariantsForHash,
 198    getVariantsForUrl,
 199    selectVariant,
 200    getOriginalVariant,
 201    getThumbnailVariant,
 202    clearCache,
 203    prefetchVariants
 204  }
 205  
 206  export default responsiveImageService
 207