You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

346 lines
11 KiB

/**
* 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<GifMetadata[]> {
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<string>()
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<string, { gif: GifMetadata; priority: number }>()
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<GifMetadata[]> {
return fetchGifs(query, limit, forceRefresh, extraReadRelayUrls, userPubkey)
}