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 { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service' import mediaUpload from '@/services/media-upload.service' import { ExternalLink, Loader2, X } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' 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 } = useNostr() const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [searchInput, setSearchInput] = useState('') const [gifs, setGifs] = useState([]) 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 searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) const gifbuddyPopupRef = useRef(null) const loadGifs = useCallback(async (q: string, forceRefresh = false) => { setError(null) setLoading(true) try { const results = q.trim() ? await searchGifs(q.trim(), 50, forceRefresh) : await fetchGifs(undefined, 50, forceRefresh) setGifs(results) if (results.length === 0 && !q.trim()) { 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') setGifs([]) } finally { setLoading(false) } }, [t]) 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 = (gif: GifMetadata) => { const url = gif.fallbackUrl || gif.url onSelect?.(url) setOpen(false) } const handleUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files?.length || !pubkey) return setUploadError(null) setUploading(true) try { for (const file of Array.from(files)) { if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) { setUploadError(t('{{name}} is not a GIF file', { name: file.name })) continue } const { url } = await mediaUpload.upload(file) const draft = { kind: ExtendedKind.FILE_METADATA, content: '', 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: GIF_RELAY_URLS }) } 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]) /** 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: '', tags: [ ['url', url], ['m', 'image/gif'], ['t', 'gif'] ], created_at: Math.floor(Date.now() / 1000) } await publish(draft, { specifiedRelayUrls: GIF_RELAY_URLS }) } catch { // ignore; URL was still inserted } finally { setPublishingPaste(false) } } }, [pasteUrl, pubkey, onSelect, publish]) /** 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 ? (
) : (
{gifs.map((gif) => ( ))}
)}

{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 && ( <> {uploadError && (

{uploadError}

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