9 changed files with 717 additions and 14 deletions
@ -0,0 +1,228 @@
@@ -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<GifMetadata[]>([]) |
||||
const [loading, setLoading] = useState(false) |
||||
const [error, setError] = useState<string | null>(null) |
||||
const [uploading, setUploading] = useState(false) |
||||
const [uploadError, setUploadError] = useState<string | null>(null) |
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
||||
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => { |
||||
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 = ( |
||||
<div className="flex flex-col gap-2 p-2 min-w-[280px] max-w-[360px]"> |
||||
<div className="flex gap-1"> |
||||
<Input |
||||
placeholder={t('Search GIFs')} |
||||
value={searchInput} |
||||
onChange={(e) => setSearchInput(e.target.value)} |
||||
className="flex-1" |
||||
/> |
||||
</div> |
||||
{error && ( |
||||
<p className="text-sm text-muted-foreground px-1">{error}</p> |
||||
)} |
||||
<ScrollArea className="h-[280px] w-full rounded-md border"> |
||||
{loading ? ( |
||||
<div className="flex items-center justify-center h-full"> |
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> |
||||
</div> |
||||
) : ( |
||||
<div className="grid grid-cols-2 gap-1 p-2"> |
||||
{gifs.map((gif) => ( |
||||
<button |
||||
key={gif.eventId} |
||||
type="button" |
||||
className="rounded overflow-hidden border border-transparent hover:border-primary focus:border-primary focus:outline-none aspect-square" |
||||
onClick={() => handleSelect(gif)} |
||||
> |
||||
<img |
||||
src={gif.url} |
||||
alt="GIF" |
||||
className="w-full h-full object-cover" |
||||
loading="lazy" |
||||
onError={(e) => { |
||||
const el = e.target as HTMLImageElement |
||||
el.style.display = 'none' |
||||
}} |
||||
/> |
||||
</button> |
||||
))} |
||||
</div> |
||||
)} |
||||
</ScrollArea> |
||||
<div className="flex flex-col gap-2 border-t pt-2"> |
||||
<a |
||||
href={GIFBUDDY_URL} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="text-sm text-muted-foreground hover:underline text-center" |
||||
> |
||||
{t('Search GifBuddy for more GIFs')} |
||||
</a> |
||||
{isLoggedIn && ( |
||||
<> |
||||
<input |
||||
ref={fileInputRef} |
||||
type="file" |
||||
accept=".gif,image/gif" |
||||
multiple |
||||
className="hidden" |
||||
onChange={handleUpload} |
||||
/> |
||||
<Button |
||||
type="button" |
||||
variant="secondary" |
||||
size="sm" |
||||
className="w-full" |
||||
disabled={uploading} |
||||
onClick={triggerFileUpload} |
||||
> |
||||
{uploading ? t('Uploading...') : t('Add your own GIFs')} |
||||
</Button> |
||||
{uploadError && ( |
||||
<p className="text-xs text-destructive text-center">{uploadError}</p> |
||||
)} |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
) |
||||
|
||||
if (isSmallScreen) { |
||||
return ( |
||||
<Drawer open={open} onOpenChange={setOpen}> |
||||
<DrawerTrigger asChild>{children}</DrawerTrigger> |
||||
<DrawerContent> |
||||
<DrawerHeader className="sr-only"> |
||||
<DrawerTitle>{t('Choose a GIF')}</DrawerTitle> |
||||
</DrawerHeader> |
||||
{content} |
||||
</DrawerContent> |
||||
</Drawer> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<DropdownMenu open={open} onOpenChange={setOpen}> |
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> |
||||
<DropdownMenuContent side="top" className="p-0"> |
||||
{content} |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
) |
||||
} |
||||
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import MentionList from '@/components/PostEditor/PostTextarea/Mention/MentionList' |
||||
import client from '@/services/client.service' |
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' |
||||
|
||||
const MENTION_LIMIT = 20 |
||||
const MENTION_INSERT_PREFIX = 'nostr:' |
||||
|
||||
export type TextareaWithMentionAutocompleteProps = Omit< |
||||
React.ComponentProps<typeof Textarea>, |
||||
'value' | 'onChange' |
||||
> & { |
||||
value: string |
||||
onChange: (value: string) => void |
||||
} |
||||
|
||||
/** |
||||
* Plain textarea with @-mention autocomplete (same npub search as post form). |
||||
* When user types @query, shows a dropdown of matching profiles; on select inserts nostr:npub... |
||||
*/ |
||||
const TextareaWithMentionAutocomplete = forwardRef<HTMLTextAreaElement, TextareaWithMentionAutocompleteProps>(function TextareaWithMentionAutocomplete({ |
||||
value, |
||||
onChange, |
||||
onKeyDown, |
||||
...textareaProps |
||||
}, refProp) { |
||||
const [mentionOpen, setMentionOpen] = useState(false) |
||||
const [mentionQuery, setMentionQuery] = useState('') |
||||
const [mentionItems, setMentionItems] = useState<string[]>([]) |
||||
const [mentionStart, setMentionStart] = useState(0) |
||||
const [selectedIndex, setSelectedIndex] = useState(0) |
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null) |
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
||||
|
||||
const closeMention = useCallback(() => { |
||||
setMentionOpen(false) |
||||
setMentionQuery('') |
||||
setMentionItems([]) |
||||
}, []) |
||||
|
||||
const insertMention = useCallback( |
||||
(npub: string) => { |
||||
const ta = textareaRef.current |
||||
if (!ta) return |
||||
const start = mentionStart |
||||
const end = start + 1 + mentionQuery.length |
||||
const before = value.slice(0, start) |
||||
const after = value.slice(end) |
||||
const insert = MENTION_INSERT_PREFIX + npub |
||||
onChange(before + insert + after) |
||||
closeMention() |
||||
setTimeout(() => { |
||||
ta.focus() |
||||
const newPos = start + insert.length |
||||
ta.setSelectionRange(newPos, newPos) |
||||
}, 0) |
||||
}, |
||||
[value, mentionStart, mentionQuery.length, onChange, closeMention] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (!mentionQuery.trim()) { |
||||
setMentionItems([]) |
||||
setMentionOpen(false) |
||||
return |
||||
} |
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) |
||||
searchTimeoutRef.current = setTimeout(() => { |
||||
client |
||||
.searchNpubsFromLocal(mentionQuery.trim(), MENTION_LIMIT) |
||||
.then((npubs) => { |
||||
setMentionItems(npubs) |
||||
setMentionOpen(npubs.length > 0) |
||||
setSelectedIndex(0) |
||||
}) |
||||
.catch(() => { |
||||
setMentionItems([]) |
||||
setMentionOpen(false) |
||||
}) |
||||
}, 150) |
||||
return () => { |
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current) |
||||
} |
||||
}, [mentionQuery]) |
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||
const v = e.target.value |
||||
const cursor = e.target.selectionStart ?? v.length |
||||
onChange(v) |
||||
|
||||
const textBeforeCursor = v.slice(0, cursor) |
||||
const lastAt = textBeforeCursor.lastIndexOf('@') |
||||
if (lastAt === -1) { |
||||
closeMention() |
||||
return |
||||
} |
||||
const afterAt = textBeforeCursor.slice(lastAt + 1) |
||||
if (/\s/.test(afterAt)) { |
||||
closeMention() |
||||
return |
||||
} |
||||
setMentionStart(lastAt) |
||||
setMentionQuery(afterAt) |
||||
} |
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
||||
if (mentionOpen && mentionItems.length > 0) { |
||||
if (e.key === 'ArrowDown') { |
||||
e.preventDefault() |
||||
setSelectedIndex((i) => (i + 1) % mentionItems.length) |
||||
return |
||||
} |
||||
if (e.key === 'ArrowUp') { |
||||
e.preventDefault() |
||||
setSelectedIndex((i) => (i + mentionItems.length - 1) % mentionItems.length) |
||||
return |
||||
} |
||||
if (e.key === 'Enter') { |
||||
e.preventDefault() |
||||
insertMention(mentionItems[selectedIndex]!) |
||||
return |
||||
} |
||||
if (e.key === 'Escape') { |
||||
e.preventDefault() |
||||
closeMention() |
||||
return |
||||
} |
||||
} |
||||
onKeyDown?.(e) |
||||
} |
||||
|
||||
const setRef = (el: HTMLTextAreaElement | null) => { |
||||
textareaRef.current = el |
||||
if (typeof refProp === 'function') { |
||||
refProp(el) |
||||
} else if (refProp) { |
||||
(refProp as React.MutableRefObject<HTMLTextAreaElement | null>).current = el |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="relative"> |
||||
<Textarea |
||||
{...textareaProps} |
||||
ref={setRef} |
||||
value={value} |
||||
onChange={handleChange} |
||||
onKeyDown={handleKeyDown} |
||||
/> |
||||
{mentionOpen && mentionItems.length > 0 && ( |
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1" role="listbox"> |
||||
<MentionList |
||||
items={mentionItems} |
||||
command={({ id }) => insertMention(id)} |
||||
selectedIndex={selectedIndex} |
||||
onSelectIndex={setSelectedIndex} |
||||
/> |
||||
</div> |
||||
)} |
||||
</div> |
||||
) |
||||
}) |
||||
export default TextareaWithMentionAutocomplete |
||||
@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
|
||||
/** |
||||
* Fetch GIFs from Nostr NIP-94 file metadata events (kind 1063). |
||||
* Same approach as aitherboard: query GIF relays, parse file/imeta/image/url tags. |
||||
*/ |
||||
|
||||
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' |
||||
import type { Event as NEvent } from 'nostr-tools' |
||||
import client from './client.service' |
||||
|
||||
export interface GifMetadata { |
||||
url: string |
||||
fallbackUrl?: string |
||||
sha256?: string |
||||
mimeType?: string |
||||
width?: number |
||||
height?: number |
||||
eventId: string |
||||
pubkey: string |
||||
createdAt: number |
||||
} |
||||
|
||||
function parseGifFromEvent(event: NEvent): GifMetadata | null { |
||||
let url: string | undefined |
||||
let mimeType: string | undefined |
||||
let width: number | undefined |
||||
let height: number | undefined |
||||
let fallbackUrl: string | undefined |
||||
let sha256: string | undefined |
||||
|
||||
// imeta tags (NIP-92)
|
||||
const imetaTags = event.tags.filter((t) => t[0] === 'imeta') |
||||
for (const imetaTag of imetaTags) { |
||||
for (let i = 1; i < imetaTag.length; i++) { |
||||
const field = imetaTag[i] |
||||
if (field?.startsWith('url ')) { |
||||
const candidateUrl = field.substring(4).trim() |
||||
if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) { |
||||
url = candidateUrl |
||||
const mimeField = imetaTag.find((f) => f?.startsWith('m ')) |
||||
if (mimeField) mimeType = mimeField.substring(2).trim() |
||||
const xField = imetaTag.find((f) => f?.startsWith('x ')) |
||||
const yField = imetaTag.find((f) => f?.startsWith('y ')) |
||||
if (xField) width = parseInt(xField.substring(2).trim(), 10) |
||||
if (yField) height = parseInt(yField.substring(2).trim(), 10) |
||||
break |
||||
} |
||||
} |
||||
} |
||||
if (url) break |
||||
} |
||||
|
||||
// file tags (NIP-94 kind 1063)
|
||||
if (!url) { |
||||
const fileTags = event.tags.filter((t) => t[0] === 'file' && t[1]) |
||||
for (const fileTag of fileTags) { |
||||
const candidateUrl = fileTag[1] |
||||
const candidateMimeType = fileTag[2] |
||||
const isGifUrl = |
||||
candidateUrl && |
||||
(candidateUrl.toLowerCase().includes('.gif') || |
||||
candidateUrl.toLowerCase().startsWith('data:image/gif') || |
||||
candidateMimeType === 'image/gif') |
||||
if (isGifUrl) { |
||||
url = candidateUrl |
||||
if (candidateMimeType) mimeType = candidateMimeType |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
// image tags
|
||||
if (!url) { |
||||
const imageTags = event.tags.filter((t) => t[0] === 'image' && t[1]) |
||||
for (const imageTag of imageTags) { |
||||
const candidateUrl = imageTag[1] |
||||
if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) { |
||||
url = candidateUrl |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
// url tag
|
||||
if (!url) { |
||||
const urlTag = event.tags.find((t) => t[0] === 'url' && t[1]) |
||||
if (urlTag?.[1] && urlTag[1].toLowerCase().includes('.gif')) { |
||||
url = urlTag[1] |
||||
} |
||||
} |
||||
|
||||
// content: markdown image or plain URL
|
||||
if (!url) { |
||||
const markdownMatch = event.content.match( |
||||
/!\[[^\]]*\]\((https?:\/\/[^\s<>"')]+\.gif[^\s<>"')]*)\)/i |
||||
) |
||||
if (markdownMatch) { |
||||
url = markdownMatch[1] |
||||
} else { |
||||
const urlMatch = event.content.match(/https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i) |
||||
if (urlMatch) url = urlMatch[0] |
||||
} |
||||
} |
||||
|
||||
if (!url) return null |
||||
|
||||
const urlLower = url.toLowerCase() |
||||
const isGif = |
||||
mimeType === 'image/gif' || |
||||
urlLower.endsWith('.gif') || |
||||
urlLower.includes('.gif?') || |
||||
urlLower.includes('/gif') || |
||||
urlLower.includes('gif') |
||||
if (!isGif) return null |
||||
|
||||
if (!mimeType) { |
||||
const mimeTag = event.tags.find((t) => t[0] === 'm' && t[1]) |
||||
mimeType = mimeTag?.[1] || 'image/gif' |
||||
} |
||||
|
||||
if (!width || !height) { |
||||
const dimTag = event.tags.find((t) => t[0] === 'dim' && t[1]) |
||||
if (dimTag?.[1]) { |
||||
const dims = dimTag[1].split('x') |
||||
if (dims.length >= 2) { |
||||
width = parseInt(dims[0], 10) |
||||
height = parseInt(dims[1], 10) |
||||
} |
||||
} |
||||
} |
||||
|
||||
const sha256Tag = event.tags.find((t) => t[0] === 'x' && t[1]) |
||||
sha256 = sha256Tag?.[1] |
||||
const fallbackTag = event.tags.find((t) => t[0] === 'fallback' && t[1]) |
||||
fallbackUrl = fallbackTag?.[1] |
||||
|
||||
return { |
||||
url, |
||||
fallbackUrl, |
||||
sha256, |
||||
mimeType: mimeType || 'image/gif', |
||||
width, |
||||
height, |
||||
eventId: event.id, |
||||
pubkey: event.pubkey, |
||||
createdAt: event.created_at |
||||
} |
||||
} |
||||
|
||||
const CACHE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes in-memory cache
|
||||
let cachedGifs: GifMetadata[] = [] |
||||
let cacheTime = 0 |
||||
|
||||
/** |
||||
* Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays. |
||||
* Optionally filter by search query (content + tags). |
||||
*/ |
||||
export async function fetchGifs( |
||||
searchQuery?: string, |
||||
limit: number = 50, |
||||
forceRefresh: boolean = false |
||||
): Promise<GifMetadata[]> { |
||||
const useCache = !forceRefresh && cachedGifs.length > 0 && Date.now() - cacheTime < CACHE_MAX_AGE_MS |
||||
if (useCache && !searchQuery) { |
||||
return cachedGifs.slice(0, limit) |
||||
} |
||||
|
||||
const filter = { |
||||
kinds: [ExtendedKind.FILE_METADATA], |
||||
limit: Math.max(limit * 3, 150) |
||||
} |
||||
|
||||
const events = await client.fetchEvents(GIF_RELAY_URLS, filter, { |
||||
eoseTimeout: 8000 |
||||
}) |
||||
|
||||
const seenUrls = new Set<string>() |
||||
const gifs: GifMetadata[] = [] |
||||
|
||||
for (const event of events) { |
||||
const gif = parseGifFromEvent(event) |
||||
if (!gif) continue |
||||
const normalizedUrl = gif.url.split('?')[0].split('#')[0] |
||||
if (seenUrls.has(normalizedUrl)) continue |
||||
seenUrls.add(normalizedUrl) |
||||
|
||||
if (searchQuery) { |
||||
const q = searchQuery.toLowerCase().trim() |
||||
const content = event.content.toLowerCase() |
||||
const tags = event.tags.flat().join(' ').toLowerCase() |
||||
if (!content.includes(q) && !tags.includes(q)) continue |
||||
} |
||||
gifs.push(gif) |
||||
} |
||||
|
||||
gifs.sort((a, b) => b.createdAt - a.createdAt) |
||||
const result = gifs.slice(0, limit) |
||||
|
||||
if (result.length > 0 && !searchQuery) { |
||||
cachedGifs = result |
||||
cacheTime = Date.now() |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
/** Search GIFs by query (same as fetchGifs with query). */ |
||||
export async function searchGifs( |
||||
query: string, |
||||
limit: number = 50, |
||||
forceRefresh: boolean = false |
||||
): Promise<GifMetadata[]> { |
||||
return fetchGifs(query, limit, forceRefresh) |
||||
} |
||||
Loading…
Reference in new issue