Add GIF keyboard with Tenor API to compose box and DM chat

Introduces a GIF picker powered by Tenor's API with search and trending
GIFs. The picker is integrated into both the post compose toolbar and
the DM chat input area, alongside a new emoji picker for DMs.
This commit is contained in:
Alex Gleason
2026-02-28 21:13:59 -06:00
parent eaa337de01
commit d27464d6fc
4 changed files with 476 additions and 10 deletions
+36 -1
View File
@@ -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<Set<string>>(new Set());
const [_uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
@@ -905,6 +907,39 @@ export function ComposeBox({
</PopoverContent>
</Popover>
{/* GIF picker */}
<Popover open={gifOpen} onOpenChange={setGifOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-2 rounded-full transition-colors',
gifOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<ImagePlay className="size-[18px]" />
</button>
</PopoverTrigger>
</TooltipTrigger>
{!gifOpen && <TooltipContent>GIF</TooltipContent>}
</Tooltip>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<GifPicker onSelect={(gif) => {
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
setGifOpen(false);
expand();
}} />
</PopoverContent>
</Popover>
{/* Content warning (NIP-36) */}
<Tooltip>
<TooltipTrigger asChild>
+204
View File
@@ -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<HTMLImageElement>(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 (
<button
type="button"
onClick={() => onClick(gif)}
className={cn(
'relative w-full rounded-lg overflow-hidden cursor-pointer',
'transition-all duration-200 hover:ring-2 hover:ring-primary/60 hover:scale-[1.02]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
'group',
)}
style={{ height: displayHeight }}
title={gif.title}
>
{/* Skeleton placeholder */}
{!loaded && !error && (
<Skeleton className="absolute inset-0 rounded-lg" />
)}
{/* Error state */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted rounded-lg">
<ImageOff className="size-5 text-muted-foreground/40" />
</div>
)}
{/* GIF image */}
<img
ref={imgRef}
src={gif.previewUrl}
alt={gif.title}
loading="lazy"
className={cn(
'w-full h-full object-cover rounded-lg transition-opacity duration-200',
loaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
/>
{/* Hover overlay with title */}
<div className={cn(
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent',
'px-2 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-150',
)}>
<span className="text-[10px] text-white line-clamp-1 font-medium">
{gif.title}
</span>
</div>
</button>
);
}
/** 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 (
<div className="flex gap-2 px-2 pb-2">
{columns.map((col, colIdx) => (
<div key={colIdx} className="flex-1 flex flex-col gap-2">
{col.map((gif) => (
<GifThumbnail key={gif.id} gif={gif} onClick={onSelect} />
))}
</div>
))}
</div>
);
}
export function GifPicker({ onSelect }: GifPickerProps) {
const { query, setQuery, clearQuery, results, isLoading, isError, isSearching } = useGifSearch();
const inputRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col w-[340px] h-[420px] bg-popover rounded-lg overflow-hidden">
{/* Search input */}
<div className="px-3 pt-3 pb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs..."
className="pl-8 pr-8 h-9 text-sm bg-muted/50 border-0 rounded-lg"
/>
{query && (
<button
type="button"
onClick={clearQuery}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* Section header */}
<div className="px-3 pb-1.5">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{isSearching ? 'Results' : 'Trending'}
</span>
</div>
{/* Results area */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="px-2 pb-2">
<div className="flex gap-2">
{[0, 1].map((col) => (
<div key={col} className="flex-1 flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton
key={i}
className="w-full rounded-lg"
style={{ height: 80 + Math.random() * 60 }}
/>
))}
</div>
))}
</div>
</div>
) : isError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<ImageOff className="size-8 mb-2 opacity-40" />
<p className="text-sm">Failed to load GIFs</p>
<p className="text-xs mt-1">Please try again</p>
</div>
) : results.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
<p className="text-sm">No GIFs found</p>
<p className="text-xs mt-1">Try a different search term</p>
</div>
) : (
<GifGrid results={results} onSelect={handleSelect} />
)}
</ScrollArea>
{/* Tenor attribution */}
<div className="px-3 py-1.5 border-t border-border/50 flex items-center justify-end gap-1.5">
<span className="text-[10px] text-muted-foreground/60">Powered by</span>
<TenorLogo />
</div>
</div>
);
}
/** Tenor brand wordmark for required attribution. */
function TenorLogo() {
return (
<svg width="42" height="12" viewBox="0 0 42 12" fill="none" xmlns="http://www.w3.org/2000/svg" className="opacity-40">
<text x="0" y="10" fontSize="10" fontWeight="600" fontFamily="system-ui, -apple-system, sans-serif" className="fill-muted-foreground">
Tenor
</text>
</svg>
);
}
+86 -9
View File
@@ -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<HTMLTextAreaElement>(null);
// Determine default protocol based on mode
const getDefaultProtocol = () => {
@@ -364,14 +370,85 @@ export const DMChatArea = ({ pubkey, onBack, className }: DMChatAreaProps) => {
<div className="p-4 border-t">
<div className="flex gap-2">
<Textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
<div className="flex-1 flex flex-col gap-1.5">
<Textarea
ref={textareaRef}
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send, Shift+Enter for new line)"
className="min-h-[80px] resize-none"
disabled={isSending}
/>
{/* Toolbar row */}
<div className="flex items-center gap-0.5">
{/* Emoji picker */}
<Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
emojiOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<Smile className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<EmojiPicker onSelect={(emoji) => {
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = messageText.slice(0, start) + emoji + messageText.slice(end);
setMessageText(newText);
requestAnimationFrame(() => {
textarea.focus();
const pos = start + emoji.length;
textarea.setSelectionRange(pos, pos);
});
} else {
setMessageText((prev) => prev + emoji);
}
}} />
</PopoverContent>
</Popover>
{/* GIF picker */}
<Popover open={gifOpen} onOpenChange={setGifOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'p-1.5 rounded-full transition-colors',
gifOpen
? 'text-primary bg-primary/10'
: 'text-muted-foreground hover:text-primary hover:bg-primary/10',
)}
>
<ImagePlay className="size-[16px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-auto p-0 border-border"
>
<GifPicker onSelect={(gif) => {
setMessageText((prev) => (prev ? prev + '\n' + gif.url : gif.url));
setGifOpen(false);
}} />
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleSend}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
const TENOR_API_KEY = 'AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ'; // Tenor public API key
const TENOR_BASE_URL = 'https://tenor.googleapis.com/v2';
const RESULTS_LIMIT = 30;
interface TenorMediaFormat {
url: string;
dims: [number, number];
duration: number;
size: number;
}
interface TenorResult {
id: string;
title: string;
media_formats: {
gif?: TenorMediaFormat;
tinygif?: TenorMediaFormat;
nanogif?: TenorMediaFormat;
mediumgif?: TenorMediaFormat;
gifpreview?: TenorMediaFormat;
tinygifpreview?: TenorMediaFormat;
};
content_description: string;
created: number;
url: string;
}
interface TenorResponse {
results: TenorResult[];
next: string;
}
export interface GifResult {
id: string;
title: string;
/** URL for the full-size GIF */
url: string;
/** URL for a smaller preview thumbnail */
previewUrl: string;
/** Width of the preview */
width: number;
/** Height of the preview */
height: number;
}
function mapTenorResult(result: TenorResult): GifResult {
const gif = result.media_formats.gif ?? result.media_formats.mediumgif;
const preview = result.media_formats.tinygif ?? result.media_formats.nanogif;
return {
id: result.id,
title: result.content_description || result.title,
url: gif?.url ?? preview?.url ?? '',
previewUrl: preview?.url ?? gif?.url ?? '',
width: preview?.dims[0] ?? gif?.dims[0] ?? 220,
height: preview?.dims[1] ?? gif?.dims[1] ?? 160,
};
}
async function fetchTenorSearch(query: string, pos?: string): Promise<{ results: GifResult[]; next: string }> {
const params = new URLSearchParams({
key: TENOR_API_KEY,
q: query,
limit: String(RESULTS_LIMIT),
media_filter: 'gif,tinygif',
contentfilter: 'medium',
client_key: 'ditto_nostr',
});
if (pos) params.set('pos', pos);
const res = await fetch(`${TENOR_BASE_URL}/search?${params}`);
if (!res.ok) throw new Error(`Tenor search failed: ${res.status}`);
const data: TenorResponse = await res.json();
return {
results: data.results.map(mapTenorResult).filter((g) => g.url),
next: data.next,
};
}
async function fetchTenorTrending(pos?: string): Promise<{ results: GifResult[]; next: string }> {
const params = new URLSearchParams({
key: TENOR_API_KEY,
limit: String(RESULTS_LIMIT),
media_filter: 'gif,tinygif',
contentfilter: 'medium',
client_key: 'ditto_nostr',
});
if (pos) params.set('pos', pos);
const res = await fetch(`${TENOR_BASE_URL}/featured?${params}`);
if (!res.ok) throw new Error(`Tenor featured failed: ${res.status}`);
const data: TenorResponse = await res.json();
return {
results: data.results.map(mapTenorResult).filter((g) => g.url),
next: data.next,
};
}
export function useGifSearch() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedQuery(value.trim());
}, 300);
}, []);
const clearQuery = useCallback(() => {
setQuery('');
setDebouncedQuery('');
clearTimeout(debounceRef.current);
}, []);
const isSearching = debouncedQuery.length > 0;
const trendingQuery = useQuery({
queryKey: ['tenor', 'trending'],
queryFn: () => fetchTenorTrending(),
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !isSearching,
});
const searchQuery = useQuery({
queryKey: ['tenor', 'search', debouncedQuery],
queryFn: () => fetchTenorSearch(debouncedQuery),
staleTime: 2 * 60 * 1000, // 2 minutes
enabled: isSearching,
});
const activeQuery = isSearching ? searchQuery : trendingQuery;
return {
query,
setQuery: handleQueryChange,
clearQuery,
results: activeQuery.data?.results ?? [],
isLoading: activeQuery.isLoading,
isError: activeQuery.isError,
isSearching,
};
}