You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

561 lines
21 KiB

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<GifMetadata[]>(() => _sessionGifs)
const gifsRef = useRef<GifMetadata[]>(_sessionGifs)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [pasteUrl, setPasteUrl] = useState('')
const [publishingPaste, setPublishingPaste] = useState(false)
const [archivingEventId, setArchivingEventId] = useState<string | null>(null)
const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(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<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. */
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<HTMLInputElement>) => {
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 = (
<div
className={`flex flex-col gap-2 p-2 ${isDrawer ? 'w-full h-[70vh] max-h-[70vh] overflow-hidden' : 'min-w-[280px] max-w-[360px]'}`}
>
<div className="flex items-center gap-1 shrink-0">
<Input
placeholder={t('Search GIFs')}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 size-8"
onClick={() => setOpen(false)}
aria-label={t('Close')}
>
<X className="size-4" />
</Button>
</div>
{error && (
<p className="text-sm text-muted-foreground px-1 shrink-0">{error}</p>
)}
<div
className={isDrawer ? 'flex-1 min-h-0 flex flex-col' : undefined}
{...(isDrawer && { 'data-vaul-no-drag': true })}
>
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
}
>
{loading ? (
<div
className="grid grid-cols-2 gap-1 p-2 min-h-[200px]"
role="status"
aria-busy="true"
aria-live="polite"
>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-1 p-2">
{gifs.map((gif) => {
const showArchive = gifShouldOfferNip94Archive(gif) && isLoggedIn
return (
<div key={gif.eventId} className="relative aspect-square rounded overflow-hidden">
<button
type="button"
className={cn(
'absolute inset-0 z-0 rounded overflow-hidden border border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => handleSelect(gif)}
>
<img
src={gif.url}
alt=""
className="w-full h-full object-cover pointer-events-none"
loading="lazy"
onError={(e) => {
const el = e.target as HTMLImageElement
const fallback = gif.fallbackUrl?.trim()
if (fallback && el.dataset.gifFallbackTried !== '1') {
el.dataset.gifFallbackTried = '1'
el.src = fallback
return
}
el.style.display = 'none'
}}
/>
</button>
<span
className="absolute top-1 left-1 z-10 max-w-[calc(100%-2.5rem)] truncate rounded border border-border/80 bg-background/90 px-1 py-px text-[10px] font-medium tabular-nums text-foreground backdrop-blur-sm pointer-events-none shadow-sm"
title={gifSourceKindTitle(gif)}
>
{gifSourceKindShortLabel(gif)}
</span>
{showArchive && (
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-1 right-1 z-10 h-7 w-7 shadow-md"
disabled={archivingEventId === gif.eventId}
title={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
aria-label={t(
'Publish kind 1063 (NIP-94) for this GIF and insert the URL into your post'
)}
onClick={(e) => handleArchiveAndInsert(e, gif)}
>
<Download className="size-3.5" />
</Button>
)}
</div>
)
})}
</div>
)}
</ScrollArea>
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
<div className="flex flex-col gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={openGifBuddySearch}
>
<ExternalLink className="size-3.5 mr-1.5" />
{t('Search on GifBuddy')}
</Button>
<p className="text-xs text-muted-foreground">
{t('Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.')}
</p>
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Paste URL of a GIF')}
</Label>
<div className="flex gap-1">
<Input
placeholder="https://..."
value={pasteUrl}
onChange={(e) => setPasteUrl(e.target.value)}
className="flex-1 min-w-0"
/>
<Button
type="button"
size="sm"
disabled={!pasteUrl.trim() || publishingPaste}
onClick={handlePasteUrlInsert}
title={t('Insert URL into your post and publish to Nostr GIF library (NIP-94).')}
>
{publishingPaste ? t('Adding…') : t('Insert')}
</Button>
</div>
</div>
</div>
{isLoggedIn && (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Description (optional, for search)')}
</Label>
<Input
placeholder={t('e.g. happy birthday, thumbs up')}
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
className="min-w-0"
/>
</div>
)}
{isLoggedIn && (
<>
<input
ref={fileInputRef}
type="file"
accept=".gif,image/gif"
className="hidden"
onChange={handleUpload}
/>
<Button
type="button"
variant="secondary"
size="sm"
className="w-full"
disabled={uploading}
onClick={triggerFileUpload}
>
{uploading ? t('Uploading...') : t('Add your own GIFs')}
</Button>
{uploadError && (
<p className="text-xs text-destructive text-center">{uploadError}</p>
)}
</>
)}
</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen} handleOnly>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent dragHandle="vaul" portalContainer={portalContainer}>
<DrawerHeader className="sr-only">
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
)
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0" portalContainer={portalContainer}>
{content}
</DropdownMenuContent>
</DropdownMenu>
)
}