diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 93a6b08b..9c380542 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Paperclip, Smile, AlertTriangle, X, Loader2 } from 'lucide-react'; +import { Paperclip, Smile, AlertTriangle, X, Loader2, ImagePlay } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { encode as blurhashEncode } from 'blurhash'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { EmojiPicker } from '@/components/EmojiPicker'; +import { GifPicker } from '@/components/GifPicker'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { MentionAutocomplete } from '@/components/MentionAutocomplete'; import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete'; @@ -159,6 +160,7 @@ export function ComposeBox({ const [cwEnabled, setCwEnabled] = useState(false); const [cwText, setCwText] = useState(''); const [emojiOpen, setEmojiOpen] = useState(false); + const [gifOpen, setGifOpen] = useState(false); const [internalPreviewMode, setInternalPreviewMode] = useState(false); const [removedEmbeds, setRemovedEmbeds] = useState>(new Set()); const [_uploadedFileTags, setUploadedFileTags] = useState([]); @@ -905,6 +907,39 @@ export function ComposeBox({ + {/* GIF picker */} + + + + + + + + {!gifOpen && GIF} + + + { + setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url)); + setGifOpen(false); + expand(); + }} /> + + + {/* Content warning (NIP-36) */} diff --git a/src/components/GifPicker.tsx b/src/components/GifPicker.tsx new file mode 100644 index 00000000..52fccdf0 --- /dev/null +++ b/src/components/GifPicker.tsx @@ -0,0 +1,204 @@ +import { useCallback, useRef, useEffect, useState } from 'react'; +import { Search, X, ImageOff } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useGifSearch, type GifResult } from '@/hooks/useGifSearch'; +import { cn } from '@/lib/utils'; + +interface GifPickerProps { + onSelect: (gif: GifResult) => void; +} + +/** A single GIF thumbnail with lazy loading and hover animation. */ +function GifThumbnail({ gif, onClick }: { gif: GifResult; onClick: (gif: GifResult) => void }) { + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + const imgRef = useRef(null); + + // Calculate the aspect ratio for the thumbnail to prevent layout shifts + const aspectRatio = gif.width && gif.height ? gif.width / gif.height : 1; + const displayHeight = Math.round(150 / aspectRatio); + + return ( + + ); +} + +/** Masonry-style two-column grid for GIF results. */ +function GifGrid({ results, onSelect }: { results: GifResult[]; onSelect: (gif: GifResult) => void }) { + // Split results into two columns for a masonry-like layout + const columns: [GifResult[], GifResult[]] = [[], []]; + const columnHeights = [0, 0]; + + for (const gif of results) { + const aspectRatio = gif.width && gif.height ? gif.width / gif.height : 1; + const height = Math.round(150 / aspectRatio); + + // Add to the shorter column + const shorter = columnHeights[0] <= columnHeights[1] ? 0 : 1; + columns[shorter].push(gif); + columnHeights[shorter] += height + 8; // 8px gap + } + + return ( +
+ {columns.map((col, colIdx) => ( +
+ {col.map((gif) => ( + + ))} +
+ ))} +
+ ); +} + +export function GifPicker({ onSelect }: GifPickerProps) { + const { query, setQuery, clearQuery, results, isLoading, isError, isSearching } = useGifSearch(); + const inputRef = useRef(null); + + // Auto-focus the search input on mount + useEffect(() => { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + const handleSelect = useCallback((gif: GifResult) => { + onSelect(gif); + }, [onSelect]); + + return ( +
+ {/* Search input */} +
+
+ + setQuery(e.target.value)} + placeholder="Search GIFs..." + className="pl-8 pr-8 h-9 text-sm bg-muted/50 border-0 rounded-lg" + /> + {query && ( + + )} +
+
+ + {/* Section header */} +
+ + {isSearching ? 'Results' : 'Trending'} + +
+ + {/* Results area */} + + {isLoading ? ( +
+
+ {[0, 1].map((col) => ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ))} +
+
+ ) : isError ? ( +
+ +

Failed to load GIFs

+

Please try again

+
+ ) : results.length === 0 ? ( +
+

No GIFs found

+

Try a different search term

+
+ ) : ( + + )} +
+ + {/* Tenor attribution */} +
+ Powered by + +
+
+ ); +} + +/** Tenor brand wordmark for required attribution. */ +function TenorLogo() { + return ( + + + Tenor + + + ); +} diff --git a/src/components/dm/DMChatArea.tsx b/src/components/dm/DMChatArea.tsx index d5e7e899..99f085b2 100644 --- a/src/components/dm/DMChatArea.tsx +++ b/src/components/dm/DMChatArea.tsx @@ -14,9 +14,12 @@ import { Textarea } from '@/components/ui/textarea'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck } from 'lucide-react'; +import { ArrowLeft, Send, Loader2, AlertTriangle, Key, ShieldCheck, ImagePlay, Smile } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { NoteContent } from '@/components/NoteContent'; +import { GifPicker } from '@/components/GifPicker'; +import { EmojiPicker } from '@/components/EmojiPicker'; import type { NostrEvent } from '@nostrify/nostrify'; interface DMChatAreaProps { @@ -222,6 +225,9 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => { const [messageText, setMessageText] = useState(''); const [isSending, setIsSending] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); + const [gifOpen, setGifOpen] = useState(false); + const [emojiOpen, setEmojiOpen] = useState(false); + const textareaRef = useRef(null); // Determine default protocol based on mode const getDefaultProtocol = () => { @@ -364,14 +370,85 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
-