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.
598 lines
18 KiB
598 lines
18 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 { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' |
|
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 |
|
/** Human-readable label from kind 1063 `content`, `alt`, or extra `#t` tags (for search / cache). */ |
|
description?: string |
|
/** 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 |
|
|
|
/** `#t` tokens derived from a user description (always includes literal `gif` separately). */ |
|
function topicTagsFromGifDescription(description: string): string[] { |
|
const seen = new Set<string>(['gif']) |
|
const out: string[] = [] |
|
for (const raw of description.split(/[\s,]+/)) { |
|
const token = raw.replace(/^#+/, '').trim().toLowerCase() |
|
if (token.length >= 2 && !seen.has(token)) { |
|
seen.add(token) |
|
out.push(token) |
|
} |
|
} |
|
return out |
|
} |
|
|
|
/** Append NIP-94 `alt` + searchable `#t` tags for a GIF description onto kind 1063 tag lists. */ |
|
export function appendGifDescriptionTo1063Tags(tags: string[][], description?: string): void { |
|
const desc = description?.trim() ?? '' |
|
if (!desc) return |
|
tags.push(['alt', desc]) |
|
for (const topic of topicTagsFromGifDescription(desc)) { |
|
tags.push(['t', topic]) |
|
} |
|
} |
|
|
|
/** Kind 1063 publish draft for a remote GIF URL (grid pick, paste, archive). */ |
|
export function buildKind1063GifPublishDraft(url: string, description?: string) { |
|
const desc = description?.trim() ?? '' |
|
const tags: string[][] = [ |
|
['url', url], |
|
['m', 'image/gif'], |
|
['t', 'gif'] |
|
] |
|
appendGifDescriptionTo1063Tags(tags, desc) |
|
return { |
|
kind: ExtendedKind.FILE_METADATA, |
|
content: desc, |
|
tags, |
|
created_at: Math.floor(Date.now() / 1000) |
|
} |
|
} |
|
|
|
function descriptionFromGifEvent(event: NEvent): string | undefined { |
|
const alt = event.tags.find((t) => t[0] === 'alt' && t[1]?.trim())?.[1]?.trim() |
|
if (alt) return alt |
|
const content = event.content?.trim() |
|
if (content && !/^https?:\/\//i.test(content)) return content |
|
const topics = event.tags |
|
.filter((t) => t[0] === 't' && t[1]?.trim() && t[1].trim().toLowerCase() !== 'gif') |
|
.map((t) => t[1]!.trim()) |
|
if (topics.length > 0) return topics.join(' ') |
|
return undefined |
|
} |
|
|
|
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] |
|
const description = descriptionFromGifEvent(event) |
|
|
|
return { |
|
url, |
|
fallbackUrl, |
|
sha256, |
|
mimeType: mimeType || 'image/gif', |
|
width, |
|
height, |
|
description, |
|
sourceKind: event.kind, |
|
eventId: event.id, |
|
pubkey: event.pubkey, |
|
createdAt: event.created_at |
|
} |
|
} |
|
|
|
const CACHE_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour; short enough to stay fresh, long enough to survive browser restarts |
|
const MIN_GIF_CACHE_ENTRIES = 8 |
|
const GIF_CACHE_CAP = 80 |
|
|
|
/** |
|
* High `limit` values otherwise trigger implicit feed first-relay grace (~2s) and return before |
|
* GIF-heavy relays (e.g. thecitadel) finish. Picker loads must also be foreground so navigation |
|
* does not abort them via {@link QueryService.interruptBackgroundQueries}. |
|
*/ |
|
const GIF_FETCH_OPTS = { |
|
eoseTimeout: 20_000, |
|
globalTimeout: 28_000, |
|
firstRelayResultGraceMs: false as const, |
|
foreground: true as const |
|
} |
|
|
|
/** Session-start cache preload — low priority but must finish (not aborted on navigation). */ |
|
const GIF_PRELOAD_FETCH_OPTS = { |
|
eoseTimeout: 18_000, |
|
globalTimeout: 26_000, |
|
firstRelayResultGraceMs: false as const, |
|
backgroundInterruptImmune: true as const |
|
} |
|
|
|
let gifOutboxPreloadInFlight: Promise<void> | null = null |
|
|
|
/** 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' |
|
|
|
function dedupeRelayUrls(urls: readonly string[]): string[] { |
|
const seen = new Set<string>() |
|
return urls |
|
.map((u) => normalizeUrl(u) || u) |
|
.filter(Boolean) |
|
.filter((u) => { |
|
const n = u.toLowerCase() |
|
if (seen.has(n)) return false |
|
seen.add(n) |
|
return true |
|
}) |
|
} |
|
|
|
function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetadata { |
|
return { |
|
...g, |
|
sourceKind: |
|
typeof g.sourceKind === 'number' ? g.sourceKind : ExtendedKind.FILE_METADATA |
|
} |
|
} |
|
|
|
export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boolean { |
|
const q = query.trim().toLowerCase() |
|
if (!q) return true |
|
const haystack = [ |
|
gif.description, |
|
gif.url, |
|
gif.fallbackUrl, |
|
gif.eventId, |
|
gif.pubkey, |
|
String(gif.sourceKind) |
|
] |
|
.filter(Boolean) |
|
.join(' ') |
|
.toLowerCase() |
|
return haystack.includes(q) |
|
} |
|
|
|
/** Full IndexedDB GIF pool for local search (up to {@link GIF_CACHE_CAP}). */ |
|
export async function getAllCachedGifsForSearch( |
|
userPubkey: string | null = null |
|
): Promise<GifMetadata[]> { |
|
try { |
|
const cached = await indexedDb.getGifCache() |
|
if (!cached?.gifs?.length) return [] |
|
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) |
|
return sortGifsForPicker(normalized, userPubkey) |
|
} catch { |
|
return [] |
|
} |
|
} |
|
|
|
function mergeGifEventsIntoMap( |
|
events: readonly NEvent[], |
|
byUrl: Map<string, { gif: GifMetadata; priority: number }>, |
|
searchQuery: string | undefined, |
|
userPubkey: string | null |
|
): void { |
|
for (const event of events) { |
|
const gif = parseGifFromEvent(event) |
|
if (!gif) continue |
|
if (searchQuery && !eventMatchesNip50LocalFullTextQuery(event, searchQuery)) 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 }) |
|
} |
|
} |
|
} |
|
|
|
export async function mergeGifsIntoIdbCache(incoming: GifMetadata[]): Promise<void> { |
|
if (incoming.length === 0) return |
|
const row = await indexedDb.getGifCache() |
|
const byKey = new Map<string, GifMetadata>() |
|
for (const g of row?.gifs ?? []) { |
|
const meta = normalizeCachedGif(g as GifMetadata) |
|
if (meta.url) byKey.set(normalizeGifUrl(meta.url), meta) |
|
} |
|
for (const g of incoming) { |
|
if (g.url) byKey.set(normalizeGifUrl(g.url), g) |
|
} |
|
const merged = [...byKey.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, GIF_CACHE_CAP) |
|
await indexedDb.setGifCache(merged, Date.now()) |
|
} |
|
|
|
function gifRelayUrlsForFetch(extraReadRelayUrls: readonly string[]): { |
|
relays1063: string[] |
|
relaysNotes: string[] |
|
} { |
|
const dedupedUrls = dedupeRelayUrls([ |
|
...GIF_RELAY_URLS, |
|
...FAST_READ_RELAY_URLS, |
|
...extraReadRelayUrls |
|
]) |
|
const relays1063 = dedupedUrls.some( |
|
(u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase() |
|
) |
|
? dedupedUrls |
|
: [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] |
|
return { relays1063, relaysNotes: dedupedUrls } |
|
} |
|
|
|
function gifsFromEvents( |
|
events1063: readonly NEvent[], |
|
eventsNotes: readonly NEvent[], |
|
userPubkey: string | null |
|
): GifMetadata[] { |
|
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>() |
|
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey) |
|
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey) |
|
return sortGifsForPicker( |
|
Array.from(byUrl.values()).map((v) => v.gif), |
|
userPubkey |
|
) |
|
} |
|
|
|
/** |
|
* Background session preload into IndexedDB: kind 1063 on GIF relays + thecitadel, kind 1/1111 on the |
|
* broad list, merged with the viewer's inboxes/outboxes when provided. |
|
*/ |
|
export async function preloadGifsIntoIdbCache( |
|
userPubkey: string | null, |
|
extraReadRelayUrls: readonly string[] = [] |
|
): Promise<void> { |
|
const cached = await indexedDb.getGifCache() |
|
if ( |
|
cached && |
|
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && |
|
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS |
|
) { |
|
return |
|
} |
|
|
|
if (gifOutboxPreloadInFlight) { |
|
await gifOutboxPreloadInFlight |
|
return |
|
} |
|
|
|
gifOutboxPreloadInFlight = (async () => { |
|
const { relays1063, relaysNotes } = gifRelayUrlsForFetch(extraReadRelayUrls) |
|
|
|
const [events1063, eventsNotes] = await Promise.all([ |
|
queryService.fetchEvents( |
|
relays1063, |
|
{ kinds: [ExtendedKind.FILE_METADATA], limit: 400 }, |
|
GIF_PRELOAD_FETCH_OPTS |
|
), |
|
queryService.fetchEvents( |
|
relaysNotes, |
|
{ |
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT], |
|
limit: 500 |
|
}, |
|
GIF_PRELOAD_FETCH_OPTS |
|
) |
|
]) |
|
|
|
const gifs = gifsFromEvents(events1063, eventsNotes, userPubkey).slice(0, GIF_CACHE_CAP) |
|
if (gifs.length > 0) { |
|
await mergeGifsIntoIdbCache(gifs) |
|
} |
|
})() |
|
|
|
try { |
|
await gifOutboxPreloadInFlight |
|
} finally { |
|
gifOutboxPreloadInFlight = null |
|
} |
|
} |
|
|
|
/** @deprecated Use {@link preloadGifsIntoIdbCache}. Kept for call-site compatibility. */ |
|
export async function preloadGifsFromUserOutboxes( |
|
outboxRelayUrls: readonly string[], |
|
userPubkey: string | null, |
|
_signal?: AbortSignal |
|
): Promise<void> { |
|
return preloadGifsIntoIdbCache(userPubkey, outboxRelayUrls) |
|
} |
|
|
|
/** |
|
* 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( |
|
limit: number = 50, |
|
forceRefresh: boolean = false, |
|
extraReadRelayUrls: string[] = [], |
|
userPubkey: string | null = null |
|
): Promise<GifMetadata[]> { |
|
if (!forceRefresh) { |
|
const cached = await indexedDb.getGifCache() |
|
if ( |
|
cached && |
|
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && |
|
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS |
|
) { |
|
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) |
|
return sortGifsForPicker(normalized, userPubkey).slice(0, limit) |
|
} |
|
} |
|
|
|
let staleFallback: GifMetadata[] | null = null |
|
const row = await indexedDb.getGifCache() |
|
if (row?.gifs?.length) { |
|
staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) |
|
} |
|
|
|
const limit1063 = Math.max(limit * 15, 400) |
|
const limitNotes = Math.max(limit * 15, 500) |
|
|
|
const { relays1063, relaysNotes: dedupedUrls } = gifRelayUrlsForFetch(extraReadRelayUrls) |
|
|
|
let events1063: NEvent[] = [] |
|
let eventsNotes: NEvent[] = [] |
|
|
|
try { |
|
;[events1063, eventsNotes] = await Promise.all([ |
|
queryService.fetchEvents( |
|
relays1063, |
|
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 }, |
|
GIF_FETCH_OPTS |
|
), |
|
queryService.fetchEvents( |
|
dedupedUrls, |
|
{ |
|
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT], |
|
limit: limitNotes |
|
}, |
|
GIF_FETCH_OPTS |
|
) |
|
]) |
|
} catch (err) { |
|
if (staleFallback?.length) { |
|
return sortGifsForPicker(staleFallback, userPubkey).slice(0, limit) |
|
} |
|
throw err |
|
} |
|
|
|
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>() |
|
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey) |
|
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey) |
|
|
|
const allGifs = sortGifsForPicker( |
|
Array.from(byUrl.values()).map((v) => v.gif), |
|
userPubkey |
|
) |
|
let result = allGifs.slice(0, limit) |
|
|
|
if (result.length === 0 && staleFallback?.length) { |
|
result = sortGifsForPicker(staleFallback, userPubkey).slice(0, limit) |
|
} |
|
|
|
if (allGifs.length > 0) { |
|
await mergeGifsIntoIdbCache(allGifs.slice(0, GIF_CACHE_CAP)) |
|
} |
|
|
|
return result |
|
} |
|
|
|
/** |
|
* Return whatever is currently in the IndexedDB GIF cache without fetching from relays. |
|
* Used to seed the picker immediately on open; the caller can then trigger a background refresh. |
|
*/ |
|
export async function getCachedGifs(userPubkey: string | null = null): Promise<GifMetadata[]> { |
|
const all = await getAllCachedGifsForSearch(userPubkey) |
|
return all.slice(0, 50) |
|
} |
|
|
|
/** Instant local search over the IndexedDB GIF cache (no relay round-trip). */ |
|
export async function searchGifs( |
|
query: string, |
|
limit: number = 50, |
|
_forceRefresh: boolean = false, |
|
_extraReadRelayUrls: string[] = [], |
|
userPubkey: string | null = null |
|
): Promise<GifMetadata[]> { |
|
const q = query.trim() |
|
if (!q) return getCachedGifs(userPubkey) |
|
const pool = await getAllCachedGifsForSearch(userPubkey) |
|
return pool.filter((g) => gifMetadataMatchesSearch(g, q)).slice(0, limit) |
|
}
|
|
|