diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 1460ae0f..d03cdb28 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -17,9 +17,11 @@ import { cn } from '@/lib/utils' import { normalizeUrl } from '@/lib/url' import { fetchGifs, - getCachedGifs, - searchGifs, + getAllCachedGifsForSearch, + gifMetadataMatchesSearch, gifShouldOfferNip94Archive, + buildKind1063GifPublishDraft, + appendGifDescriptionTo1063Tags, type GifMetadata } from '@/services/gif.service' import mediaUpload from '@/services/media-upload.service' @@ -50,11 +52,13 @@ export default function GifPicker({ const { isSmallScreen } = useScreenSize() const { publish, pubkey, relayList } = useNostr() const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') // Initialise from the module-level session cache so re-opens are instant const [gifs, setGifsState] = useState(() => _sessionGifs) const gifsRef = useRef(_sessionGifs) + const gifPoolRef = useRef([]) + const searchInputRef = useRef(searchInput) + searchInputRef.current = searchInput const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploading, setUploading] = useState(false) @@ -63,7 +67,6 @@ export default function GifPicker({ const [publishingPaste, setPublishingPaste] = useState(false) const [archivingEventId, setArchivingEventId] = useState(null) const [publishDescription, setPublishDescription] = useState('') - const searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) const gifbuddyPopupRef = useRef(null) @@ -106,88 +109,87 @@ export default function GifPicker({ setGifsState(newGifs) }, []) - const loadGifs = useCallback(async (q: string, forceRefresh = false) => { - setError(null) - const isSearch = q.trim() !== '' + /** Apply search filter to the in-memory GIF pool (instant, no network). */ + const applyLocalFilter = useCallback( + (q: string) => { + const trimmed = q.trim() + const pool = gifPoolRef.current + const filtered = trimmed + ? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed)) + : pool.slice(0, 50) + setGifs(filtered, trimmed.length > 0) + }, + [setGifs] + ) - // For a search or a forced refresh with no data: clear and show skeleton immediately. - if (isSearch) { - gifsRef.current = [] - setGifsState([]) - setLoading(true) - } else if (gifsRef.current.length === 0) { - // No data yet — try the IDB cache first so we can show something instantly. - try { - const cached = await getCachedGifs(pubkey ?? null) - if (cached.length > 0) { - setGifs(cached) + const refreshGifPoolFromIdb = useCallback(async () => { + const pool = await getAllCachedGifsForSearch(pubkey ?? null) + gifPoolRef.current = pool + return pool + }, [pubkey]) + + const loadGifs = useCallback( + async (forceRefresh = false) => { + setError(null) + + if (gifPoolRef.current.length === 0) { + try { + const cached = await refreshGifPoolFromIdb() + if (cached.length > 0) { + applyLocalFilter(searchInputRef.current) + } + } catch { + /* ignore */ } - } catch { /* ignore */ } - // If still empty after the cache read, show the skeleton while we wait for relays. - if (gifsRef.current.length === 0) setLoading(true) - } - // If we already have data (session cache or IDB seed above): no skeleton — - // results will update silently when the relay fetch completes. + if (gifPoolRef.current.length === 0) setLoading(true) + } - try { - const results = isSearch - ? await searchGifs(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) - : await fetchGifs(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) - setGifs(results, isSearch) - if (results.length === 0 && !isSearch) { - setError( - t( - 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.' + try { + await fetchGifs(50, forceRefresh, userReadRelays, pubkey ?? null) + await refreshGifPoolFromIdb() + applyLocalFilter(searchInputRef.current) + if (gifPoolRef.current.length === 0 && !searchInput.trim()) { + setError( + t( + 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.' + ) ) - ) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load GIFs') + if (gifPoolRef.current.length === 0) setGifsState([]) + } finally { + setLoading(false) } - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load GIFs') - if (gifsRef.current.length === 0) setGifsState([]) - } finally { - setLoading(false) - } - }, [t, userReadRelays, pubkey, setGifs]) + }, + [t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb] + ) useEffect(() => { if (!open) return - loadGifs(query) - }, [open, query, loadGifs]) + applyLocalFilter(searchInput) + }, [searchInput, open, applyLocalFilter]) useEffect(() => { if (!open) return - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) - searchTimeoutRef.current = setTimeout(() => { - setQuery(searchInput) - }, 300) - return () => { - if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) - } - }, [searchInput, open]) + void loadGifs() + }, [open, loadGifs]) const handleSelect = useCallback( (gif: GifMetadata) => { const url = (gif.fallbackUrl?.trim() || gif.url).trim() if (!url) return + const desc = publishDescription.trim() onSelect?.(url) setOpen(false) if (!pubkey || !/^https?:\/\//i.test(url)) return // Fire-and-forget: waiting on every relay can freeze the UI when relays are down. - void publish( - { - kind: ExtendedKind.FILE_METADATA, - content: '', - tags: [ - ['url', url], - ['m', 'image/gif'], - ['t', 'gif'] - ], - created_at: Math.floor(Date.now() / 1000) - }, - { specifiedRelayUrls: gifSelectPublishRelayUrls } - ).catch(() => {}) + void publish(buildKind1063GifPublishDraft(url, desc), { + specifiedRelayUrls: gifSelectPublishRelayUrls + }).catch(() => {}) + if (desc) setPublishDescription('') }, - [pubkey, onSelect, publish, gifSelectPublishRelayUrls] + [pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription] ) const handleUpload = async (e: React.ChangeEvent) => { @@ -201,21 +203,24 @@ export default function GifPicker({ return } const { url } = await mediaUpload.upload(file) + const desc = publishDescription.trim() + const tags: string[][] = [ + ['file', url, file.type || 'image/gif', `size ${file.size}`], + ['url', url], + ['m', file.type || 'image/gif'], + ['t', 'gif'] + ] + appendGifDescriptionTo1063Tags(tags, desc) const draft = { kind: ExtendedKind.FILE_METADATA, - content: publishDescription.trim(), - tags: [ - ['file', url, file.type || 'image/gif', `size ${file.size}`], - ['url', url], - ['m', file.type || 'image/gif'], - ['t', 'gif'] - ], + content: desc, + tags, created_at: Math.floor(Date.now() / 1000) } await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) setPublishDescription('') - setQuery('') - await loadGifs('', true) + setSearchInput('') + await loadGifs(true) } catch (err) { setUploadError(err instanceof Error ? err.message : 'Upload failed') } finally { @@ -267,17 +272,9 @@ export default function GifPicker({ if (pubkey) { setPublishingPaste(true) try { - const draft = { - kind: ExtendedKind.FILE_METADATA, - content: descriptionForPublish, - tags: [ - ['url', url], - ['m', 'image/gif'], - ['t', 'gif'] - ], - created_at: Math.floor(Date.now() / 1000) - } - await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) + await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), { + specifiedRelayUrls: gifPublishRelayUrls + }) setPublishDescription('') } catch { // ignore; URL was still inserted @@ -295,27 +292,21 @@ export default function GifPicker({ if (!pubkey) return const url = (gif.fallbackUrl?.trim() || gif.url).trim() if (!url || !/^https?:\/\//i.test(url)) return + const desc = publishDescription.trim() setArchivingEventId(gif.eventId) onSelect?.(url) setOpen(false) - void loadGifs(query, true) - void publish( - { - kind: ExtendedKind.FILE_METADATA, - content: '', - tags: [ - ['url', url], - ['m', 'image/gif'], - ['t', 'gif'] - ], - created_at: Math.floor(Date.now() / 1000) - }, - { specifiedRelayUrls: gifSelectPublishRelayUrls } - ) + void loadGifs(true) + void publish(buildKind1063GifPublishDraft(url, desc), { + specifiedRelayUrls: gifSelectPublishRelayUrls + }) .catch(() => {}) - .finally(() => setArchivingEventId(null)) + .finally(() => { + setArchivingEventId(null) + if (desc) setPublishDescription('') + }) }, - [pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, query] + [pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, publishDescription] ) const gifSourceKindTitle = useCallback( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index e0900d9f..9e181eb2 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -805,6 +805,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (storedRelayListEvent) { client.updateRelayListCache(storedRelayListEvent) } + void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal }) if (!storedFollowListEvent) { const trySetFollowListSkip = (evt: Event) => { if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index b1cf68e4..5987ba39 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -243,6 +243,11 @@ export interface QueryOptions { * AbortController, or other foreground work that must not be tied to the global background token). */ foreground?: boolean + /** + * When true, ignore {@link QueryService.interruptBackgroundQueries} without treating the query as feed-foreground + * (e.g. session-start GIF cache preload). + */ + backgroundInterruptImmune?: boolean } export interface SubscribeCallbacks { @@ -791,7 +796,7 @@ export class QueryService { sig.removeEventListener('abort', onAbortQuery) }) } - if (!foreground) { + if (!foreground && !options?.backgroundInterruptImmune) { registerQueryAbort(this.backgroundInterruptController.signal) } if (options?.signal) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 31ab78dd..17858a32 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -209,6 +209,7 @@ import { } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' +import { preloadGifsIntoIdbCache } from './gif.service' import { invalidateArchiveFootprintCache } from './event-archive.service' import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge' import nip66Service from './nip66.service' @@ -422,6 +423,8 @@ class ClientService extends EventTarget { private profileSearchIndexWarmed = false /** Deferred follow-graph prefetch; cancelled on new session prewarm. */ private followingIndexPrefetchTimer: ReturnType | null = null + /** Deferred kind 1063 GIF preload from viewer outboxes; cancelled on new session prewarm. */ + private gifOutboxPreloadTimer: ReturnType | null = null /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */ private authorCorePrefetchCooldownUntilMs = new Map() private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000 @@ -585,6 +588,20 @@ class ClientService extends EventTarget { /** Unblock sidebar/widgets immediately — no IndexedDB scan or NIP-66 at startup. */ notifySessionInteractivePrewarmComplete() + if (this.gifOutboxPreloadTimer != null) { + clearTimeout(this.gifOutboxPreloadTimer) + } + /** GIF relays + viewer mailbox → IndexedDB; not tied to hydrate AbortSignal (that aborts on account switch). */ + this.gifOutboxPreloadTimer = setTimeout(() => { + this.gifOutboxPreloadTimer = null + void this.runGifCachePreload(options.pubkey).catch((err) => { + logger.debug('[client] Prewarm: GIF cache preload failed', { + pubkeySlice: options.pubkey?.slice(0, 12) ?? null, + err: err instanceof Error ? err.message : String(err) + }) + }) + }, 2_000) + if (options.pubkey) { const pk = options.pubkey if (this.followingIndexPrefetchTimer != null) { @@ -604,6 +621,21 @@ class ClientService extends EventTarget { } } + /** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */ + private async runGifCachePreload(pubkey: string | null): Promise { + const extra: string[] = [] + if (pubkey) { + const rl = await this.peekRelayListFromStorage(pubkey) + extra.push( + ...(rl.read ?? []), + ...(rl.write ?? []), + ...(rl.httpRead ?? []), + ...(rl.httpWrite ?? []) + ) + } + await preloadGifsIntoIdbCache(pubkey, extra) + } + /** NIP-66 discovery for Explore / publish hints — call when the user opens Explore, not at boot. */ scheduleNip66RelayDiscoveryFromExplore(): void { if (typeof window === 'undefined') return diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index 59f790f8..1389d85b 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -3,7 +3,12 @@ * 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 { + 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' @@ -17,6 +22,8 @@ export interface GifMetadata { 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 @@ -77,6 +84,59 @@ const GIF_PRIORITY = { 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(['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 @@ -202,6 +262,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { sha256 = sha256Tag?.[1] const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1]) fallbackUrl = fallbackTag?.[1] + const description = descriptionFromGifEvent(event) return { url, @@ -210,6 +271,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { mimeType: mimeType || 'image/gif', width, height, + description, sourceKind: event.kind, eventId: event.id, pubkey: event.pubkey, @@ -219,11 +281,216 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { 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 | 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() + 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 { + 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, + 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 { + if (incoming.length === 0) return + const row = await indexedDb.getGifCache() + const byKey = new Map() + 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() + 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 { + 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 { + 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, @@ -232,102 +499,76 @@ const THECITADEL_FOR_GIF_METADATA = * @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) { + if (!forceRefresh) { 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) + const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) + return sortGifsForPicker(normalized, 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 } + 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 = dedupedUrls.some( - (u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase() - ) - ? dedupedUrls - : [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] + const { relays1063, relaysNotes: dedupedUrls } = gifRelayUrlsForFetch(extraReadRelayUrls) - // 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 - ) - ]) + let events1063: NEvent[] = [] + let eventsNotes: NEvent[] = [] - const events = [...events1063, ...eventsNotes] + 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 + } - // Map: normalized URL -> { gif, priority }. Higher priority wins when same URL appears multiple times. const byUrl = new Map() + mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey) + mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey) - 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 allGifs = sortGifsForPicker( + Array.from(byUrl.values()).map((v) => v.gif), + userPubkey + ) + let result = allGifs.slice(0, limit) - 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 }) - } + if (result.length === 0 && staleFallback?.length) { + result = sortGifsForPicker(staleFallback, userPubkey).slice(0, limit) } - 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()) + if (allGifs.length > 0) { + await mergeGifsIntoIdbCache(allGifs.slice(0, GIF_CACHE_CAP)) } return result @@ -338,22 +579,20 @@ export async function fetchGifs( * 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 { - try { - const cached = await indexedDb.getGifCache() - if (!cached?.gifs?.length) return [] - return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, 50) - } catch { - return [] - } + const all = await getAllCachedGifsForSearch(userPubkey) + return all.slice(0, 50) } -/** Search GIFs by query (same as fetchGifs with query). */ +/** 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[] = [], + _forceRefresh: boolean = false, + _extraReadRelayUrls: string[] = [], userPubkey: string | null = null ): Promise { - return fetchGifs(query, limit, forceRefresh, extraReadRelayUrls, userPubkey) + const q = query.trim() + if (!q) return getCachedGifs(userPubkey) + const pool = await getAllCachedGifsForSearch(userPubkey) + return pool.filter((g) => gifMetadataMatchesSearch(g, q)).slice(0, limit) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 955be363..8e0117da 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -2671,6 +2671,7 @@ class IndexedDbService { url: string fallbackUrl?: string sourceKind?: number + description?: string eventId: string pubkey: string createdAt: number @@ -2693,6 +2694,7 @@ class IndexedDbService { url: string fallbackUrl?: string sourceKind?: number + description?: string eventId: string pubkey: string createdAt: number @@ -2715,6 +2717,7 @@ class IndexedDbService { url: string fallbackUrl?: string sourceKind?: number + description?: string eventId: string pubkey: string createdAt: number