import React, { useState, useEffect, useRef, useCallback } from 'react'; import { api } from '../../api/client'; import type { GifResult } from '@backspace/shared'; import { useT, type TranslationKey } from '../../i18n'; /** * Category shortcuts. The label is translated but the query is not: it is sent * to the provider, which indexes in English — a translated query would return * nothing. */ const CATEGORIES: { key: TranslationKey; query: string }[] = [ { key: 'gif.category.hello', query: 'hello' }, { key: 'gif.category.lol', query: 'lol' }, { key: 'gif.category.love', query: 'love' }, { key: 'gif.category.birthday', query: 'happy birthday' }, { key: 'gif.category.dance', query: 'dance' }, { key: 'gif.category.facepalm', query: 'facepalm' }, ]; interface GifPickerProps { onGifSelect: (url: string) => void; /** * Mobile rendering: drop the desktop fixed dimensions and let the picker * fill its parent (a bottom sheet that controls width + max-height). */ mobile?: boolean; } export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) { const t = useT(); const [showFavorites, setShowFavorites] = useState(false); const [favorites, setFavorites] = useState([]); const [favoriteIds, setFavoriteIds] = useState>(new Set()); const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(true); const [nextPos, setNextPos] = useState(''); const [loadingMore, setLoadingMore] = useState(false); const scrollRef = useRef(null); const debounceRef = useRef>(); // Favourites load once and are kept in memory: the picker is opened and // closed constantly, and re-fetching on every open would be visible. useEffect(() => { let cancelled = false; api.gif.favorites() .then(({ results }) => { if (cancelled) return; setFavorites(results); setFavoriteIds(new Set(results.map((g) => g.id))); }) .catch(() => { /* favourites are an enhancement; browsing still works */ }); return () => { cancelled = true; }; }, []); const toggleFavorite = async (gif: GifResult, e: React.MouseEvent) => { // The tile behind this button inserts the GIF into the message. e.stopPropagation(); const isFavorite = favoriteIds.has(gif.id); // Optimistic: the star must feel instant. Reverted below if the call fails. setFavoriteIds((prev) => { const next = new Set(prev); if (isFavorite) next.delete(gif.id); else next.add(gif.id); return next; }); setFavorites((prev) => (isFavorite ? prev.filter((g) => g.id !== gif.id) : [gif, ...prev])); try { if (isFavorite) await api.gif.removeFavorite(gif.id); else await api.gif.addFavorite(gif); } catch { setFavoriteIds((prev) => { const next = new Set(prev); if (isFavorite) next.add(gif.id); else next.delete(gif.id); return next; }); setFavorites((prev) => (isFavorite ? [gif, ...prev] : prev.filter((g) => g.id !== gif.id))); } }; // Debounce search query useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setDebouncedQuery(query); }, 300); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [query]); // Fetch results when debounced query changes useEffect(() => { let cancelled = false; setLoading(true); setResults([]); setNextPos(''); const fetchGifs = async () => { try { const data = debouncedQuery.trim() ? await api.gif.search(debouncedQuery.trim(), 30) : await api.gif.trending(30); if (!cancelled) { setResults(data.results); setNextPos(data.next); setLoading(false); } } catch { if (!cancelled) setLoading(false); } }; fetchGifs(); return () => { cancelled = true; }; }, [debouncedQuery]); // Infinite scroll const handleScroll = useCallback(() => { const el = scrollRef.current; // Favourites are a complete local list — nothing to page through. if (showFavorites) return; if (!el || loadingMore || !nextPos) return; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { setLoadingMore(true); const fetchMore = async () => { try { const data = debouncedQuery.trim() ? await api.gif.search(debouncedQuery.trim(), 30, nextPos) : await api.gif.trending(30, nextPos); setResults((prev) => [...prev, ...data.results]); setNextPos(data.next); } finally { setLoadingMore(false); } }; fetchMore(); } }, [loadingMore, nextPos, debouncedQuery, showFavorites]); // Prevent keyboard events from bubbling const handleKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); }; // Favourites are a local list; browsing results come from the provider. const shown = showFavorites ? favorites : results; // Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims // matching the legacy popover footprint. const rootClass = mobile ? 'flex flex-col flex-1 min-h-0 w-full' : 'flex flex-col h-[390px] w-[390px]'; return (
{/* Search */}
setQuery(e.target.value)} placeholder={t('gif.search')} className="input-search w-full" // Auto-focus only on desktop. On mobile this would force the OS // keyboard up the moment the sheet opens, hiding most of the grid. autoFocus={!mobile} />
{/* Category shortcuts */}
{CATEGORIES.map((category) => ( ))}
{/* Results grid */}
{loading && !showFavorites ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : shown.length === 0 ? (
{showFavorites ? t('gif.empty.favorites') : debouncedQuery.trim() ? t('gif.empty.search') : t('gif.empty.trending')}
) : (
{shown.map((gif) => { const isFavorite = favoriteIds.has(gif.id); return ( // The star cannot live inside the tile button — a button inside // a button is invalid and swallows the click. Siblings instead.
); })}
)} {loadingMore && (
)}
{/* Attribution */}
Powered by Klipy
); }