Browse Source

fix gif picker

imwald
Silberengel 2 weeks ago
parent
commit
db3dc48846
  1. 88
      src/components/GifPicker/index.tsx
  2. 2
      src/constants.ts
  3. 12
      src/lib/relay-auth-feedback.ts
  4. 13
      src/services/client.service.ts
  5. 74
      src/services/gif.service.test.ts
  6. 515
      src/services/gif.service.ts

88
src/components/GifPicker/index.tsx

@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls' import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import { import {
fetchGifs, fetchGifs,
getAllCachedGifsForSearch, getAllCachedGifsForSearch,
getGif1063RelayUrls,
gifMetadataMatchesSearch, gifMetadataMatchesSearch,
gifShouldOfferNip94Archive, gifShouldOfferNip94Archive,
buildKind1063GifPublishDraft, buildKind1063GifPublishDraft,
@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service'
import { Download, ExternalLink, X } from 'lucide-react' import { Download, ExternalLink, X } from 'lucide-react'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFollowListOptional } from '@/providers/follow-list-context'
/** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */ /** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */
let _sessionGifs: GifMetadata[] = [] let _sessionGifs: GifMetadata[] = []
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const GIFBUDDY_URL = 'https://www.gifbuddy.lol/' const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
/** Stable empty follows list — avoids re-running picker fetch every render. */
const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
/** Query param gifbuddy may use for pre-filled search (common convention). */ /** Query param gifbuddy may use for pre-filled search (common convention). */
const GIFBUDDY_SEARCH_URL = (q: string) => const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL
@ -51,6 +54,12 @@ export default function GifPicker({
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { publish, pubkey } = useNostr() const { publish, pubkey } = useNostr()
const followList = useFollowListOptional()
const followingPubkeys = useMemo(
() => followList?.followings ?? EMPTY_FOLLOWING_PUBKEYS,
[followList?.followings]
)
const loadGenerationRef = useRef(0)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
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
@ -71,36 +80,9 @@ export default function GifPicker({
const gifbuddyPopupRef = useRef<Window | null>(null) const gifbuddyPopupRef = useRef<Window | null>(null)
const userReadRelays = useUserReadInboxUrls() const userReadRelays = useUserReadInboxUrls()
const userWriteRelays = useUserWriteOutboxUrls()
/** Paste / upload: GIF discovery relays + user writes (unchanged). */ /** Kind 1063 publish targets — GIF relays only. */
const gifPublishRelayUrls = useMemo(() => { const gif1063PublishRelayUrls = useMemo(() => getGif1063RelayUrls(), [])
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
return writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Grid pick / archive: user write relays first, then fast write relays as fallback. */
const gifSelectPublishRelayUrls = useMemo(() => {
const primary =
userWriteRelays.length > 0 ? userWriteRelays : [...FAST_WRITE_RELAY_URLS]
const extra = userWriteRelays.length > 0 ? FAST_WRITE_RELAY_URLS : []
const seen = new Set<string>()
return [...primary, ...extra]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.filter((u) => {
const n = u.toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Keep gifsRef, session cache, and React state in sync. */ /** Keep gifsRef, session cache, and React state in sync. */
const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => { const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => {
@ -116,25 +98,27 @@ export default function GifPicker({
const pool = gifPoolRef.current const pool = gifPoolRef.current
const filtered = trimmed const filtered = trimmed
? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed)) ? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed))
: pool.slice(0, 50) : pool
setGifs(filtered, trimmed.length > 0) setGifs(filtered, trimmed.length > 0)
}, },
[setGifs] [setGifs]
) )
const refreshGifPoolFromIdb = useCallback(async () => { const refreshGifPoolFromIdb = useCallback(async () => {
const pool = await getAllCachedGifsForSearch(pubkey ?? null) const pool = await getAllCachedGifsForSearch(pubkey ?? null, followingPubkeys)
gifPoolRef.current = pool gifPoolRef.current = pool
return pool return pool
}, [pubkey]) }, [pubkey, followingPubkeys])
const loadGifs = useCallback( const loadGifs = useCallback(
async (forceRefresh = false) => { async (forceRefresh = false) => {
const generation = ++loadGenerationRef.current
setError(null) setError(null)
if (gifPoolRef.current.length === 0) { if (gifPoolRef.current.length === 0) {
try { try {
const cached = await refreshGifPoolFromIdb() const cached = await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
if (cached.length > 0) { if (cached.length > 0) {
applyLocalFilter(searchInputRef.current) applyLocalFilter(searchInputRef.current)
} }
@ -145,10 +129,17 @@ export default function GifPicker({
} }
try { try {
await fetchGifs(50, forceRefresh, userReadRelays, pubkey ?? null) await fetchGifs({
forceRefresh: forceRefresh || Boolean(pubkey),
userPubkey: pubkey ?? null,
followingPubkeys,
noteFallbackRelays: userReadRelays
})
if (generation !== loadGenerationRef.current) return
await refreshGifPoolFromIdb() await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
applyLocalFilter(searchInputRef.current) applyLocalFilter(searchInputRef.current)
if (gifPoolRef.current.length === 0 && !searchInput.trim()) { if (gifPoolRef.current.length === 0 && !searchInputRef.current.trim()) {
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.'
@ -156,13 +147,14 @@ export default function GifPicker({
) )
} }
} catch (e) { } catch (e) {
if (generation !== loadGenerationRef.current) return
setError(e instanceof Error ? e.message : 'Failed to load GIFs') setError(e instanceof Error ? e.message : 'Failed to load GIFs')
if (gifPoolRef.current.length === 0) setGifsState([]) if (gifPoolRef.current.length === 0) setGifsState([])
} finally { } finally {
setLoading(false) if (generation === loadGenerationRef.current) setLoading(false)
} }
}, },
[t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb] [t, userReadRelays, pubkey, followingPubkeys, applyLocalFilter, refreshGifPoolFromIdb]
) )
useEffect(() => { useEffect(() => {
@ -185,11 +177,11 @@ export default function GifPicker({
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(buildKind1063GifPublishDraft(url, desc), { void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls specifiedRelayUrls: gif1063PublishRelayUrls
}).catch(() => {}) }).catch(() => {})
if (desc) setPublishDescription('') if (desc) setPublishDescription('')
}, },
[pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription] [pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
) )
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -217,7 +209,7 @@ export default function GifPicker({
tags, tags,
created_at: Math.floor(Date.now() / 1000) created_at: Math.floor(Date.now() / 1000)
} }
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) await publish(draft, { specifiedRelayUrls: gif1063PublishRelayUrls })
setPublishDescription('') setPublishDescription('')
setSearchInput('') setSearchInput('')
await loadGifs(true) await loadGifs(true)
@ -273,7 +265,7 @@ export default function GifPicker({
setPublishingPaste(true) setPublishingPaste(true)
try { try {
await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), { await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), {
specifiedRelayUrls: gifPublishRelayUrls specifiedRelayUrls: gif1063PublishRelayUrls
}) })
setPublishDescription('') setPublishDescription('')
} catch { } catch {
@ -282,7 +274,7 @@ export default function GifPicker({
setPublishingPaste(false) setPublishingPaste(false)
} }
} }
}, [pasteUrl, pubkey, onSelect, publish, gifPublishRelayUrls, descriptionForPublish]) }, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */ /** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback( const handleArchiveAndInsert = useCallback(
@ -298,7 +290,7 @@ export default function GifPicker({
setOpen(false) setOpen(false)
void loadGifs(true) void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), { void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls specifiedRelayUrls: gif1063PublishRelayUrls
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@ -306,7 +298,7 @@ export default function GifPicker({
if (desc) setPublishDescription('') if (desc) setPublishDescription('')
}) })
}, },
[pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, publishDescription] [pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
) )
const gifSourceKindTitle = useCallback( const gifSourceKindTitle = useCallback(
@ -372,8 +364,8 @@ export default function GifPicker({
<ScrollArea <ScrollArea
className={ className={
isDrawer isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border' ? 'flex-1 min-h-[420px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border' : 'h-[520px] w-full rounded-md border'
} }
> >
{loading ? ( {loading ? (

2
src/constants.ts

@ -530,7 +530,7 @@ export const MONERO_NOSTR_RELAY_URLS = [
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */ * Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [ export const GIF_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://gifbuddy.lol' 'wss://relay.gifbuddy.lol'
] ]
export const SEARCHABLE_RELAY_URLS = [ export const SEARCHABLE_RELAY_URLS = [

12
src/lib/relay-auth-feedback.ts

@ -20,21 +20,11 @@ function relayLabel(url: string): string {
} }
} }
/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */ /** Relay responded to NIP-42 AUTH with OK — log only (no success toast; routine on many relays). */
export function notifyRelayNip42Accepted(url: string, okReason?: string): void { export function notifyRelayNip42Accepted(url: string, okReason?: string): void {
const key = sessionKeyForRelay(url) const key = sessionKeyForRelay(url)
if (!key || nip42NotifiedAccept.has(key)) return if (!key || nip42NotifiedAccept.has(key)) return
nip42NotifiedAccept.add(key) nip42NotifiedAccept.add(key)
const relay = relayLabel(url)
const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : ''
toast.success(
i18n.t('Relay auth accepted (NIP-42)', {
relay,
detailSuffix,
defaultValue: `The relay accepted authentication (NIP-42): ${relay}${detailSuffix}`
})
)
logger.info('[NIP-42] Auth accepted by relay', { url, okReason }) logger.info('[NIP-42] Auth accepted by relay', { url, okReason })
} }

13
src/services/client.service.ts

@ -654,16 +654,15 @@ class ClientService extends EventTarget {
/** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */ /** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */
private async runGifCachePreload(pubkey: string | null): Promise<void> { private async runGifCachePreload(pubkey: string | null): Promise<void> {
const extra: string[] = [] let followings: string[] = []
let noteFallbackRelays: string[] = []
if (pubkey) { if (pubkey) {
const ev = await this.fetchFollowListEvent(pubkey)
if (ev) followings = getPubkeysFromPTags(ev.tags)
const rl = await this.peekRelayListFromStorage(pubkey) const rl = await this.peekRelayListFromStorage(pubkey)
const [readInboxes, writeOutboxes] = await Promise.all([ noteFallbackRelays = await collectViewerReadInboxUrls(pubkey, rl)
collectViewerReadInboxUrls(pubkey, rl),
collectViewerWriteOutboxUrls(pubkey, rl)
])
extra.push(...readInboxes, ...writeOutboxes)
} }
await preloadGifsIntoIdbCache(pubkey, extra) await preloadGifsIntoIdbCache(pubkey, followings, noteFallbackRelays)
} }
/** 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. */

74
src/services/gif.service.test.ts

@ -0,0 +1,74 @@
import { kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants'
import {
dedupeGifsByUrl,
getGif1063RelayUrls,
sortGifsForPicker,
type GifMetadata
} from './gif.service'
describe('gif.service', () => {
it('getGif1063RelayUrls returns deduped GIF relay constants', () => {
const urls = getGif1063RelayUrls()
expect(urls.length).toBeGreaterThan(0)
expect(urls.some((u) => u.includes('thecitadel'))).toBe(true)
expect(urls.some((u) => u.includes('relay.gifbuddy.lol'))).toBe(true)
})
it('sortGifsForPicker orders own, follows, then others', () => {
const me = 'a'.repeat(64)
const follow = 'b'.repeat(64)
const other = 'c'.repeat(64)
const mk = (pubkey: string, createdAt: number): GifMetadata => ({
url: `https://example.com/${pubkey.slice(0, 4)}.gif`,
sourceKind: ExtendedKind.FILE_METADATA,
eventId: `${pubkey}-${createdAt}`,
pubkey,
createdAt
})
const gifs = [
mk(other, 300),
mk(follow, 200),
mk(me, 100),
mk(me, 400),
mk(follow, 500)
]
const sorted = sortGifsForPicker(gifs, me, [follow])
expect(sorted.map((g) => g.pubkey)).toEqual([
me,
me,
follow,
follow,
other
])
expect(sorted[0]!.createdAt).toBe(400)
expect(sorted[1]!.createdAt).toBe(100)
})
it('dedupeGifsByUrl keeps kind 1063 over kind 1 and 1111 for the same URL', () => {
const url = 'https://cdn.example/animation.gif'
const note = {
url,
sourceKind: kinds.ShortTextNote,
eventId: 'note-1',
pubkey: 'a'.repeat(64),
createdAt: 200
}
const comment = {
url,
sourceKind: ExtendedKind.COMMENT,
eventId: 'comment-1',
pubkey: 'a'.repeat(64),
createdAt: 300
}
const fileMeta = {
url,
sourceKind: ExtendedKind.FILE_METADATA,
eventId: '1063-1',
pubkey: 'a'.repeat(64),
createdAt: 100
}
expect(dedupeGifsByUrl([note, comment, fileMeta])).toEqual([fileMeta])
expect(dedupeGifsByUrl([note, comment])).toEqual([comment])
})
})

515
src/services/gif.service.ts

@ -6,12 +6,13 @@
import { import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
GIF_RELAY_URLS GIF_RELAY_URLS,
METADATA_BATCH_AUTHORS_CHUNK
} from '@/constants' } from '@/constants'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { kinds } from 'nostr-tools' import { kinds, type Event as NEvent, type Filter } from 'nostr-tools'
import type { Event as NEvent } from 'nostr-tools'
import { queryService } from './client.service' import { queryService } from './client.service'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
@ -52,15 +53,24 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean {
return true return true
} }
/** Own GIFs/memes first, then newest first (picker grids). */ /** Own GIFs, then follows, then others — newest first within each tier. */
export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] { export function sortGifsForPicker(
gifs: GifMetadata[],
userPubkey: string | null,
followingPubkeys: readonly string[] = []
): GifMetadata[] {
const u = userPubkey?.toLowerCase() ?? '' const u = userPubkey?.toLowerCase() ?? ''
return [...gifs].sort((a, b) => { const followSet = new Set(followingPubkeys.map((p) => p.toLowerCase()).filter(Boolean))
if (u) { const tier = (g: GifMetadata): number => {
const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0 const pk = g.pubkey.toLowerCase()
const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0 if (u && pk === u) return 0
if (aOwn !== bOwn) return bOwn - aOwn if (followSet.has(pk)) return 1
return 2
} }
return [...gifs].sort((a, b) => {
const ta = tier(a)
const tb = tier(b)
if (ta !== tb) return ta - tb
return b.createdAt - a.createdAt return b.createdAt - a.createdAt
}) })
} }
@ -77,12 +87,35 @@ function normalizeGifUrl(url: string): string {
} }
} }
/** Priority for deduplication: higher wins. Own event > other's event > non-event. */ /** Higher wins when the same GIF URL appears from multiple Nostr events. */
const GIF_PRIORITY = { function gifSourceKindPriority(sourceKind: number): number {
OWN_EVENT: 2, if (sourceKind === ExtendedKind.FILE_METADATA) return 3
OTHER_EVENT: 1, if (sourceKind === ExtendedKind.COMMENT) return 2
NON_EVENT: 0 if (sourceKind === kinds.ShortTextNote) return 1
} as const if (sourceKind === ExtendedKind.DISCUSSION) return 1
return 0
}
function shouldPreferGif(candidate: GifMetadata, existing: GifMetadata): boolean {
const cp = gifSourceKindPriority(candidate.sourceKind)
const ep = gifSourceKindPriority(existing.sourceKind)
if (cp !== ep) return cp > ep
return candidate.createdAt > existing.createdAt
}
/** One grid row per GIF URL; kind 1063 beats kind 1 / 1111 / 11 from notes. */
export function dedupeGifsByUrl(gifs: readonly GifMetadata[]): GifMetadata[] {
const byUrl = new Map<string, GifMetadata>()
for (const gif of gifs) {
if (!gif.url?.trim()) continue
const key = normalizeGifUrl(gif.url)
const existing = byUrl.get(key)
if (!existing || shouldPreferGif(gif, existing)) {
byUrl.set(key, gif)
}
}
return [...byUrl.values()]
}
/** `#t` tokens derived from a user description (always includes literal `gif` separately). */ /** `#t` tokens derived from a user description (always includes literal `gif` separately). */
function topicTagsFromGifDescription(description: string): string[] { function topicTagsFromGifDescription(description: string): string[] {
@ -279,35 +312,50 @@ 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
const MIN_GIF_CACHE_ENTRIES = 8 const MIN_GIF_CACHE_ENTRIES = 1
const GIF_CACHE_CAP = 80 /** Max kind-1063 rows from authors outside the viewer + follow graph. */
const GIF_OTHERS_1063_MAX = 500
/** Per-REQ page size when paginating author/global 1063 fetches on GIF relays. */
const GIF_1063_PAGE_LIMIT = 500
const GIF_AUTHOR_1063_MAX_PAGES = 40
/** When the 1063 pool is smaller than this, also scrape kind 1 / 1111 for GIF URLs. */
const GIF_NOTES_FALLBACK_THRESHOLD = 50
const GIF_NOTES_RELAY_LIMIT = 500
type GifFetchQueryOpts = {
eoseTimeout: number
globalTimeout: number
firstRelayResultGraceMs: false
foreground?: true
backgroundInterruptImmune?: true
}
/** /**
* High `limit` values otherwise trigger implicit feed first-relay grace (~2s) and return before * 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 * GIF-heavy relays finish. Picker loads must also be foreground so navigation does not abort them.
* does not abort them via {@link QueryService.interruptBackgroundQueries}.
*/ */
const GIF_FETCH_OPTS = { const GIF_FETCH_OPTS: GifFetchQueryOpts = {
eoseTimeout: 20_000, eoseTimeout: 20_000,
globalTimeout: 28_000, globalTimeout: 28_000,
firstRelayResultGraceMs: false as const, firstRelayResultGraceMs: false,
foreground: true as const foreground: true
} }
/** Session-start cache preload — low priority but must finish (not aborted on navigation). */ /** Session-start cache preload — low priority but must finish (not aborted on navigation). */
const GIF_PRELOAD_FETCH_OPTS = { const GIF_PRELOAD_FETCH_OPTS: GifFetchQueryOpts = {
eoseTimeout: 18_000, eoseTimeout: 18_000,
globalTimeout: 26_000, globalTimeout: 26_000,
firstRelayResultGraceMs: false as const, firstRelayResultGraceMs: false,
backgroundInterruptImmune: true as const backgroundInterruptImmune: true
} }
let gifOutboxPreloadInFlight: Promise<void> | null = null let gifOutboxPreloadInFlight: Promise<void> | null = null
/** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */ /** Kind 1063 read/write targets — {@link GIF_RELAY_URLS} only. */
const THECITADEL_FOR_GIF_METADATA = export function getGif1063RelayUrls(): string[] {
normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' return dedupeRelayUrls(GIF_RELAY_URLS)
}
function dedupeRelayUrls(urls: readonly string[]): string[] { function dedupeRelayUrls(urls: readonly string[]): string[] {
const seen = new Set<string>() const seen = new Set<string>()
@ -322,6 +370,14 @@ function dedupeRelayUrls(urls: readonly string[]): string[] {
}) })
} }
function chunkPubkeys(pubkeys: readonly string[], size: number): string[][] {
const chunks: string[][] = []
for (let i = 0; i < pubkeys.length; i += size) {
chunks.push(pubkeys.slice(i, i + size))
}
return chunks
}
function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetadata { function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetadata {
return { return {
...g, ...g,
@ -330,6 +386,139 @@ function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetada
} }
} }
function mergeGifEventsIntoPool(
events: readonly NEvent[],
pool: Map<string, GifMetadata>,
searchQuery?: string
): 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 existing = pool.get(key)
if (!existing || shouldPreferGif(gif, existing)) {
pool.set(key, gif)
}
}
}
async function fetch1063Paginated(
relays: readonly string[],
opts: GifFetchQueryOpts,
filterBase: Omit<Filter, 'limit' | 'until'>,
maxEvents?: number
): Promise<NEvent[]> {
const out: NEvent[] = []
const seen = new Set<string>()
let until: number | undefined
for (let page = 0; page < GIF_AUTHOR_1063_MAX_PAGES; page++) {
const filter: Filter = {
...filterBase,
kinds: [ExtendedKind.FILE_METADATA],
limit: GIF_1063_PAGE_LIMIT,
...(until != null ? { until } : {})
}
const batch = await queryService.fetchEvents([...relays], filter, opts)
if (batch.length === 0) break
let oldest = until ?? Number.MAX_SAFE_INTEGER
for (const event of batch) {
if (seen.has(event.id)) continue
seen.add(event.id)
out.push(event)
if (event.created_at < oldest) oldest = event.created_at
if (maxEvents != null && out.length >= maxEvents) return out
}
if (batch.length < GIF_1063_PAGE_LIMIT) break
if (oldest <= 1) break
until = oldest - 1
}
return out
}
function gifNoteFallbackRelays(extraReadRelayUrls: readonly string[]): string[] {
return dedupeRelayUrls([...GIF_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...extraReadRelayUrls])
}
export type LoadGifPoolOptions = {
userPubkey: string | null
followingPubkeys?: readonly string[]
/** Viewer read inboxes — used only for kind 1 / 1111 fallback when the 1063 pool is tiny. */
noteFallbackRelays?: readonly string[]
fetchOpts?: GifFetchQueryOpts
}
/** Load picker pool: all viewer 1063 → all follows' 1063 → up to 500 other 1063; optional note scrape. */
export async function loadGifPoolFromRelays(options: LoadGifPoolOptions): Promise<GifMetadata[]> {
const {
userPubkey,
followingPubkeys = [],
noteFallbackRelays = [],
fetchOpts = GIF_FETCH_OPTS
} = options
const relays1063 = getGif1063RelayUrls()
const noteRelays =
noteFallbackRelays.length > 0 ? gifNoteFallbackRelays(noteFallbackRelays) : []
const revokeScope = grantRelayConnectionOperationScope([...relays1063, ...noteRelays])
try {
const pool = new Map<string, GifMetadata>()
const userKey = userPubkey?.toLowerCase() ?? ''
const followAuthors = followingPubkeys.filter((p) => p && p.toLowerCase() !== userKey)
const knownAuthors = new Set<string>()
if (userKey) knownAuthors.add(userKey)
for (const pk of followAuthors) knownAuthors.add(pk.toLowerCase())
if (userPubkey) {
const ownEvents = await fetch1063Paginated(relays1063, fetchOpts, { authors: [userPubkey] })
mergeGifEventsIntoPool(ownEvents, pool)
}
for (const chunk of chunkPubkeys(followAuthors, METADATA_BATCH_AUTHORS_CHUNK)) {
const followEvents = await fetch1063Paginated(relays1063, fetchOpts, { authors: chunk })
mergeGifEventsIntoPool(followEvents, pool)
}
const globalEvents = await fetch1063Paginated(
relays1063,
fetchOpts,
{},
GIF_OTHERS_1063_MAX + pool.size + 200
)
let othersAdded = 0
for (const event of globalEvents) {
if (knownAuthors.has(event.pubkey.toLowerCase())) continue
const gif = parseGifFromEvent(event)
if (!gif) continue
const key = normalizeGifUrl(gif.url)
if (pool.has(key)) continue
pool.set(key, gif)
othersAdded++
if (othersAdded >= GIF_OTHERS_1063_MAX) break
}
if (pool.size < GIF_NOTES_FALLBACK_THRESHOLD) {
const noteEvents = await queryService.fetchEvents(
gifNoteFallbackRelays(noteFallbackRelays),
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: GIF_NOTES_RELAY_LIMIT
},
fetchOpts
)
mergeGifEventsIntoPool(noteEvents, pool)
}
return sortGifsForPicker(dedupeGifsByUrl([...pool.values()]), userPubkey, followingPubkeys)
} finally {
revokeScope()
}
}
async function persistGifPool(gifs: GifMetadata[]): Promise<void> {
if (gifs.length === 0) return
await indexedDb.setGifCache(gifs, Date.now())
}
export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boolean { export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boolean {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (!q) return true if (!q) return true
@ -347,94 +536,87 @@ export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boole
return haystack.includes(q) return haystack.includes(q)
} }
/** Full IndexedDB GIF pool for local search (up to {@link GIF_CACHE_CAP}). */ /** Full IndexedDB GIF pool for local search and display. */
export async function getAllCachedGifsForSearch( export async function getAllCachedGifsForSearch(
userPubkey: string | null = null userPubkey: string | null = null,
followingPubkeys: readonly string[] = []
): Promise<GifMetadata[]> { ): Promise<GifMetadata[]> {
try { try {
const cached = await indexedDb.getGifCache() const cached = await indexedDb.getGifCache()
if (!cached?.gifs?.length) return [] if (!cached?.gifs?.length) return []
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey) return sortGifsForPicker(
dedupeGifsByUrl(normalized),
userPubkey,
followingPubkeys
)
} catch { } catch {
return [] return []
} }
} }
function mergeGifEventsIntoMap( export type FetchGifsOptions = {
events: readonly NEvent[], forceRefresh?: boolean
byUrl: Map<string, { gif: GifMetadata; priority: number }>,
searchQuery: string | undefined,
userPubkey: string | null userPubkey: string | null
): void { followingPubkeys?: readonly string[]
for (const event of events) { noteFallbackRelays?: readonly string[]
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 })
} }
/**
* Fetch the full GIF picker pool from relays and persist to IndexedDB.
* Order: all viewer kind 1063 all follows' kind 1063 up to 500 other kind 1063;
* if still < 50 entries, adds GIFs parsed from kind 1 / 1111.
*/
export async function fetchGifs(options: FetchGifsOptions): Promise<GifMetadata[]> {
const {
forceRefresh = false,
userPubkey,
followingPubkeys = [],
noteFallbackRelays = []
} = options
if (!forceRefresh) {
const cached = await indexedDb.getGifCache()
if (
cached &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey, followingPubkeys)
} }
} }
export async function mergeGifsIntoIdbCache(incoming: GifMetadata[]): Promise<void> { let staleFallback: GifMetadata[] | null = null
if (incoming.length === 0) return
const row = await indexedDb.getGifCache() const row = await indexedDb.getGifCache()
const byKey = new Map<string, GifMetadata>() if (row?.gifs?.length) {
for (const g of row?.gifs ?? []) { staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
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( try {
events1063: readonly NEvent[], const pool = await loadGifPoolFromRelays({
eventsNotes: readonly NEvent[], userPubkey,
userPubkey: string | null followingPubkeys,
): GifMetadata[] { noteFallbackRelays,
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>() fetchOpts: GIF_FETCH_OPTS
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey) })
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey) if (pool.length > 0) {
return sortGifsForPicker( await persistGifPool(pool)
Array.from(byUrl.values()).map((v) => v.gif), }
userPubkey return pool
) } catch (err) {
if (staleFallback?.length) {
return sortGifsForPicker(staleFallback, userPubkey, followingPubkeys)
}
throw err
}
} }
/** /** Background session preload into IndexedDB using the tiered 1063 fetch. */
* 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( export async function preloadGifsIntoIdbCache(
userPubkey: string | null, userPubkey: string | null,
extraReadRelayUrls: readonly string[] = [] followingPubkeys: readonly string[] = [],
noteFallbackRelays: readonly string[] = []
): Promise<void> { ): Promise<void> {
const cached = await indexedDb.getGifCache() const cached = await indexedDb.getGifCache()
if ( if (
@ -451,27 +633,14 @@ export async function preloadGifsIntoIdbCache(
} }
gifOutboxPreloadInFlight = (async () => { gifOutboxPreloadInFlight = (async () => {
const { relays1063, relaysNotes } = gifRelayUrlsForFetch(extraReadRelayUrls) const pool = await loadGifPoolFromRelays({
userPubkey,
const [events1063, eventsNotes] = await Promise.all([ followingPubkeys,
queryService.fetchEvents( noteFallbackRelays,
relays1063, fetchOpts: GIF_PRELOAD_FETCH_OPTS
{ kinds: [ExtendedKind.FILE_METADATA], limit: 400 }, })
GIF_PRELOAD_FETCH_OPTS if (pool.length > 0) {
), await persistGifPool(pool)
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)
} }
})() })()
@ -484,115 +653,37 @@ export async function preloadGifsIntoIdbCache(
/** @deprecated Use {@link preloadGifsIntoIdbCache}. Kept for call-site compatibility. */ /** @deprecated Use {@link preloadGifsIntoIdbCache}. Kept for call-site compatibility. */
export async function preloadGifsFromUserOutboxes( export async function preloadGifsFromUserOutboxes(
outboxRelayUrls: readonly string[], _outboxRelayUrls: readonly string[],
userPubkey: string | null, userPubkey: string | null,
_signal?: AbortSignal _signal?: AbortSignal
): Promise<void> { ): Promise<void> {
return preloadGifsIntoIdbCache(userPubkey, outboxRelayUrls) return preloadGifsIntoIdbCache(userPubkey, [])
}
/**
* Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays.
* Deduplicates by normalized URL; when the same GIF appears from multiple sources,
* keeps: 1) user's own events, 2) other users' events, 3) non-event sources.
* @param extraReadRelayUrls - Logged-in user's read relays (inboxes) and local relays to include when fetching.
* @param userPubkey - Current user's pubkey; entries from this pubkey get highest priority when deduping.
*/
export async function fetchGifs(
limit: number = 50,
forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> {
if (!forceRefresh) {
const cached = await indexedDb.getGifCache()
if (
cached &&
cached.gifs.length >= MIN_GIF_CACHE_ENTRIES &&
Date.now() - cached.cachedAt < CACHE_MAX_AGE_MS
) {
const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
return sortGifsForPicker(normalized, userPubkey).slice(0, limit)
}
}
let staleFallback: GifMetadata[] | null = null
const row = await indexedDb.getGifCache()
if (row?.gifs?.length) {
staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata))
}
const limit1063 = Math.max(limit * 15, 400)
const limitNotes = Math.max(limit * 15, 500)
const { relays1063, relaysNotes: dedupedUrls } = gifRelayUrlsForFetch(extraReadRelayUrls)
let events1063: NEvent[] = []
let eventsNotes: NEvent[] = []
try {
;[events1063, eventsNotes] = await Promise.all([
queryService.fetchEvents(
relays1063,
{ kinds: [ExtendedKind.FILE_METADATA], limit: limit1063 },
GIF_FETCH_OPTS
),
queryService.fetchEvents(
dedupedUrls,
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: limitNotes
},
GIF_FETCH_OPTS
)
])
} catch (err) {
if (staleFallback?.length) {
return sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
}
throw err
}
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>()
mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey)
mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey)
const allGifs = sortGifsForPicker(
Array.from(byUrl.values()).map((v) => v.gif),
userPubkey
)
let result = allGifs.slice(0, limit)
if (result.length === 0 && staleFallback?.length) {
result = sortGifsForPicker(staleFallback, userPubkey).slice(0, limit)
}
if (allGifs.length > 0) {
await mergeGifsIntoIdbCache(allGifs.slice(0, GIF_CACHE_CAP))
}
return result
} }
/** /**
* Return whatever is currently in the IndexedDB GIF cache without fetching from relays. * Return whatever is currently in the IndexedDB GIF cache without fetching from relays.
* Used to seed the picker immediately on open; the caller can then trigger a background refresh.
*/ */
export async function getCachedGifs(userPubkey: string | null = null): Promise<GifMetadata[]> { export async function getCachedGifs(
const all = await getAllCachedGifsForSearch(userPubkey) userPubkey: string | null = null,
return all.slice(0, 50) followingPubkeys: readonly string[] = []
): Promise<GifMetadata[]> {
return getAllCachedGifsForSearch(userPubkey, followingPubkeys)
} }
/** Instant local search over the IndexedDB GIF cache (no relay round-trip). */ /** 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, userPubkey: string | null = null,
_forceRefresh: boolean = false, followingPubkeys: readonly string[] = []
_extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> { ): Promise<GifMetadata[]> {
const q = query.trim() const q = query.trim()
if (!q) return getCachedGifs(userPubkey) const pool = await getAllCachedGifsForSearch(userPubkey, followingPubkeys)
const pool = await getAllCachedGifsForSearch(userPubkey) if (!q) return pool
return pool.filter((g) => gifMetadataMatchesSearch(g, q)).slice(0, limit) return pool.filter((g) => gifMetadataMatchesSearch(g, q))
}
/** @deprecated Merges by URL with a hard cap; prefer {@link persistGifPool}. */
export async function mergeGifsIntoIdbCache(incoming: GifMetadata[]): Promise<void> {
if (incoming.length === 0) return
await indexedDb.setGifCache(incoming, Date.now())
} }

Loading…
Cancel
Save