Browse Source

gif picker and make kind 11 editor more advanced

imwald
Silberengel 3 days ago
parent
commit
9462a05d9a
  1. 228
      src/components/GifPicker/index.tsx
  2. 12
      src/components/PostEditor/PostContent.tsx
  3. 18
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  4. 163
      src/components/TextareaWithMentionAutocomplete/index.tsx
  5. 11
      src/constants.ts
  6. 10
      src/i18n/locales/de.ts
  7. 10
      src/i18n/locales/en.ts
  8. 66
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  9. 213
      src/services/gif.service.ts

228
src/components/GifPicker/index.tsx

@ -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>
)
}

12
src/components/PostEditor/PostContent.tsx

@ -41,7 +41,7 @@ import logger from '@/lib/logger' @@ -41,7 +41,7 @@ import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video } from 'lucide-react'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video, Film } from 'lucide-react'
import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service'
@ -52,6 +52,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -52,6 +52,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import EmojiPickerDialog from '../EmojiPickerDialog'
import GifPicker from '../GifPicker'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Mentions, { extractMentions } from './Mentions'
import PollEditor from './PollEditor'
@ -2259,6 +2260,15 @@ export default function PostContent({ @@ -2259,6 +2260,15 @@ export default function PostContent({
<ImageUp />
</Button>
</Uploader>
<GifPicker
onSelect={(gifUrl) => {
textareaRef.current?.insertText(gifUrl)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
{/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}

18
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -10,6 +10,9 @@ import { SimpleUsername } from '../../../Username' @@ -10,6 +10,9 @@ import { SimpleUsername } from '../../../Username'
export interface MentionListProps {
items: string[]
command: (payload: { id: string; label?: string }) => void
/** When provided, selection is controlled by parent (e.g. for plain textarea @-mentions). */
selectedIndex?: number
onSelectIndex?: (index: number) => void
}
export interface MentionListHandle {
@ -17,7 +20,10 @@ export interface MentionListHandle { @@ -17,7 +20,10 @@ export interface MentionListHandle {
}
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState<number>(0)
const [internalIndex, setInternalIndex] = useState<number>(0)
const isControlled = props.selectedIndex !== undefined
const selectedIndex = isControlled ? props.selectedIndex! : internalIndex
const setSelectedIndex = isControlled ? (n: number) => props.onSelectIndex?.(n) : setInternalIndex
const selectItem = (index: number) => {
const item = props.items[index]
@ -40,8 +46,10 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -40,8 +46,10 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
}
useEffect(() => {
setSelectedIndex(props.items.length ? 0 : -1)
}, [props.items])
if (!isControlled) {
setInternalIndex(props.items.length ? 0 : -1)
}
}, [props.items, isControlled])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
@ -71,8 +79,8 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -71,8 +79,8 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
return (
<ScrollArea
className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onWheel={(e: React.WheelEvent) => e.stopPropagation()}
onTouchMove={(e: React.TouchEvent) => e.stopPropagation()}
>
{props.items.map((item, index) => (
<button

163
src/components/TextareaWithMentionAutocomplete/index.tsx

@ -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

11
src/constants.ts

@ -110,6 +110,13 @@ export const FAST_WRITE_RELAY_URLS = [ @@ -110,6 +110,13 @@ export const FAST_WRITE_RELAY_URLS = [
'wss://nos.lol'
]
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish */
export const GIF_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relay.gifbuddy.lol'
]
export const SEARCHABLE_RELAY_URLS = [
'wss://nostr.sovbit.host',
'wss://freelay.sovbit.host',
@ -170,7 +177,9 @@ export const ExtendedKind = { @@ -170,7 +177,9 @@ export const ExtendedKind = {
APPLICATION_HANDLER_RECOMMENDATION: 31989,
APPLICATION_HANDLER_INFO: 31990,
PAYMENT_INFO: 10133,
FOLLOW_PACK: 39089
FOLLOW_PACK: 39089,
/** NIP-94 File Metadata (e.g. GIFs) */
FILE_METADATA: 1063
}
export const SUPPORTED_KINDS = [

10
src/i18n/locales/de.ts

@ -113,6 +113,16 @@ export default { @@ -113,6 +113,16 @@ export default {
'Picture note requires images': 'Bildnotiz erfordert Bilder',
Relays: 'Relays',
Image: 'Bild',
'Upload Image': 'Bild hochladen',
'Insert emoji': 'Emoji einfügen',
'Insert GIF': 'GIF einfügen',
'Search GIFs': 'GIFs suchen',
'Choose a GIF': 'GIF auswählen',
'Search GifBuddy for more GIFs': 'Bei GifBuddy nach weiteren GIFs suchen',
'Add your own GIFs': 'Eigene GIFs hinzufügen',
'Uploading...': 'Wird hochgeladen...',
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.': 'Keine GIFs gefunden. Suche oder füge eigene hinzu. GIFs stammen von Nostr-Kind-1063-Events (NIP-94) auf GIF-Relays.',
'{{name}} is not a GIF file': '{{name}} ist keine GIF-Datei',
'R & W': 'R & W',
Read: 'Lesen',
Write: 'Schreiben',

10
src/i18n/locales/en.ts

@ -116,6 +116,16 @@ export default { @@ -116,6 +116,16 @@ export default {
'Picture note requires images': 'Picture note requires images',
Relays: 'Relays',
Image: 'Image',
'Upload Image': 'Upload Image',
'Insert emoji': 'Insert emoji',
'Insert GIF': 'Insert GIF',
'Search GIFs': 'Search GIFs',
'Choose a GIF': 'Choose a GIF',
'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs',
'Add your own GIFs': 'Add your own GIFs',
'Uploading...': 'Uploading...',
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.': 'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.',
'{{name}} is not a GIF file': '{{name}} is not a GIF file',
'R & W': 'R & W',
Read: 'Read',
Write: 'Write',

66
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button' @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import TextareaWithMentionAutocomplete from '@/components/TextareaWithMentionAutocomplete'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
@ -10,8 +10,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -10,8 +10,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3, ChevronDown, Check } from 'lucide-react'
import { useState, useEffect, useMemo } from 'react'
import { Hash, X, Users, Code, Coins, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt, Image, Zap, Settings, Book, Network, Car, Eye, Edit3, ChevronDown, Check, ImageUp, Smile } from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -26,6 +26,9 @@ import dayjs from 'dayjs' @@ -26,6 +26,9 @@ import dayjs from 'dayjs'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import RelayIcon from '@/components/RelayIcon'
import GifPicker from '@/components/GifPicker'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import Uploader from '@/components/PostEditor/Uploader'
import logger from '@/lib/logger'
// Utility functions for thread creation
@ -134,6 +137,25 @@ export default function CreateThreadDialog({ @@ -134,6 +137,25 @@ export default function CreateThreadDialog({
const [selectedGroup, setSelectedGroup] = useState<string>('')
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false)
const contentTextareaRef = useRef<HTMLTextAreaElement | null>(null)
const insertAtCursor = (text: string) => {
const ta = contentTextareaRef.current
if (ta) {
const start = ta.selectionStart
const end = ta.selectionEnd
const before = content.slice(0, start)
const after = content.slice(end)
setContent(before + text + after)
setTimeout(() => {
ta.focus()
ta.setSelectionRange(start + text.length, start + text.length)
}, 0)
} else {
setContent((prev) => prev + text)
}
}
// Create combined topics list (predefined + dynamic) with hierarchy
const allAvailableTopics = useMemo(() => {
const combined = [...DISCUSSION_TOPICS]
@ -678,10 +700,40 @@ export default function CreateThreadDialog({ @@ -678,10 +700,40 @@ export default function CreateThreadDialog({
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="space-y-2">
<Textarea
<div className="flex items-center gap-1 mb-1 flex-wrap">
<Uploader
onUploadSuccess={({ url }) => insertAtCursor(url)}
accept="image/*"
>
<Button type="button" variant="outline" size="sm">
<ImageUp className="h-4 w-4 mr-1" />
{t('Upload Image')}
</Button>
</Uploader>
<GifPicker onSelect={(gifUrl) => insertAtCursor(gifUrl)}>
<Button type="button" variant="outline" size="sm">
<Film className="h-4 w-4 mr-1" />
{t('Insert GIF')}
</Button>
</GifPicker>
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (emoji == null) return
const char = typeof emoji === 'string' ? emoji : (emoji as { native?: string }).native ?? String(emoji)
insertAtCursor(char)
}}
>
<Button type="button" variant="outline" size="sm">
<Smile className="h-4 w-4 mr-1" />
{t('Insert emoji')}
</Button>
</EmojiPickerDialog>
</div>
<TextareaWithMentionAutocomplete
ref={contentTextareaRef}
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={setContent}
placeholder={t('Share your thoughts, ask questions, or start a discussion...')}
rows={8}
maxLength={5000}
@ -854,7 +906,7 @@ export default function CreateThreadDialog({ @@ -854,7 +906,7 @@ export default function CreateThreadDialog({
<Checkbox
id={`relay-${relay}`}
checked={isChecked}
onCheckedChange={(checked) => handleRelayCheckedChange(!!checked, relay)}
onCheckedChange={(checked: boolean | 'indeterminate') => handleRelayCheckedChange(!!checked, relay)}
disabled={isLoadingRelays}
/>
<label
@ -958,7 +1010,7 @@ export default function CreateThreadDialog({ @@ -958,7 +1010,7 @@ export default function CreateThreadDialog({
<div className="px-2">
<Slider
value={[minPow]}
onValueChange={(value) => setMinPow(value[0])}
onValueChange={(value: number[]) => setMinPow(value[0])}
max={20}
min={0}
step={1}

213
src/services/gif.service.ts

@ -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…
Cancel
Save