Browse Source

fix gif picker

imwald
Silberengel 3 weeks ago
parent
commit
e27532f74c
  1. 193
      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. 403
      src/services/gif.service.ts
  6. 3
      src/services/indexed-db.service.ts

193
src/components/GifPicker/index.tsx

@ -17,9 +17,11 @@ import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { import {
fetchGifs, fetchGifs,
getCachedGifs, getAllCachedGifsForSearch,
searchGifs, gifMetadataMatchesSearch,
gifShouldOfferNip94Archive, gifShouldOfferNip94Archive,
buildKind1063GifPublishDraft,
appendGifDescriptionTo1063Tags,
type GifMetadata type GifMetadata
} from '@/services/gif.service' } from '@/services/gif.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
@ -50,11 +52,13 @@ export default function GifPicker({
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, pubkey, relayList } = useNostr() const { publish, pubkey, relayList } = useNostr()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
// Initialise from the module-level session cache so re-opens are instant // Initialise from the module-level session cache so re-opens are instant
const [gifs, setGifsState] = useState<GifMetadata[]>(() => _sessionGifs) const [gifs, setGifsState] = useState<GifMetadata[]>(() => _sessionGifs)
const gifsRef = useRef<GifMetadata[]>(_sessionGifs) const gifsRef = useRef<GifMetadata[]>(_sessionGifs)
const gifPoolRef = useRef<GifMetadata[]>([])
const searchInputRef = useRef(searchInput)
searchInputRef.current = searchInput
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@ -63,7 +67,6 @@ export default function GifPicker({
const [publishingPaste, setPublishingPaste] = useState(false) const [publishingPaste, setPublishingPaste] = useState(false)
const [archivingEventId, setArchivingEventId] = useState<string | null>(null) const [archivingEventId, setArchivingEventId] = useState<string | null>(null)
const [publishDescription, setPublishDescription] = useState('') const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null) const gifbuddyPopupRef = useRef<Window | null>(null)
@ -106,88 +109,87 @@ export default function GifPicker({
setGifsState(newGifs) setGifsState(newGifs)
}, []) }, [])
const loadGifs = useCallback(async (q: string, forceRefresh = false) => { /** Apply search filter to the in-memory GIF pool (instant, no network). */
setError(null) const applyLocalFilter = useCallback(
const isSearch = q.trim() !== '' (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. const refreshGifPoolFromIdb = useCallback(async () => {
if (isSearch) { const pool = await getAllCachedGifsForSearch(pubkey ?? null)
gifsRef.current = [] gifPoolRef.current = pool
setGifsState([]) return pool
setLoading(true) }, [pubkey])
} else if (gifsRef.current.length === 0) {
// No data yet — try the IDB cache first so we can show something instantly. const loadGifs = useCallback(
try { async (forceRefresh = false) => {
const cached = await getCachedGifs(pubkey ?? null) setError(null)
if (cached.length > 0) {
setGifs(cached) if (gifPoolRef.current.length === 0) {
try {
const cached = await refreshGifPoolFromIdb()
if (cached.length > 0) {
applyLocalFilter(searchInputRef.current)
}
} catch {
/* ignore */
} }
} catch { /* ignore */ } if (gifPoolRef.current.length === 0) setLoading(true)
// 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.
try { try {
const results = isSearch await fetchGifs(50, forceRefresh, userReadRelays, pubkey ?? null)
? await searchGifs(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) await refreshGifPoolFromIdb()
: await fetchGifs(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) applyLocalFilter(searchInputRef.current)
setGifs(results, isSearch) if (gifPoolRef.current.length === 0 && !searchInput.trim()) {
if (results.length === 0 && !isSearch) { setError(
setError( t(
t( 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.'
'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') [t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb]
if (gifsRef.current.length === 0) setGifsState([]) )
} finally {
setLoading(false)
}
}, [t, userReadRelays, pubkey, setGifs])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
loadGifs(query) applyLocalFilter(searchInput)
}, [open, query, loadGifs]) }, [searchInput, open, applyLocalFilter])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) void loadGifs()
searchTimeoutRef.current = setTimeout(() => { }, [open, loadGifs])
setQuery(searchInput)
}, 300)
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
}
}, [searchInput, open])
const handleSelect = useCallback( const handleSelect = useCallback(
(gif: GifMetadata) => { (gif: GifMetadata) => {
const url = (gif.fallbackUrl?.trim() || gif.url).trim() const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url) return if (!url) return
const desc = publishDescription.trim()
onSelect?.(url) onSelect?.(url)
setOpen(false) setOpen(false)
if (!pubkey || !/^https?:\/\//i.test(url)) return if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down. // Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish( void publish(buildKind1063GifPublishDraft(url, desc), {
{ specifiedRelayUrls: gifSelectPublishRelayUrls
kind: ExtendedKind.FILE_METADATA, }).catch(() => {})
content: '', if (desc) setPublishDescription('')
tags: [
['url', url],
['m', 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000)
},
{ specifiedRelayUrls: gifSelectPublishRelayUrls }
).catch(() => {})
}, },
[pubkey, onSelect, publish, gifSelectPublishRelayUrls] [pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription]
) )
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -201,21 +203,24 @@ export default function GifPicker({
return return
} }
const { url } = await mediaUpload.upload(file) 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 = { const draft = {
kind: ExtendedKind.FILE_METADATA, kind: ExtendedKind.FILE_METADATA,
content: publishDescription.trim(), content: desc,
tags: [ tags,
['file', url, file.type || 'image/gif', `size ${file.size}`],
['url', url],
['m', file.type || 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
setPublishDescription('') setPublishDescription('')
setQuery('') setSearchInput('')
await loadGifs('', true) await loadGifs(true)
} catch (err) { } catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed') setUploadError(err instanceof Error ? err.message : 'Upload failed')
} finally { } finally {
@ -267,17 +272,9 @@ export default function GifPicker({
if (pubkey) { if (pubkey) {
setPublishingPaste(true) setPublishingPaste(true)
try { try {
const draft = { await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), {
kind: ExtendedKind.FILE_METADATA, specifiedRelayUrls: gifPublishRelayUrls
content: descriptionForPublish, })
tags: [
['url', url],
['m', 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000)
}
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
setPublishDescription('') setPublishDescription('')
} catch { } catch {
// ignore; URL was still inserted // ignore; URL was still inserted
@ -295,27 +292,21 @@ export default function GifPicker({
if (!pubkey) return if (!pubkey) return
const url = (gif.fallbackUrl?.trim() || gif.url).trim() const url = (gif.fallbackUrl?.trim() || gif.url).trim()
if (!url || !/^https?:\/\//i.test(url)) return if (!url || !/^https?:\/\//i.test(url)) return
const desc = publishDescription.trim()
setArchivingEventId(gif.eventId) setArchivingEventId(gif.eventId)
onSelect?.(url) onSelect?.(url)
setOpen(false) setOpen(false)
void loadGifs(query, true) void loadGifs(true)
void publish( void publish(buildKind1063GifPublishDraft(url, desc), {
{ specifiedRelayUrls: gifSelectPublishRelayUrls
kind: ExtendedKind.FILE_METADATA, })
content: '',
tags: [
['url', url],
['m', 'image/gif'],
['t', 'gif']
],
created_at: Math.floor(Date.now() / 1000)
},
{ specifiedRelayUrls: gifSelectPublishRelayUrls }
)
.catch(() => {}) .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( const gifSourceKindTitle = useCallback(

1
src/providers/NostrProvider/index.tsx

@ -805,6 +805,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (storedRelayListEvent) { if (storedRelayListEvent) {
client.updateRelayListCache(storedRelayListEvent) client.updateRelayListCache(storedRelayListEvent)
} }
void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal })
if (!storedFollowListEvent) { if (!storedFollowListEvent) {
const trySetFollowListSkip = (evt: Event) => { const trySetFollowListSkip = (evt: Event) => {
if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return if (hydrationGenForThisRun !== accountHydrationGenerationRef.current) return

7
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). * AbortController, or other foreground work that must not be tied to the global background token).
*/ */
foreground?: boolean 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 { export interface SubscribeCallbacks {
@ -791,7 +796,7 @@ export class QueryService {
sig.removeEventListener('abort', onAbortQuery) sig.removeEventListener('abort', onAbortQuery)
}) })
} }
if (!foreground) { if (!foreground && !options?.backgroundInterruptImmune) {
registerQueryAbort(this.backgroundInterruptController.signal) registerQueryAbort(this.backgroundInterruptController.signal)
} }
if (options?.signal) { if (options?.signal) {

32
src/services/client.service.ts

@ -209,6 +209,7 @@ import {
} from 'nostr-tools' } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import { preloadGifsIntoIdbCache } from './gif.service'
import { invalidateArchiveFootprintCache } from './event-archive.service' import { invalidateArchiveFootprintCache } from './event-archive.service'
import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge' import { notifySessionInteractivePrewarmComplete } from './session-interactive-prewarm-bridge'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
@ -422,6 +423,8 @@ class ClientService extends EventTarget {
private profileSearchIndexWarmed = false private profileSearchIndexWarmed = false
/** Deferred follow-graph prefetch; cancelled on new session prewarm. */ /** Deferred follow-graph prefetch; cancelled on new session prewarm. */
private followingIndexPrefetchTimer: ReturnType<typeof setTimeout> | null = null 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). */ /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
private authorCorePrefetchCooldownUntilMs = new Map<string, number>() private authorCorePrefetchCooldownUntilMs = new Map<string, number>()
private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 6 * 60 * 1000 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. */ /** Unblock sidebar/widgets immediately — no IndexedDB scan or NIP-66 at startup. */
notifySessionInteractivePrewarmComplete() 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) { if (options.pubkey) {
const pk = options.pubkey const pk = options.pubkey
if (this.followingIndexPrefetchTimer != null) { 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<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. */ /** NIP-66 discovery for Explore / publish hints — call when the user opens Explore, not at boot. */
scheduleNip66RelayDiscoveryFromExplore(): void { scheduleNip66RelayDiscoveryFromExplore(): void {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return

403
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. * 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 { normalizeUrl } from '@/lib/url'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import type { Event as NEvent } from 'nostr-tools' import type { Event as NEvent } from 'nostr-tools'
@ -17,6 +22,8 @@ export interface GifMetadata {
mimeType?: string mimeType?: string
width?: number width?: number
height?: 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). */ /** Nostr kind of the event this row was parsed from (1063 vs note vs comment). */
sourceKind: number sourceKind: number
eventId: string eventId: string
@ -77,6 +84,59 @@ const GIF_PRIORITY = {
NON_EVENT: 0 NON_EVENT: 0
} as const } 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 { function parseGifFromEvent(event: NEvent): GifMetadata | null {
let url: string | undefined let url: string | undefined
let mimeType: string | undefined let mimeType: string | undefined
@ -202,6 +262,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
sha256 = sha256Tag?.[1] sha256 = sha256Tag?.[1]
const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1]) const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1])
fallbackUrl = fallbackTag?.[1] fallbackUrl = fallbackTag?.[1]
const description = descriptionFromGifEvent(event)
return { return {
url, url,
@ -210,6 +271,7 @@ function parseGifFromEvent(event: NEvent): GifMetadata | null {
mimeType: mimeType || 'image/gif', mimeType: mimeType || 'image/gif',
width, width,
height, height,
description,
sourceKind: event.kind, sourceKind: event.kind,
eventId: event.id, eventId: event.id,
pubkey: event.pubkey, 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 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 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. */ /** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */
const THECITADEL_FOR_GIF_METADATA = const THECITADEL_FOR_GIF_METADATA =
normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' 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. * Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays.
* Deduplicates by normalized URL; when the same GIF appears from multiple sources, * 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. * @param userPubkey - Current user's pubkey; entries from this pubkey get highest priority when deduping.
*/ */
export async function fetchGifs( export async function fetchGifs(
searchQuery?: string,
limit: number = 50, limit: number = 50,
forceRefresh: boolean = false, forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [], extraReadRelayUrls: string[] = [],
userPubkey: string | null = null userPubkey: string | null = null
): Promise<GifMetadata[]> { ): Promise<GifMetadata[]> {
if (!forceRefresh && !searchQuery) { if (!forceRefresh) {
const cached = await indexedDb.getGifCache() const cached = await indexedDb.getGifCache()
const cacheHasSourceKind =
cached?.gifs.length &&
cached.gifs.every((g) => typeof (g as GifMetadata).sourceKind === 'number')
if ( if (
cached && cached &&
cacheHasSourceKind &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES && cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS 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. let staleFallback: GifMetadata[] | null = null
const readUrls = [ const row = await indexedDb.getGifCache()
...GIF_RELAY_URLS, if (row?.gifs?.length) {
...FAST_READ_RELAY_URLS, staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
...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 limit1063 = Math.max(limit * 15, 400)
const limitNotes = Math.max(limit * 15, 500) const limitNotes = Math.max(limit * 15, 500)
const relays1063 = dedupedUrls.some( const { relays1063, relaysNotes: dedupedUrls } = gifRelayUrlsForFetch(extraReadRelayUrls)
(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). let events1063: NEvent[] = []
const [events1063, eventsNotes] = await Promise.all([ let eventsNotes: NEvent[] = []
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] 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<string, { gif: GifMetadata; priority: number }>() 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 allGifs = sortGifsForPicker(
const gif = parseGifFromEvent(event) Array.from(byUrl.values()).map((v) => v.gif),
if (!gif) continue userPubkey
)
if (searchQuery) { let result = allGifs.slice(0, limit)
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) if (result.length === 0 && staleFallback?.length) {
const priority = result = sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
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) if (allGifs.length > 0) {
const result = sortGifsForPicker(gifs, userPubkey).slice(0, limit) await mergeGifsIntoIdbCache(allGifs.slice(0, GIF_CACHE_CAP))
if (result.length >= MIN_GIF_CACHE_ENTRIES && !searchQuery) {
await indexedDb.setGifCache(result, Date.now())
} }
return result 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. * 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[]> { export async function getCachedGifs(userPubkey: string | null = null): Promise<GifMetadata[]> {
try { const all = await getAllCachedGifsForSearch(userPubkey)
const cached = await indexedDb.getGifCache() return all.slice(0, 50)
if (!cached?.gifs?.length) return []
return sortGifsForPicker(cached.gifs as GifMetadata[], userPubkey).slice(0, 50)
} catch {
return []
}
} }
/** 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( export async function searchGifs(
query: string, query: string,
limit: number = 50, limit: number = 50,
forceRefresh: boolean = false, _forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [], _extraReadRelayUrls: string[] = [],
userPubkey: string | null = null userPubkey: string | null = null
): Promise<GifMetadata[]> { ): 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 {
url: string url: string
fallbackUrl?: string fallbackUrl?: string
sourceKind?: number sourceKind?: number
description?: string
eventId: string eventId: string
pubkey: string pubkey: string
createdAt: number createdAt: number
@ -2693,6 +2694,7 @@ class IndexedDbService {
url: string url: string
fallbackUrl?: string fallbackUrl?: string
sourceKind?: number sourceKind?: number
description?: string
eventId: string eventId: string
pubkey: string pubkey: string
createdAt: number createdAt: number
@ -2715,6 +2717,7 @@ class IndexedDbService {
url: string url: string
fallbackUrl?: string fallbackUrl?: string
sourceKind?: number sourceKind?: number
description?: string
eventId: string eventId: string
pubkey: string pubkey: string
createdAt: number createdAt: number

Loading…
Cancel
Save