@ -1,11 +1,13 @@
@@ -1,11 +1,13 @@
/ * *
* Service to fetch GIFs from Nostr NIP94 events
* NIP94 events ( kind 94 ) contain file attachment metadata
* Service to fetch GIFs from Nostr events
* NIP - 94 events ( kind 1063 ) contain file attachment metadata
* Also searches kind 1 events which may contain GIFs in tags or content
* /
import { nostrClient } from './nostr-client.js' ;
import { relayManager } from './relay-manager.js' ;
import type { NostrEvent } from '../../types/nostr.js' ;
import { KIND_LOOKUP , getKindInfo } from '../../types/kind-lookup.js' ;
import { config } from './config.js' ;
export interface GifMetadata {
url : string ;
@ -20,24 +22,87 @@ export interface GifMetadata {
@@ -20,24 +22,87 @@ export interface GifMetadata {
}
/ * *
* Parse NIP94 event to extract GIF metadata
* Parse any event ( kind 1063 , kind 1 , etc . ) to extract GIF metadata
* Supports NIP - 94 ( kind 1063 ) , NIP - 92 ( imeta ) , NIP - 23 ( image ) , and content URLs
* /
function parseNip94Event ( event : NostrEvent ) : GifMetadata | null {
// NIP94 events can have different tag structures
// Try to find URL in various tag formats: url, file, or in content
function parseGifFromEvent ( event : NostrEvent ) : 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 ;
// Try imeta tags (NIP-92) - format: ["imeta", "url <URL>", "m <mime-type>", "x <width>", "y <height>", ...]
const imetaTags = event . tags . filter ( t = > t [ 0 ] === 'imeta' ) ;
for ( const imetaTag of imetaTags ) {
for ( let i = 1 ; i < imetaTag . length ; i ++ ) {
const field = imetaTag [ i ] ;
if ( field ? . startsWith ( 'url ' ) ) {
const candidateUrl = field . substring ( 4 ) . trim ( ) ;
if ( candidateUrl && candidateUrl . toLowerCase ( ) . includes ( '.gif' ) ) {
url = candidateUrl ;
// Look for mime type in same tag
const mimeField = imetaTag . find ( f = > f ? . startsWith ( 'm ' ) ) ;
if ( mimeField ) {
mimeType = mimeField . substring ( 2 ) . trim ( ) ;
}
// Look for dimensions
const xField = imetaTag . find ( f = > f ? . startsWith ( 'x ' ) ) ;
const yField = imetaTag . find ( f = > f ? . startsWith ( 'y ' ) ) ;
if ( xField ) width = parseInt ( xField . substring ( 2 ) . trim ( ) , 10 ) ;
if ( yField ) height = parseInt ( yField . substring ( 2 ) . trim ( ) , 10 ) ;
break ;
}
}
}
if ( url ) break ;
}
// Try file tags (NIP-94 kind 1063) - format: ["url", "<URL>"], ["m", "<mime-type>"], etc.
if ( ! url ) {
const fileTags = event . tags . filter ( t = > t [ 0 ] === 'file' && t [ 1 ] ) ;
for ( const fileTag of fileTags ) {
const candidateUrl = fileTag [ 1 ] ;
if ( candidateUrl && candidateUrl . toLowerCase ( ) . includes ( '.gif' ) ) {
url = candidateUrl ;
// MIME type is typically the second element
if ( fileTag [ 2 ] ) {
mimeType = fileTag [ 2 ] ;
}
break ;
}
}
}
// First try 'url' tag
const urlTag = event . tags . find ( t = > t [ 0 ] === 'url' && t [ 1 ] ) ;
if ( urlTag && urlTag [ 1 ] ) {
url = urlTag [ 1 ] ;
} else {
// Try 'file' tag (NIP-94 format)
const fileTag = event . tags . find ( t = > t [ 0 ] === 'file' && t [ 1 ] ) ;
if ( fileTag && fileTag [ 1 ] ) {
url = fileTag [ 1 ] ;
// Try image tags (NIP-23) - format: ["image", "<URL>"]
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 ;
}
}
}
// Try url tag (NIP-94 kind 1063 standard tag)
if ( ! url ) {
const urlTag = event . tags . find ( t = > t [ 0 ] === 'url' && t [ 1 ] ) ;
if ( urlTag && urlTag [ 1 ] && urlTag [ 1 ] . toLowerCase ( ) . includes ( '.gif' ) ) {
url = urlTag [ 1 ] ;
}
}
// Try to extract URL from content (markdown images or plain URLs)
if ( ! url ) {
// Markdown image: 
const markdownMatch = event . content . match ( /!\[[^\]]*\]\((https?:\/\/[^\s<>"')]+\.gif[^\s<>"')]*)\)/i ) ;
if ( markdownMatch ) {
url = markdownMatch [ 1 ] ;
} else {
// Try to extract URL from content (might be in markdown or plain text)
// Plain URL
const urlMatch = event . content . match ( /https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i ) ;
if ( urlMatch ) {
url = urlMatch [ 0 ] ;
@ -46,48 +111,49 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null {
@@ -46,48 +111,49 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null {
}
if ( ! url ) {
console . debug ( '[gif-service] No URL found in event:' , event . id ) ;
return null ;
}
// Check if it's a GIF by MIME type, file extension, or URL pattern
const mimeTag = event . tags . find ( t = > t [ 0 ] === 'm' && t [ 1 ] ) ;
const mimeType = mimeTag ? . [ 1 ] || '' ;
// Verify it's actually a GIF
const urlLower = url . toLowerCase ( ) ;
// More flexible GIF detection
const isGif =
mimeType === 'image/gif' ||
urlLower . endsWith ( '.gif' ) ||
urlLower . includes ( '.gif?' ) ||
urlLower . includes ( '/gif' ) ||
( mimeType . startsWith ( 'image/' ) && event . content . toLowerCase ( ) . includes ( 'gif' ) ) ;
urlLower . includes ( 'gif' ) ;
if ( ! isGif ) {
console . debug ( '[gif-service] Not a GIF:' , { url , mimeType , eventId : event.id } ) ;
return null ;
}
// Extract optional metadata
const sha256Tag = event . tags . find ( t = > t [ 0 ] === 'x' && t [ 1 ] ) ;
const dimTag = event . tags . find ( t = > t [ 0 ] === 'dim' && t [ 1 ] ) ;
const fallbackTag = event . tags . find ( t = > t [ 0 ] === 'fallback' && t [ 1 ] ) ;
let width : number | undefined ;
let height : number | undefined ;
if ( dimTag && dimTag [ 1 ] ) {
// Format: "widthxheight" or "widthxheightxfps" for videos
const dims = dimTag [ 1 ] . split ( 'x' ) ;
if ( dims . length >= 2 ) {
width = parseInt ( dims [ 0 ] , 10 ) ;
height = parseInt ( dims [ 1 ] , 10 ) ;
// Extract additional metadata from tags
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 && 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 : fallbackTag?. [ 1 ] ,
sha256 : sha256Tag?. [ 1 ] ,
fallbackUrl ,
sha256 ,
mimeType : mimeType || 'image/gif' ,
width ,
height ,
@ -98,38 +164,111 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null {
@@ -98,38 +164,111 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null {
}
/ * *
* Fetch GIFs from Nostr NIP94 events
* Fetch GIFs from Nostr NIP - 94 events ( kind 1063 )
* Only queries kind 1063 file metadata events to avoid flooding with kind 1 events
* @param searchQuery Optional search query to filter GIFs ( searches in content / tags )
* @param limit Maximum number of GIFs to return
* /
export async function fetchGifs ( searchQuery? : string , limit : number = 50 ) : Promise < GifMetadata [ ] > {
try {
// Use profile read relays to get GIFs
const relays = relayManager . getProfileReadRelays ( ) ;
console . debug ( ` [gif-service] Fetching GIFs from ${ relays . length } relays: ` , relays ) ;
// Ensure client is initialized
await nostrClient . initialize ( ) ;
// Use GIF relays from config, with fallback to default relays if GIF relays fail
let relays = config . gifRelays ;
console . debug ( ` [gif-service] Fetching GIFs from ${ relays . length } GIF relays: ` , relays ) ;
// Try GIF relays first, but if they all fail, we'll fall back to default relays
// This ensures we can still find GIFs even if GIF-specific relays are down
// Fetch kind 94 events (NIP94 file attachments)
const filters = [ {
kinds : [ 94 ] ,
limit : limit * 2 // Fetch more to filter for GIFs
} ] ;
// Only fetch kind 1063 (NIP-94 file metadata) events - kind 1 floods the fetch
const fileMetadataKind = KIND_LOOKUP [ 1063 ] . number ; // NIP-94 File Metadata
// Fetch a larger number of events to build a good cache
// Use a higher limit to ensure we cache enough events for consistent results
const cacheLimit = Math . max ( limit * 10 , 200 ) ; // Cache at least 200 events for consistency
const filters = [
{
kinds : [ fileMetadataKind ] , // NIP-94 file metadata events only
limit : cacheLimit // Fetch more to build a good cache
}
] ;
console . debug ( ` [gif-service] Fetching kind 94 events with filters: ` , filters ) ;
const events = await nostrClient . fetchEvents ( filters , relays , {
useCache : true ,
cacheResults : true
const fileMetadataKindName = getKindInfo ( fileMetadataKind ) . description ;
console . debug ( ` [gif-service] Fetching ${ fileMetadataKindName } (kind ${ fileMetadataKind } ) events with filters: ` , filters ) ;
// First, try to get cached events for consistent results
let events = await nostrClient . fetchEvents ( filters , relays , {
useCache : true , // Use cache first for consistent results
cacheResults : true ,
timeout : config.relayTimeout
} ) ;
// Then refresh cache in background to get new events
// This ensures we have consistent results from cache while updating it
nostrClient . fetchEvents ( filters , relays , {
useCache : false , // Force query relays to update cache
cacheResults : true , // Cache the results
timeout : config.relayTimeout * 2 // Give more time for GIF relays
} ) . then ( ( newEvents ) = > {
if ( newEvents . length > 0 ) {
console . debug ( ` [gif-service] Background refresh cached ${ newEvents . length } new ${ fileMetadataKindName } events ` ) ;
}
} ) . catch ( ( error ) = > {
console . debug ( '[gif-service] Background refresh error:' , error ) ;
} ) ;
// If no cached events, try default relays as fallback
if ( events . length === 0 ) {
console . log ( '[gif-service] No cached events, trying default relays as fallback...' ) ;
const fallbackRelays = [ . . . config . defaultRelays , . . . config . profileRelays ] ;
events = await nostrClient . fetchEvents ( filters , fallbackRelays , {
useCache : true , // Try cache first
cacheResults : true ,
timeout : config.relayTimeout
} ) ;
// If still no events, try querying relays directly
if ( events . length === 0 ) {
events = await nostrClient . fetchEvents ( filters , fallbackRelays , {
useCache : false ,
cacheResults : true ,
timeout : config.relayTimeout
} ) ;
}
}
console . debug ( ` [gif-service] Received ${ events . length } kind 94 events ` ) ;
console . lo g( ` [gif-service] Received ${ events . length } total ${ fileMetadataKindName } (kind ${ fileMetadataKind } ) events ` ) ;
// Parse and filter for GIFs
const gifs : GifMetadata [ ] = [ ] ;
const seenUrls = new Set < string > ( ) ; // Deduplicate by URL
let parsedCount = 0 ;
let skippedCount = 0 ;
let sampleEvents : Array < { kind : number ; hasImeta : boolean ; hasFile : boolean ; hasImage : boolean ; hasUrlInContent : boolean ; tagCount : number } > = [ ] ;
for ( const event of events ) {
const gif = parseNip94Event ( event ) ;
// Sample first 5 events for debugging
if ( sampleEvents . length < 5 ) {
const hasImeta = event . tags . some ( t = > t [ 0 ] === 'imeta' ) ;
const hasFile = event . tags . some ( t = > t [ 0 ] === 'file' ) ;
const hasImage = event . tags . some ( t = > t [ 0 ] === 'image' ) ;
const hasUrlInContent = /https?:\/\/[^\s<>"']+\.gif/i . test ( event . content ) ;
sampleEvents . push ( { kind : event.kind , hasImeta , hasFile , hasImage , hasUrlInContent , tagCount : event.tags.length } ) ;
}
const gif = parseGifFromEvent ( event ) ;
if ( gif ) {
// Deduplicate by URL (normalize by removing query params for comparison)
const normalizedUrl = gif . url . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] ;
if ( seenUrls . has ( normalizedUrl ) ) {
skippedCount ++ ;
continue ;
}
seenUrls . add ( normalizedUrl ) ;
parsedCount ++ ;
// If search query provided, filter by content or tags
if ( searchQuery ) {
@ -150,12 +289,30 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
@@ -150,12 +289,30 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
}
}
console . debug ( ` [gif-service] Parsed ${ parsedCount } GIFs, skipped ${ skippedCount } non-GIF events ` ) ;
console . log ( ` [gif-service] Parsed ${ parsedCount } GIFs, skipped ${ skippedCount } non-GIF events ` ) ;
// Debug: Show sample of events we checked with more detail
if ( sampleEvents . length > 0 ) {
console . log ( ` [gif-service] Sample of first ${ sampleEvents . length } events checked: ` , sampleEvents ) ;
// Also log actual event content for first event to see what we're working with
if ( events . length > 0 ) {
const firstEvent = events [ 0 ] ;
console . debug ( '[gif-service] First event sample:' , {
id : firstEvent.id.substring ( 0 , 16 ) + '...' ,
kind : firstEvent.kind ,
contentLength : firstEvent.content.length ,
contentPreview : firstEvent.content.substring ( 0 , 100 ) ,
tagCount : firstEvent.tags.length ,
tagTypes : [ . . . new Set ( firstEvent . tags . map ( t = > t [ 0 ] ) ) ]
} ) ;
}
}
// Only log final result if GIFs were found, otherwise it's just noise
if ( gifs . length > 0 ) {
console . log ( ` [gif-service] Found ${ gifs . length } GIFs ${ searchQuery ? ` matching " ${ searchQuery } " ` : '' } ` ) ;
} else {
console . debug ( ` [gif-service] Final result: 0 GIFs ${ searchQuery ? ` matching " ${ searchQuery } " ` : '' } ` ) ;
console . log ( ` [gif-service] No GIFs found. Checked ${ events . length } ${ fileMetadataKindName } (kind ${ fileMetadataKind } ) events. Try searching for a specific term or check if there are GIF URLs in the events. ` ) ;
}
// Sort by creation date (newest first) and limit