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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user