9 changed files with 717 additions and 14 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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