/** * Fetch GIFs from Nostr: kind 1063 (NIP-94 file metadata) and from kind 1 / 1111 (notes/comments that contain GIF URLs). * Same approach as aitherboard for 1063; for 1/1111 we parse content and tags for .gif URLs. */ import { ExtendedKind, FAST_READ_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { kinds } from 'nostr-tools' import type { Event as NEvent } from 'nostr-tools' import { queryService } from './client.service' import indexedDb from './indexed-db.service' export interface GifMetadata { url: string fallbackUrl?: string sha256?: string mimeType?: string width?: number height?: number /** Nostr kind of the event this row was parsed from (1063 vs note vs comment). */ sourceKind: number eventId: string pubkey: string createdAt: number } /** True if the GIF bytes are served from nostr.build (or a subdomain). */ export function isNostrBuildHostedUrl(url: string): boolean { try { const h = new URL(url).hostname.toLowerCase() return h === 'nostr.build' || h.endsWith('.nostr.build') } catch { return false } } /** * External GIF from a note/comment: offer “archive” = publish kind 1063 + insert. * Not shown for 1063 events or when the URL already points at nostr.build. */ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean { if (gif.sourceKind === ExtendedKind.FILE_METADATA) return false if (isNostrBuildHostedUrl(gif.url)) return false if (gif.fallbackUrl?.trim() && isNostrBuildHostedUrl(gif.fallbackUrl.trim())) return false return true } /** Own GIFs/memes first, then newest first (picker grids). */ export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] { const u = userPubkey?.toLowerCase() ?? '' return [...gifs].sort((a, b) => { if (u) { const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0 const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0 if (aOwn !== bOwn) return bOwn - aOwn } return b.createdAt - a.createdAt }) } /** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */ function normalizeGifUrl(url: string): string { try { const withoutFragment = url.split('#')[0].trim() const withoutQuery = withoutFragment.split('?')[0].trim() const lower = withoutQuery.toLowerCase() return lower || url } catch { return url } } /** Priority for deduplication: higher wins. Own event > other's event > non-event. */ const GIF_PRIORITY = { OWN_EVENT: 2, OTHER_EVENT: 1, NON_EVENT: 0 } as const function parseGifFromEvent(event: NEvent): GifMetadata | null { let url: string | undefined let mimeType: string | undefined let width: number | undefined let height: number | undefined let fallbackUrl: string | undefined let sha256: string | undefined // imeta tags (NIP-92): accept url when it contains .gif or when m is image/gif const imetaTags = event.tags.filter((t) => t[0] === 'imeta') for (const imetaTag of imetaTags) { const mimeField = imetaTag.find((f) => f?.startsWith('m ')) const imetaMime = mimeField?.substring(2).trim() const isGifMime = imetaMime === 'image/gif' for (let i = 1; i < imetaTag.length; i++) { const field = imetaTag[i] if (field?.startsWith('url ')) { const candidateUrl = field.substring(4).trim() if (!candidateUrl) continue const urlHasGif = candidateUrl.toLowerCase().includes('.gif') if (urlHasGif || isGifMime) { url = candidateUrl if (mimeField) mimeType = imetaMime const dimField = imetaTag.find((f) => f?.startsWith('dim ')) if (dimField) { const dims = dimField.substring(4).trim().split('x') if (dims.length >= 2) { width = parseInt(dims[0], 10) height = parseInt(dims[1], 10) } } break } } } if (url) break } // file tags (NIP-94 kind 1063) if (!url) { const fileTags = event.tags.filter((t) => t[0] === 'file' && t[1]) for (const fileTag of fileTags) { const candidateUrl = fileTag[1] const candidateMimeType = fileTag[2] const isGifUrl = candidateUrl && (candidateUrl.toLowerCase().includes('.gif') || candidateUrl.toLowerCase().startsWith('data:image/gif') || candidateMimeType === 'image/gif') if (isGifUrl) { url = candidateUrl if (candidateMimeType) mimeType = candidateMimeType break } } } // image tags if (!url) { const imageTags = event.tags.filter((t) => t[0] === 'image' && t[1]) for (const imageTag of imageTags) { const candidateUrl = imageTag[1] if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) { url = candidateUrl break } } } // url tag (accept any URL; isGif check below uses mime from 'm' tag if URL has no .gif) if (!url) { const urlTag = event.tags.find((t) => t[0] === 'url' && t[1]) if (urlTag?.[1]) { url = urlTag[1] if (!mimeType) { const mTag = event.tags.find((t) => t[0] === 'm' && t[1]) mimeType = mTag?.[1] } } } // content: markdown image or plain URL if (!url) { const markdownMatch = event.content.match( /!\[[^\]]*\]\((https?:\/\/[^\s<>"')]+\.gif[^\s<>"')]*)\)/i ) if (markdownMatch) { url = markdownMatch[1] } else { const urlMatch = event.content.match(/https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i) if (urlMatch) url = urlMatch[0] } } if (!url) return null const urlLower = url.toLowerCase() const isGif = mimeType === 'image/gif' || urlLower.endsWith('.gif') || urlLower.includes('.gif?') || urlLower.includes('/gif') || urlLower.includes('gif') if (!isGif) return null if (!mimeType) { const mimeTag = event.tags.find((t) => t[0] === 'm' && t[1]) mimeType = mimeTag?.[1] || 'image/gif' } if (!width || !height) { const dimTag = event.tags.find((t) => t[0] === 'dim' && t[1]) if (dimTag?.[1]) { const dims = dimTag[1].split('x') if (dims.length >= 2) { width = parseInt(dims[0], 10) height = parseInt(dims[1], 10) } } } const sha256Tag = event.tags.find((t) => t[0] === 'x' && t[1]) sha256 = sha256Tag?.[1] const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1]) fallbackUrl = fallbackTag?.[1] return { url, fallbackUrl, sha256, mimeType: mimeType || 'image/gif', width, height, sourceKind: event.kind, eventId: event.id, pubkey: event.pubkey, createdAt: event.created_at } } const CACHE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes; cache lives in IndexedDB /** Partial fetches (timeouts, relay issues) used to get cached as-is and hide the grid for 5 minutes. */ const MIN_GIF_CACHE_ENTRIES = 8 /** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */ const THECITADEL_FOR_GIF_METADATA = normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' /** * Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays. * Deduplicates by normalized URL; when the same GIF appears from multiple sources, * keeps: 1) user's own events, 2) other users' events, 3) non-event sources. * @param extraReadRelayUrls - Logged-in user's read relays (inboxes) and local relays to include when fetching. * @param userPubkey - Current user's pubkey; entries from this pubkey get highest priority when deduping. */ export async function fetchGifs( searchQuery?: string, limit: number = 50, forceRefresh: boolean = false, extraReadRelayUrls: string[] = [], userPubkey: string | null = null ): Promise { if (!forceRefresh && !searchQuery) { const cached = await indexedDb.getGifCache() const cacheHasSourceKind = cached?.gifs.length && cached.gifs.every((g) => typeof (g as GifMetadata).sourceKind === 'number') if ( cached && cacheHasSourceKind && cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS ) { return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, limit) } } // GIF-focused relays often fail (e.g. gifbuddy/damus down); merge fast read indexers so kind 1063 / GIF notes still resolve. const readUrls = [ ...GIF_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter((u): u is string => !!u) ] const seen = new Set() const dedupedUrls = readUrls .map((u) => normalizeUrl(u) || u) .filter(Boolean) .filter((u) => { const n = u.toLowerCase() if (seen.has(n)) return false seen.add(n) return true }) const fetchOpts = { eoseTimeout: 20000, globalTimeout: 28000 } const limit1063 = Math.max(limit * 15, 400) const limitNotes = Math.max(limit * 15, 500) const relays1063 = dedupedUrls.some( (u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase() ) ? dedupedUrls : [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] // Kind 1063 (incl. thecitadel) + kind 1/1111 on the broad list (thecitadel omitted for social kinds via SOCIAL_KIND_BLOCKED_RELAY_URLS). const [events1063, eventsNotes] = await Promise.all([ queryService.fetchEvents( relays1063, { kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, fetchOpts ), queryService.fetchEvents( dedupedUrls, { kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT], limit: limitNotes }, fetchOpts ) ]) const events = [...events1063, ...eventsNotes] // Map: normalized URL -> { gif, priority }. Higher priority wins when same URL appears multiple times. const byUrl = new Map() for (const event of events) { const gif = parseGifFromEvent(event) if (!gif) continue if (searchQuery) { const q = searchQuery.toLowerCase().trim() const content = event.content.toLowerCase() const tags = event.tags.flat().join(' ').toLowerCase() if (!content.includes(q) && !tags.includes(q)) continue } const key = normalizeGifUrl(gif.url) const priority = userPubkey && event.pubkey === userPubkey ? GIF_PRIORITY.OWN_EVENT : GIF_PRIORITY.OTHER_EVENT const existing = byUrl.get(key) if (!existing || priority > existing.priority) { byUrl.set(key, { gif, priority }) } } const gifs = Array.from(byUrl.values()).map((v) => v.gif) const result = sortGifsForPicker(gifs, userPubkey).slice(0, limit) if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) { await indexedDb.setGifCache(result, Date.now()) } return result } /** Search GIFs by query (same as fetchGifs with query). */ export async function searchGifs( query: string, limit: number = 50, forceRefresh: boolean = false, extraReadRelayUrls: string[] = [], userPubkey: string | null = null ): Promise { return fetchGifs(query, limit, forceRefresh, extraReadRelayUrls, userPubkey) }