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