Browse Source

fix gif picker

imwald
Silberengel 3 weeks ago
parent
commit
e27532f74c
  1. 161
      src/components/GifPicker/index.tsx
  2. 1
      src/providers/NostrProvider/index.tsx
  3. 7
      src/services/client-query.service.ts
  4. 32
      src/services/client.service.ts
  5. 381
      src/services/gif.service.ts
  6. 3
      src/services/indexed-db.service.ts

161
src/components/GifPicker/index.tsx

@ -17,9 +17,11 @@ import { cn } from '@/lib/utils' @@ -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({ @@ -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<GifMetadata[]>(() => _sessionGifs)
const gifsRef = useRef<GifMetadata[]>(_sessionGifs)
const gifPoolRef = useRef<GifMetadata[]>([])
const searchInputRef = useRef(searchInput)
searchInputRef.current = searchInput
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
@ -63,7 +67,6 @@ export default function GifPicker({ @@ -63,7 +67,6 @@ export default function GifPicker({
const [publishingPaste, setPublishingPaste] = useState(false)
const [archivingEventId, setArchivingEventId] = useState<string | null>(null)
const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null)
@ -106,35 +109,46 @@ export default function GifPicker({ @@ -106,35 +109,46 @@ export default function GifPicker({
setGifsState(newGifs)
}, [])
const loadGifs = useCallback(async (q: string, forceRefresh = false) => {
/** 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]
)
const refreshGifPoolFromIdb = useCallback(async () => {
const pool = await getAllCachedGifsForSearch(pubkey ?? null)
gifPoolRef.current = pool
return pool
}, [pubkey])
const loadGifs = useCallback(
async (forceRefresh = false) => {
setError(null)
const isSearch = q.trim() !== ''
// 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.
if (gifPoolRef.current.length === 0) {
try {
const cached = await getCachedGifs(pubkey ?? null)
const cached = await refreshGifPoolFromIdb()
if (cached.length > 0) {
setGifs(cached)
applyLocalFilter(searchInputRef.current)
}
} catch { /* ignore */ }
// If still empty after the cache read, show the skeleton while we wait for relays.
if (gifsRef.current.length === 0) setLoading(true)
} catch {
/* ignore */
}
if (gifPoolRef.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.
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) {
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.'
@ -143,51 +157,39 @@ export default function GifPicker({ @@ -143,51 +157,39 @@ export default function GifPicker({
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load GIFs')
if (gifsRef.current.length === 0) setGifsState([])
if (gifPoolRef.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)
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls
}).catch(() => {})
if (desc) setPublishDescription('')
},
{ specifiedRelayUrls: gifSelectPublishRelayUrls }
).catch(() => {})
},
[pubkey, onSelect, publish, gifSelectPublishRelayUrls]
[pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -201,21 +203,24 @@ export default function GifPicker({ @@ -201,21 +203,24 @@ export default function GifPicker({
return
}
const { url } = await mediaUpload.upload(file)
const draft = {
kind: ExtendedKind.FILE_METADATA,
content: publishDescription.trim(),
tags: [
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: 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({ @@ -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({ @@ -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(

1
src/providers/NostrProvider/index.tsx

@ -805,6 +805,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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

7
src/services/client-query.service.ts

@ -243,6 +243,11 @@ export interface QueryOptions { @@ -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 { @@ -791,7 +796,7 @@ export class QueryService {
sig.removeEventListener('abort', onAbortQuery)
})
}
if (!foreground) {
if (!foreground && !options?.backgroundInterruptImmune) {
registerQueryAbort(this.backgroundInterruptController.signal)
}
if (options?.signal) {

32
src/services/client.service.ts

@ -209,6 +209,7 @@ import { @@ -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 { @@ -422,6 +423,8 @@ class ClientService extends EventTarget {
private profileSearchIndexWarmed = false
/** Deferred follow-graph prefetch; cancelled on new session prewarm. */
private followingIndexPrefetchTimer: ReturnType<typeof setTimeout> | null = null
/** Deferred kind 1063 GIF preload from viewer outboxes; cancelled on new session prewarm. */
private gifOutboxPreloadTimer: ReturnType<typeof setTimeout> | null = null
/** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
private authorCorePrefetchCooldownUntilMs = new Map<string, number>()
private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000
@ -585,6 +588,20 @@ class ClientService extends EventTarget { @@ -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 { @@ -604,6 +621,21 @@ class ClientService extends EventTarget {
}
}
/** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */
private async runGifCachePreload(pubkey: string | null): Promise<void> {
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

381
src/services/gif.service.ts

@ -3,7 +3,12 @@ @@ -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 { @@ -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 = { @@ -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<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
@ -202,6 +262,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null { @@ -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 { @@ -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 { @@ -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<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,
@ -232,61 +499,43 @@ const THECITADEL_FOR_GIF_METADATA = @@ -232,61 +499,43 @@ 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<GifMetadata[]> {
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<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 }
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([
let events1063: NEvent[] = []
let eventsNotes: NEvent[] = []
try {
;[events1063, eventsNotes] = await Promise.all([
queryService.fetchEvents(
relays1063,
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 },
fetchOpts
GIF_FETCH_OPTS
),
queryService.fetchEvents(
dedupedUrls,
@ -294,40 +543,32 @@ export async function fetchGifs( @@ -294,40 +543,32 @@ export async function fetchGifs(
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: limitNotes
},
fetchOpts
GIF_FETCH_OPTS
)
])
} catch (err) {
if (staleFallback?.length) {
return sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
}
throw err
}
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 }>()
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey)
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey)
for (const event of events) {
const gif = parseGifFromEvent(event)
if (!gif) continue
const allGifs = sortGifsForPicker(
Array.from(byUrl.values()).map((v) => v.gif),
userPubkey
)
let result = allGifs.slice(0, limit)
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
if (result.length === 0 && staleFallback?.length) {
result = sortGifsForPicker(staleFallback, userPubkey).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 })
}
}
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( @@ -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<GifMetadata[]> {
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<GifMetadata[]> {
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)
}

3
src/services/indexed-db.service.ts

@ -2671,6 +2671,7 @@ class IndexedDbService { @@ -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 { @@ -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 { @@ -2715,6 +2717,7 @@ class IndexedDbService {
url: string
fallbackUrl?: string
sourceKind?: number
description?: string
eventId: string
pubkey: string
createdAt: number

Loading…
Cancel
Save