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