import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer' import { Input } from '@/components/ui/input' 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 { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { cn } from '@/lib/utils' import { normalizeUrl } from '@/lib/url' import { fetchGifs, getCachedGifs, searchGifs, gifShouldOfferNip94Archive, type GifMetadata } from '@/services/gif.service' 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' /** 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/' /** 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 export default function GifPicker({ children, onSelect, portalContainer }: { children: React.ReactNode onSelect?: (gifUrl: string) => void /** When set (e.g. inside a modal), picker content portals here so it stays on top of the modal */ portalContainer?: HTMLElement | null }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() const { publish, pubkey, relayList } = useNostr() const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') // Initialise from the module-level session cache so re-opens are instant const [gifs, setGifsState] = useState(() => _sessionGifs) const gifsRef = useRef(_sessionGifs) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [uploading, setUploading] = useState(false) const [uploadError, setUploadError] = useState(null) const [pasteUrl, setPasteUrl] = useState('') const [publishingPaste, setPublishingPaste] = useState(false) const [archivingEventId, setArchivingEventId] = useState(null) const [publishDescription, setPublishDescription] = useState('') const searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) const gifbuddyPopupRef = useRef(null) const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userWriteRelays = relayList?.write ?? [] /** 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]) /** Keep gifsRef, session cache, and React state in sync. */ const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => { gifsRef.current = newGifs if (!isSearch) _sessionGifs = newGifs setGifsState(newGifs) }, []) const loadGifs = useCallback(async (q: string, forceRefresh = false) => { setError(null) const isSearch = q.trim() !== '' // For a search or a forced refresh with no data: clear and show skeleton immediately. if (isSearch) { gifsRef.current = [] setGifsState([]) setLoading(true) } else if (gifsRef.current.length === 0) { // No data yet — try the IDB cache first so we can show something instantly. try { const cached = await getCachedGifs(pubkey ?? null) if (cached.length > 0) { setGifs(cached) } } catch { /* ignore */ } // If still empty after the cache read, show the skeleton while we wait for relays. if (gifsRef.current.length === 0) setLoading(true) } // If we already have data (session cache or IDB seed above): no skeleton — // results will update silently when the relay fetch completes. try { const results = isSearch ? await searchGifs(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) : await fetchGifs(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) setGifs(results, isSearch) if (results.length === 0 && !isSearch) { setError( t( 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.' ) ) } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load GIFs') if (gifsRef.current.length === 0) setGifsState([]) } finally { setLoading(false) } }, [t, userReadRelays, pubkey, setGifs]) useEffect(() => { if (!open) return loadGifs(query) }, [open, query, loadGifs]) useEffect(() => { if (!open) return if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) searchTimeoutRef.current = setTimeout(() => { setQuery(searchInput) }, 300) return () => { if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) } }, [searchInput, open]) const handleSelect = useCallback( (gif: GifMetadata) => { const url = (gif.fallbackUrl?.trim() || gif.url).trim() if (!url) return onSelect?.(url) setOpen(false) if (!pubkey || !/^https?:\/\//i.test(url)) return // Fire-and-forget: waiting on every relay can freeze the UI when relays are down. void publish( { kind: ExtendedKind.FILE_METADATA, content: '', tags: [ ['url', url], ['m', 'image/gif'], ['t', 'gif'] ], created_at: Math.floor(Date.now() / 1000) }, { specifiedRelayUrls: gifSelectPublishRelayUrls } ).catch(() => {}) }, [pubkey, onSelect, publish, gifSelectPublishRelayUrls] ) const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file || !pubkey) return setUploadError(null) setUploading(true) try { if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) { setUploadError(t('{{name}} is not a GIF file', { name: file.name })) return } const { url } = await mediaUpload.upload(file) const draft = { kind: ExtendedKind.FILE_METADATA, content: publishDescription.trim(), tags: [ ['file', url, file.type || 'image/gif', `size ${file.size}`], ['url', url], ['m', file.type || 'image/gif'], ['t', 'gif'] ], created_at: Math.floor(Date.now() / 1000) } await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) setPublishDescription('') setQuery('') await loadGifs('', true) } catch (err) { setUploadError(err instanceof Error ? err.message : 'Upload failed') } finally { setUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' } } const triggerFileUpload = () => fileInputRef.current?.click() const isLoggedIn = !!pubkey /** Open GifBuddy in a new tab (not a popup) so the picker doesn't close from focus loss. Listen for postMessage in case GifBuddy adds embed support. */ const openGifBuddySearch = useCallback(() => { const url = GIFBUDDY_SEARCH_URL(searchInput) const w = window.open(url, '_blank', 'noopener,noreferrer') gifbuddyPopupRef.current = w ?? null const handler = (event: MessageEvent) => { if (event.origin !== 'https://www.gifbuddy.lol' && event.origin !== 'https://gifbuddy.lol') return const data = event.data const urlToInsert = typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://')) ? data : data?.url ?? data?.gifUrl if (urlToInsert && typeof urlToInsert === 'string') { window.removeEventListener('message', handler) gifbuddyPopupRef.current = null onSelect?.(urlToInsert) setOpen(false) } } window.addEventListener('message', handler) const t = setTimeout(() => { window.removeEventListener('message', handler) gifbuddyPopupRef.current = null }, 10 * 60 * 1000) if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) }) }, [searchInput, onSelect]) const descriptionForPublish = publishDescription.trim() /** Insert pasted GIF URL and publish kind 1063 so it's added to Nostr GIF library. */ const handlePasteUrlInsert = useCallback(async () => { const url = pasteUrl.trim() if (!url || !/^https?:\/\//i.test(url)) return onSelect?.(url) setPasteUrl('') setOpen(false) if (pubkey) { setPublishingPaste(true) try { const draft = { kind: ExtendedKind.FILE_METADATA, content: descriptionForPublish, tags: [ ['url', url], ['m', 'image/gif'], ['t', 'gif'] ], created_at: Math.floor(Date.now() / 1000) } await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls }) setPublishDescription('') } catch { // ignore; URL was still inserted } finally { setPublishingPaste(false) } } }, [pasteUrl, pubkey, onSelect, publish, gifPublishRelayUrls, descriptionForPublish]) /** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */ const handleArchiveAndInsert = useCallback( (e: React.MouseEvent, gif: GifMetadata) => { e.preventDefault() e.stopPropagation() if (!pubkey) return const url = (gif.fallbackUrl?.trim() || gif.url).trim() if (!url || !/^https?:\/\//i.test(url)) return setArchivingEventId(gif.eventId) onSelect?.(url) setOpen(false) void loadGifs(query, true) void publish( { kind: ExtendedKind.FILE_METADATA, content: '', tags: [ ['url', url], ['m', 'image/gif'], ['t', 'gif'] ], created_at: Math.floor(Date.now() / 1000) }, { specifiedRelayUrls: gifSelectPublishRelayUrls } ) .catch(() => {}) .finally(() => setArchivingEventId(null)) }, [pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, query] ) const gifSourceKindTitle = useCallback( (gif: GifMetadata) => { if (gif.sourceKind === ExtendedKind.FILE_METADATA) { return t( 'This GIF comes from kind 1063 (NIP-94 file metadata). Choosing it still publishes your own kind 1063 to your write relays (and fast write relays as fallback) so your relays index the URL.' ) } if (gif.sourceKind === kinds.ShortTextNote) { return t( 'This GIF was found in a kind 1 note. Notes are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.' ) } if (gif.sourceKind === ExtendedKind.COMMENT) { return t( 'This GIF was found in a kind 1111 comment. Comments are not NIP-94 GIF index entries; publish kind 1063 yourself if you want it discoverable as file metadata.' ) } return t('This GIF was found in a Nostr event of kind {{kind}}.', { kind: gif.sourceKind }) }, [t] ) const gifSourceKindShortLabel = (gif: GifMetadata) => { if (gif.sourceKind === ExtendedKind.FILE_METADATA) return '1063' if (gif.sourceKind === kinds.ShortTextNote) return '1' if (gif.sourceKind === ExtendedKind.COMMENT) return '1111' return String(gif.sourceKind) } /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ const isDrawer = isSmallScreen const content = (
setSearchInput(e.target.value)} className="flex-1" />
{error && (

{error}

)}
{loading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : (
{gifs.map((gif) => { const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn return (
{gifSourceKindShortLabel(gif)} {showArchive && ( )}
) })}
)}

{t('Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.')}

setPasteUrl(e.target.value)} className="flex-1 min-w-0" />
{isLoggedIn && (
setPublishDescription(e.target.value)} className="min-w-0" />
)} {isLoggedIn && ( <> {uploadError && (

{uploadError}

)} )}
) if (isSmallScreen) { return ( {children} {t('Choose a GIF')} {content} ) } return ( {children} {content} ) }