diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 3829ec16..31dce471 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls' +import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls' 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 { normalizeUrl } from '@/lib/url' import { fetchGifs, getAllCachedGifsForSearch, + getGif1063RelayUrls, gifMetadataMatchesSearch, gifShouldOfferNip94Archive, buildKind1063GifPublishDraft, @@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service' import { Download, ExternalLink, X } from 'lucide-react' import { kinds } from 'nostr-tools' 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. */ let _sessionGifs: GifMetadata[] = [] import { useTranslation } from 'react-i18next' 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). */ const GIFBUDDY_SEARCH_URL = (q: string) => q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL @@ -51,6 +54,12 @@ export default function GifPicker({ const { t } = useTranslation() const { isSmallScreen } = useScreenSize() 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 [searchInput, setSearchInput] = useState('') // Initialise from the module-level session cache so re-opens are instant @@ -71,36 +80,9 @@ export default function GifPicker({ const gifbuddyPopupRef = useRef(null) const userReadRelays = useUserReadInboxUrls() - const userWriteRelays = useUserWriteOutboxUrls() - - /** Paste / upload: GIF discovery relays + user writes (unchanged). */ - const gifPublishRelayUrls = useMemo(() => { - const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays] - const seen = new Set() - 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() - 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]) + /** Kind 1063 publish targets — GIF relays only. */ + const gif1063PublishRelayUrls = useMemo(() => getGif1063RelayUrls(), []) /** Keep gifsRef, session cache, and React state in sync. */ const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => { @@ -116,25 +98,27 @@ export default function GifPicker({ const pool = gifPoolRef.current const filtered = trimmed ? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed)) - : pool.slice(0, 50) + : pool setGifs(filtered, trimmed.length > 0) }, [setGifs] ) const refreshGifPoolFromIdb = useCallback(async () => { - const pool = await getAllCachedGifsForSearch(pubkey ?? null) + const pool = await getAllCachedGifsForSearch(pubkey ?? null, followingPubkeys) gifPoolRef.current = pool return pool - }, [pubkey]) + }, [pubkey, followingPubkeys]) const loadGifs = useCallback( async (forceRefresh = false) => { + const generation = ++loadGenerationRef.current setError(null) if (gifPoolRef.current.length === 0) { try { const cached = await refreshGifPoolFromIdb() + if (generation !== loadGenerationRef.current) return if (cached.length > 0) { applyLocalFilter(searchInputRef.current) } @@ -145,10 +129,17 @@ export default function GifPicker({ } 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() + if (generation !== loadGenerationRef.current) return applyLocalFilter(searchInputRef.current) - if (gifPoolRef.current.length === 0 && !searchInput.trim()) { + if (gifPoolRef.current.length === 0 && !searchInputRef.current.trim()) { setError( t( '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) { + if (generation !== loadGenerationRef.current) return setError(e instanceof Error ? e.message : 'Failed to load GIFs') if (gifPoolRef.current.length === 0) setGifsState([]) } finally { - setLoading(false) + if (generation === loadGenerationRef.current) setLoading(false) } }, - [t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb] + [t, userReadRelays, pubkey, followingPubkeys, applyLocalFilter, refreshGifPoolFromIdb] ) useEffect(() => { @@ -185,11 +177,11 @@ export default function GifPicker({ if (!pubkey || !/^https?:\/\//i.test(url)) return // Fire-and-forget: waiting on every relay can freeze the UI when relays are down. void publish(buildKind1063GifPublishDraft(url, desc), { - specifiedRelayUrls: gifSelectPublishRelayUrls + specifiedRelayUrls: gif1063PublishRelayUrls }).catch(() => {}) if (desc) setPublishDescription('') }, - [pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription] + [pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription] ) const handleUpload = async (e: React.ChangeEvent) => { @@ -217,7 +209,7 @@ export default function GifPicker({ tags, created_at: Math.floor(Date.now() / 1000) } - await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) + await publish(draft, { specifiedRelayUrls: gif1063PublishRelayUrls }) setPublishDescription('') setSearchInput('') await loadGifs(true) @@ -273,7 +265,7 @@ export default function GifPicker({ setPublishingPaste(true) try { await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), { - specifiedRelayUrls: gifPublishRelayUrls + specifiedRelayUrls: gif1063PublishRelayUrls }) setPublishDescription('') } catch { @@ -282,7 +274,7 @@ export default function GifPicker({ 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). */ const handleArchiveAndInsert = useCallback( @@ -298,7 +290,7 @@ export default function GifPicker({ setOpen(false) void loadGifs(true) void publish(buildKind1063GifPublishDraft(url, desc), { - specifiedRelayUrls: gifSelectPublishRelayUrls + specifiedRelayUrls: gif1063PublishRelayUrls }) .catch(() => {}) .finally(() => { @@ -306,7 +298,7 @@ export default function GifPicker({ if (desc) setPublishDescription('') }) }, - [pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, publishDescription] + [pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription] ) const gifSourceKindTitle = useCallback( @@ -372,8 +364,8 @@ export default function GifPicker({ {loading ? ( diff --git a/src/constants.ts b/src/constants.ts index 35e9caf1..b16cf926 100644 --- a/src/constants.ts +++ b/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. */ export const GIF_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', - 'wss://gifbuddy.lol' + 'wss://relay.gifbuddy.lol' ] export const SEARCHABLE_RELAY_URLS = [ diff --git a/src/lib/relay-auth-feedback.ts b/src/lib/relay-auth-feedback.ts index b9bcadcb..356dc275 100644 --- a/src/lib/relay-auth-feedback.ts +++ b/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 { const key = sessionKeyForRelay(url) if (!key || nip42NotifiedAccept.has(key)) return 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 }) } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 21649b4e..b661ba55 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -654,16 +654,15 @@ class ClientService extends EventTarget { /** {@link runSessionPrewarm} — background fetch into GIF IndexedDB cache. */ private async runGifCachePreload(pubkey: string | null): Promise { - const extra: string[] = [] + let followings: string[] = [] + let noteFallbackRelays: string[] = [] if (pubkey) { + const ev = await this.fetchFollowListEvent(pubkey) + if (ev) followings = getPubkeysFromPTags(ev.tags) const rl = await this.peekRelayListFromStorage(pubkey) - const [readInboxes, writeOutboxes] = await Promise.all([ - collectViewerReadInboxUrls(pubkey, rl), - collectViewerWriteOutboxUrls(pubkey, rl) - ]) - extra.push(...readInboxes, ...writeOutboxes) + noteFallbackRelays = await collectViewerReadInboxUrls(pubkey, rl) } - 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. */ diff --git a/src/services/gif.service.test.ts b/src/services/gif.service.test.ts new file mode 100644 index 00000000..e637b1f1 --- /dev/null +++ b/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]) + }) +}) diff --git a/src/services/gif.service.ts b/src/services/gif.service.ts index 1389d85b..862aa2b4 100644 --- a/src/services/gif.service.ts +++ b/src/services/gif.service.ts @@ -6,12 +6,13 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, - GIF_RELAY_URLS + GIF_RELAY_URLS, + METADATA_BATCH_AUTHORS_CHUNK } from '@/constants' import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match' +import { grantRelayConnectionOperationScope } from '@/lib/read-only-relay-personal' import { normalizeUrl } from '@/lib/url' -import { kinds } from 'nostr-tools' -import type { Event as NEvent } from 'nostr-tools' +import { kinds, type Event as NEvent, type Filter } from 'nostr-tools' import { queryService } from './client.service' import indexedDb from './indexed-db.service' @@ -52,15 +53,24 @@ export function gifShouldOfferNip94Archive(gif: GifMetadata): boolean { return true } -/** Own GIFs/memes first, then newest first (picker grids). */ -export function sortGifsForPicker(gifs: GifMetadata[], userPubkey: string | null): GifMetadata[] { +/** Own GIFs, then follows, then others — newest first within each tier. */ +export function sortGifsForPicker( + gifs: GifMetadata[], + userPubkey: string | null, + followingPubkeys: readonly string[] = [] +): GifMetadata[] { const u = userPubkey?.toLowerCase() ?? '' + const followSet = new Set(followingPubkeys.map((p) => p.toLowerCase()).filter(Boolean)) + const tier = (g: GifMetadata): number => { + const pk = g.pubkey.toLowerCase() + if (u && pk === u) return 0 + if (followSet.has(pk)) return 1 + return 2 + } return [...gifs].sort((a, b) => { - if (u) { - const aOwn = a.pubkey.toLowerCase() === u ? 1 : 0 - const bOwn = b.pubkey.toLowerCase() === u ? 1 : 0 - if (aOwn !== bOwn) return bOwn - aOwn - } + const ta = tier(a) + const tb = tier(b) + if (ta !== tb) return ta - tb 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. */ -const GIF_PRIORITY = { - OWN_EVENT: 2, - OTHER_EVENT: 1, - NON_EVENT: 0 -} as const +/** Higher wins when the same GIF URL appears from multiple Nostr events. */ +function gifSourceKindPriority(sourceKind: number): number { + if (sourceKind === ExtendedKind.FILE_METADATA) return 3 + if (sourceKind === ExtendedKind.COMMENT) return 2 + if (sourceKind === kinds.ShortTextNote) return 1 + 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() + 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). */ 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 MIN_GIF_CACHE_ENTRIES = 8 -const GIF_CACHE_CAP = 80 +const CACHE_MAX_AGE_MS = 60 * 60 * 1000 // 1 hour +const MIN_GIF_CACHE_ENTRIES = 1 +/** 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 - * GIF-heavy relays (e.g. thecitadel) finish. Picker loads must also be foreground so navigation - * does not abort them via {@link QueryService.interruptBackgroundQueries}. + * GIF-heavy relays finish. Picker loads must also be foreground so navigation does not abort them. */ -const GIF_FETCH_OPTS = { +const GIF_FETCH_OPTS: GifFetchQueryOpts = { eoseTimeout: 20_000, globalTimeout: 28_000, - firstRelayResultGraceMs: false as const, - foreground: true as const + firstRelayResultGraceMs: false, + foreground: true } /** 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, globalTimeout: 26_000, - firstRelayResultGraceMs: false as const, - backgroundInterruptImmune: true as const + firstRelayResultGraceMs: false, + backgroundInterruptImmune: true } let gifOutboxPreloadInFlight: Promise | null = null -/** Ensured on the kind 1063 (NIP-94) relay set even if absent from merged read lists. */ -const THECITADEL_FOR_GIF_METADATA = - normalizeUrl('wss://thecitadel.nostr1.com') || 'wss://thecitadel.nostr1.com' +/** Kind 1063 read/write targets — {@link GIF_RELAY_URLS} only. */ +export function getGif1063RelayUrls(): string[] { + return dedupeRelayUrls(GIF_RELAY_URLS) +} function dedupeRelayUrls(urls: readonly string[]): string[] { const seen = new Set() @@ -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 { return { ...g, @@ -330,6 +386,139 @@ function normalizeCachedGif(g: GifMetadata & { sourceKind?: number }): GifMetada } } +function mergeGifEventsIntoPool( + events: readonly NEvent[], + pool: Map, + 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, + maxEvents?: number +): Promise { + const out: NEvent[] = [] + const seen = new Set() + 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 { + 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() + const userKey = userPubkey?.toLowerCase() ?? '' + const followAuthors = followingPubkeys.filter((p) => p && p.toLowerCase() !== userKey) + const knownAuthors = new Set() + 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 { + if (gifs.length === 0) return + await indexedDb.setGifCache(gifs, Date.now()) +} + export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boolean { const q = query.trim().toLowerCase() if (!q) return true @@ -347,94 +536,87 @@ export function gifMetadataMatchesSearch(gif: GifMetadata, query: string): boole 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( - userPubkey: string | null = null + userPubkey: string | null = null, + followingPubkeys: readonly string[] = [] ): Promise { try { const cached = await indexedDb.getGifCache() if (!cached?.gifs?.length) return [] const normalized = cached.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) - return sortGifsForPicker(normalized, userPubkey) + return sortGifsForPicker( + dedupeGifsByUrl(normalized), + userPubkey, + followingPubkeys + ) } catch { return [] } } -function mergeGifEventsIntoMap( - events: readonly NEvent[], - byUrl: Map, - searchQuery: string | undefined, +export type FetchGifsOptions = { + forceRefresh?: boolean userPubkey: string | null -): void { - for (const event of events) { - const gif = parseGifFromEvent(event) - if (!gif) continue - if (searchQuery && !eventMatchesNip50LocalFullTextQuery(event, searchQuery)) continue + followingPubkeys?: readonly string[] + noteFallbackRelays?: readonly string[] +} - 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 { + 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 { - if (incoming.length === 0) return + let staleFallback: GifMetadata[] | null = null const row = await indexedDb.getGifCache() - const byKey = new Map() - for (const g of row?.gifs ?? []) { - const meta = normalizeCachedGif(g as GifMetadata) - if (meta.url) byKey.set(normalizeGifUrl(meta.url), meta) + if (row?.gifs?.length) { + staleFallback = row.gifs.map((g) => normalizeCachedGif(g as GifMetadata)) } - for (const g of incoming) { - if (g.url) byKey.set(normalizeGifUrl(g.url), g) + + try { + const pool = await loadGifPoolFromRelays({ + userPubkey, + followingPubkeys, + noteFallbackRelays, + fetchOpts: GIF_FETCH_OPTS + }) + if (pool.length > 0) { + await persistGifPool(pool) + } + return pool + } catch (err) { + if (staleFallback?.length) { + return sortGifsForPicker(staleFallback, userPubkey, followingPubkeys) + } + throw err } - const merged = [...byKey.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, GIF_CACHE_CAP) - await indexedDb.setGifCache(merged, Date.now()) -} - -function gifRelayUrlsForFetch(extraReadRelayUrls: readonly string[]): { - relays1063: string[] - relaysNotes: string[] -} { - const dedupedUrls = dedupeRelayUrls([ - ...GIF_RELAY_URLS, - ...FAST_READ_RELAY_URLS, - ...extraReadRelayUrls - ]) - const relays1063 = dedupedUrls.some( - (u) => (normalizeUrl(u) || u).toLowerCase() === THECITADEL_FOR_GIF_METADATA.toLowerCase() - ) - ? dedupedUrls - : [...dedupedUrls, THECITADEL_FOR_GIF_METADATA] - return { relays1063, relaysNotes: dedupedUrls } -} - -function gifsFromEvents( - events1063: readonly NEvent[], - eventsNotes: readonly NEvent[], - userPubkey: string | null -): GifMetadata[] { - const byUrl = new Map() - mergeGifEventsIntoMap(events1063, byUrl, undefined, userPubkey) - mergeGifEventsIntoMap(eventsNotes, byUrl, undefined, userPubkey) - return sortGifsForPicker( - Array.from(byUrl.values()).map((v) => v.gif), - userPubkey - ) } -/** - * Background session preload into IndexedDB: kind 1063 on GIF relays + thecitadel, kind 1/1111 on the - * broad list, merged with the viewer's inboxes/outboxes when provided. - */ +/** Background session preload into IndexedDB using the tiered 1063 fetch. */ export async function preloadGifsIntoIdbCache( userPubkey: string | null, - extraReadRelayUrls: readonly string[] = [] + followingPubkeys: readonly string[] = [], + noteFallbackRelays: readonly string[] = [] ): Promise { const cached = await indexedDb.getGifCache() if ( @@ -451,27 +633,14 @@ export async function preloadGifsIntoIdbCache( } 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) + const pool = await loadGifPoolFromRelays({ + userPubkey, + followingPubkeys, + noteFallbackRelays, + fetchOpts: GIF_PRELOAD_FETCH_OPTS + }) + if (pool.length > 0) { + await persistGifPool(pool) } })() @@ -484,115 +653,37 @@ export async function preloadGifsIntoIdbCache( /** @deprecated Use {@link preloadGifsIntoIdbCache}. Kept for call-site compatibility. */ export async function preloadGifsFromUserOutboxes( - outboxRelayUrls: readonly string[], + _outboxRelayUrls: readonly string[], userPubkey: string | null, _signal?: AbortSignal ): Promise { - return preloadGifsIntoIdbCache(userPubkey, outboxRelayUrls) -} - -/** - * Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays. - * Deduplicates by normalized URL; when the same GIF appears from multiple sources, - * 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 { - 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() - 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 preloadGifsIntoIdbCache(userPubkey, []) } /** * 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 { - const all = await getAllCachedGifsForSearch(userPubkey) - return all.slice(0, 50) +export async function getCachedGifs( + userPubkey: string | null = null, + followingPubkeys: readonly string[] = [] +): Promise { + return getAllCachedGifsForSearch(userPubkey, followingPubkeys) } /** Instant local search over the IndexedDB GIF cache (no relay round-trip). */ export async function searchGifs( query: string, - limit: number = 50, - _forceRefresh: boolean = false, - _extraReadRelayUrls: string[] = [], - userPubkey: string | null = null + userPubkey: string | null = null, + followingPubkeys: readonly string[] = [] ): Promise { const q = query.trim() - if (!q) return getCachedGifs(userPubkey) - const pool = await getAllCachedGifsForSearch(userPubkey) - return pool.filter((g) => gifMetadataMatchesSearch(g, q)).slice(0, limit) + const pool = await getAllCachedGifsForSearch(userPubkey, followingPubkeys) + if (!q) return pool + 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 { + if (incoming.length === 0) return + await indexedDb.setGifCache(incoming, Date.now()) }