diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx new file mode 100644 index 00000000..62a16199 --- /dev/null +++ b/src/components/GifPicker/index.tsx @@ -0,0 +1,228 @@ +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 { 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 { Loader2 } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const GIFBUDDY_URL = 'https://www.gifbuddy.lol/' + +export default function GifPicker({ + children, + onSelect +}: { + children: React.ReactNode + onSelect?: (gifUrl: string) => void +}) { + 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 searchTimeoutRef = useRef | null>(null) + const fileInputRef = 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 + + const content = ( +
+
+ setSearchInput(e.target.value)} + className="flex-1" + /> +
+ {error && ( +

{error}

+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {gifs.map((gif) => ( + + ))} +
+ )} +
+
+ + {t('Search GifBuddy for more GIFs')} + + {isLoggedIn && ( + <> + + + {uploadError && ( +

{uploadError}

+ )} + + )} +
+
+ ) + + if (isSmallScreen) { + return ( + + {children} + + + {t('Choose a GIF')} + + {content} + + + ) + } + + return ( + + {children} + + {content} + + + ) +} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 3f632dcb..a1ea0b36 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -41,7 +41,7 @@ import logger from '@/lib/logger' import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' -import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video } from 'lucide-react' +import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video, Film } from 'lucide-react' import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' @@ -52,6 +52,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import EmojiPickerDialog from '../EmojiPickerDialog' +import GifPicker from '../GifPicker' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import Mentions, { extractMentions } from './Mentions' import PollEditor from './PollEditor' @@ -2259,6 +2260,15 @@ export default function PostContent({ + { + textareaRef.current?.insertText(gifUrl) + }} + > + + {/* I'm not sure why, but after triggering the virtual keyboard, opening the emoji picker drawer causes an issue, the emoji I tap isn't the one that gets inserted. */} diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx index 536429fa..28a51f14 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionList.tsx @@ -10,6 +10,9 @@ import { SimpleUsername } from '../../../Username' export interface MentionListProps { items: string[] command: (payload: { id: string; label?: string }) => void + /** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */ + selectedIndex?: number + onSelectIndex?: (index: number) => void } export interface MentionListHandle { @@ -17,7 +20,10 @@ export interface MentionListHandle { } const MentionList = forwardRef((props, ref) => { - const [selectedIndex, setSelectedIndex] = useState(0) + const [internalIndex, setInternalIndex] = useState(0) + const isControlled = props.selectedIndex !== undefined + const selectedIndex = isControlled ? props.selectedIndex! : internalIndex + const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex const selectItem = (index: number) => { const item = props.items[index] @@ -40,8 +46,10 @@ const MentionList = forwardRef((props, ref) } useEffect(() => { - setSelectedIndex(props.items.length ? 0 : -1) - }, [props.items]) + if (!isControlled) { + setInternalIndex(props.items.length ? 0 : -1) + } + }, [props.items, isControlled]) useImperativeHandle(ref, () => ({ onKeyDown: ({ event }: SuggestionKeyDownProps) => { @@ -71,8 +79,8 @@ const MentionList = forwardRef((props, ref) return ( e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} + onWheel={(e: React.WheelEvent) => e.stopPropagation()} + onTouchMove={(e: React.TouchEvent) => e.stopPropagation()} > {props.items.map((item, index) => (