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, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' import { fetchMemes, getCachedMemes, mergeMemesIntoIdbCache, memeMetadataFrom1063Event, searchMemes, type MemeMetadata } from '@/services/meme.service' import mediaUpload from '@/services/media-upload.service' import { ExternalLink, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' /** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */ let _sessionMemes: MemeMetadata[] = [] import { useTranslation } from 'react-i18next' import { toast } from 'sonner' const MEMEAMIGO_URL = 'https://www.memeamigo.lol/' const MEMEAMIGO_SEARCH_URL = (q: string) => q.trim() ? `${MEMEAMIGO_URL}?q=${encodeURIComponent(q.trim())}` : MEMEAMIGO_URL function mimeFromImageUrl(url: string): string { const lower = url.toLowerCase().split('?')[0] ?? '' if (lower.endsWith('.png')) return 'image/png' if (lower.endsWith('.webp')) return 'image/webp' if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' return 'image/jpeg' } function isStaticImageFile(file: File): boolean { const n = file.name.toLowerCase() const t = file.type.toLowerCase() return ( t === 'image/jpeg' || t === 'image/png' || t === 'image/webp' || n.endsWith('.jpg') || n.endsWith('.jpeg') || n.endsWith('.png') || n.endsWith('.webp') ) } export default function MemePicker({ children, onSelect, portalContainer }: { children: React.ReactNode onSelect?: (imageUrl: string) => void 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 [memes, setMemesState] = useState(() => _sessionMemes) const memesRef = useRef(_sessionMemes) 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 [publishDescription, setPublishDescription] = useState('') const searchTimeoutRef = useRef | null>(null) const fileInputRef = useRef(null) const memeamigoPopupRef = useRef(null) const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userWriteRelays = relayList?.write ?? [] /** Keep memesRef, session cache, and React state in sync. */ const setMemes = useCallback((newMemes: MemeMetadata[], isSearch = false) => { memesRef.current = newMemes if (!isSearch) _sessionMemes = newMemes setMemesState(newMemes) }, []) const loadMemes = useCallback( async (q: string, forceRefresh = false) => { setError(null) const isSearch = q.trim() !== '' if (isSearch) { memesRef.current = [] setMemesState([]) setLoading(true) } else if (memesRef.current.length === 0) { try { const cached = await getCachedMemes(pubkey ?? null) if (cached.length > 0) { setMemes(cached) } } catch { /* ignore */ } if (memesRef.current.length === 0) setLoading(true) } try { const results = isSearch ? await searchMemes(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null) : await fetchMemes(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null) setMemes(results, isSearch) if (results.length === 0 && !isSearch) { setError( t( 'No meme templates found. Try searching or open Meme Amigo. The grid only lists kind 1063 (NIP-94) files tagged memeamigo (not random photos from notes).' ) ) } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load memes') if (memesRef.current.length === 0) setMemesState([]) } finally { setLoading(false) } }, [t, userReadRelays, pubkey, setMemes] ) useEffect(() => { if (!open) return loadMemes(query) }, [open, query, loadMemes]) 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 = (meme: MemeMetadata) => { const url = meme.fallbackUrl || meme.url onSelect?.(url) setOpen(false) } const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file || !pubkey) return setUploadError(null) setUploading(true) try { if (!isStaticImageFile(file)) { setUploadError(t('{{name}} is not a JPEG, PNG, or WebP file', { name: file.name })) return } const { url } = await mediaUpload.upload(file) const mime = file.type || mimeFromImageUrl(url) const draft = { kind: ExtendedKind.FILE_METADATA, content: publishDescription.trim(), tags: [ ['file', url, mime, `size ${file.size}`], ['url', url], ['m', mime], ['t', 'memeamigo'] ], created_at: Math.floor(Date.now() / 1000) } const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays] const seen = new Set() const specifiedRelayUrls = writeUrls.filter((u) => { const n = (normalizeUrl(u) ?? u).toLowerCase() if (seen.has(n)) return false seen.add(n) return true }) const published = await publish(draft, { specifiedRelayUrls }) const meta = memeMetadataFrom1063Event(published) if (meta) { await mergeMemesIntoIdbCache([meta]) const next = [meta, ...memesRef.current.filter((m) => m.eventId !== meta.eventId)].slice(0, 50) setMemes(next) } setPublishDescription('') setQuery('') await loadMemes('', false) } 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 const openMemeAmigoSearch = useCallback(() => { const url = MEMEAMIGO_SEARCH_URL(searchInput) const w = window.open(url, '_blank', 'noopener,noreferrer') memeamigoPopupRef.current = w ?? null const handler = (event: MessageEvent) => { if ( event.origin !== 'https://www.memeamigo.lol' && event.origin !== 'https://memeamigo.lol' ) { return } const data = event.data const urlToInsert = typeof data === 'string' && (data.startsWith('http://') || data.startsWith('https://')) ? data : data?.url ?? data?.imageUrl if (urlToInsert && typeof urlToInsert === 'string') { window.removeEventListener('message', handler) memeamigoPopupRef.current = null onSelect?.(urlToInsert) setOpen(false) } } window.addEventListener('message', handler) const timer = setTimeout(() => { window.removeEventListener('message', handler) memeamigoPopupRef.current = null }, 10 * 60 * 1000) if (w) w.addEventListener('beforeunload', () => { clearTimeout(timer) window.removeEventListener('message', handler) }) }, [searchInput, onSelect]) const descriptionForPublish = publishDescription.trim() 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 mime = mimeFromImageUrl(url) const draft = { kind: ExtendedKind.FILE_METADATA, content: descriptionForPublish, tags: [ ['file', url, mime, 'size 0'], ['url', url], ['m', mime], ['t', 'memeamigo'] ], created_at: Math.floor(Date.now() / 1000) } const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays] const seen = new Set() const specifiedRelayUrls = writeUrls.filter((u) => { const n = (normalizeUrl(u) ?? u).toLowerCase() if (seen.has(n)) return false seen.add(n) return true }) const published = await publish(draft, { specifiedRelayUrls }) const meta = memeMetadataFrom1063Event(published) if (meta) { await mergeMemesIntoIdbCache([meta]) } setPublishDescription('') } catch (err) { toast.error( err instanceof Error ? err.message : t('Failed to publish meme template for the picker') ) } finally { setPublishingPaste(false) } } }, [pasteUrl, pubkey, onSelect, publish, userWriteRelays, descriptionForPublish]) const isDrawer = isSmallScreen const content = (
setSearchInput(e.target.value)} className="flex-1" />
{error &&

{error}

}
{loading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : (
{memes.map((meme) => ( ))}
)}

{t( 'Opens in a new tab. Copy an image URL there, then paste below. If this picker closed, click “Insert meme” 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 meme')} {content} ) } return ( {children} {content} ) }