Browse Source

fix gif service

imwald
Silberengel 1 month ago
parent
commit
c97b9aa5e7
  1. 91
      src/components/GifPicker/index.tsx
  2. 2
      src/i18n/locales/de.ts
  3. 2
      src/i18n/locales/en.ts
  4. 38
      src/services/client.service.ts
  5. 96
      src/services/gif.service.ts

91
src/components/GifPicker/index.tsx

@ -11,6 +11,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -11,6 +11,7 @@ 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 { normalizeUrl } from '@/lib/url'
import { fetchGifs, searchGifs, type GifMetadata } from '@/services/gif.service'
import mediaUpload from '@/services/media-upload.service'
import { ExternalLink, Loader2, X } from 'lucide-react'
@ -34,7 +35,7 @@ export default function GifPicker({ @@ -34,7 +35,7 @@ export default function GifPicker({
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, pubkey } = useNostr()
const { publish, pubkey, relayList } = useNostr()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [searchInput, setSearchInput] = useState('')
@ -45,17 +46,21 @@ export default function GifPicker({ @@ -45,17 +46,21 @@ export default function GifPicker({
const [uploadError, setUploadError] = useState<string | null>(null)
const [pasteUrl, setPasteUrl] = useState('')
const [publishingPaste, setPublishingPaste] = useState(false)
const [publishDescription, setPublishDescription] = useState('')
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const gifbuddyPopupRef = useRef<Window | null>(null)
const userReadRelays = relayList?.read ?? []
const userWriteRelays = relayList?.write ?? []
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)
? await searchGifs(q.trim(), 50, forceRefresh, userReadRelays, pubkey ?? null)
: await fetchGifs(undefined, 50, forceRefresh, userReadRelays, pubkey ?? null)
setGifs(results)
if (results.length === 0 && !q.trim()) {
setError(
@ -70,7 +75,7 @@ export default function GifPicker({ @@ -70,7 +75,7 @@ export default function GifPicker({
} finally {
setLoading(false)
}
}, [t])
}, [t, userReadRelays, pubkey])
useEffect(() => {
if (!open) return
@ -95,30 +100,37 @@ export default function GifPicker({ @@ -95,30 +100,37 @@ export default function GifPicker({
}
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length || !pubkey) return
const file = e.target.files?.[0]
if (!file || !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 })
if (!file.type.includes('gif') && !file.name.toLowerCase().endsWith('.gif')) {
setUploadError(t('{{name}} is not a GIF file', { name: file.name }))
return
}
const { url } = await mediaUpload.upload(file)
const draft = {
kind: ExtendedKind.FILE_METADATA,
content: publishDescription.trim(),
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)
}
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
setPublishDescription('')
setQuery('')
await loadGifs('', true)
} catch (err) {
@ -160,6 +172,8 @@ export default function GifPicker({ @@ -160,6 +172,8 @@ export default function GifPicker({
if (w) w.addEventListener('beforeunload', () => { clearTimeout(t); window.removeEventListener('message', handler) })
}, [searchInput, onSelect])
const descriptionForPublish = publishDescription.trim()
/** Insert pasted GIF URL and publish kind 1063 so it's added to Nostr GIF library. */
const handlePasteUrlInsert = useCallback(async () => {
const url = pasteUrl.trim()
@ -172,7 +186,7 @@ export default function GifPicker({ @@ -172,7 +186,7 @@ export default function GifPicker({
try {
const draft = {
kind: ExtendedKind.FILE_METADATA,
content: '',
content: descriptionForPublish,
tags: [
['url', url],
['m', 'image/gif'],
@ -180,14 +194,23 @@ export default function GifPicker({ @@ -180,14 +194,23 @@ export default function GifPicker({
],
created_at: Math.floor(Date.now() / 1000)
}
await publish(draft, { specifiedRelayUrls: GIF_RELAY_URLS })
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
const specifiedRelayUrls = writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
await publish(draft, { specifiedRelayUrls })
setPublishDescription('')
} catch {
// ignore; URL was still inserted
} finally {
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish])
}, [pasteUrl, pubkey, onSelect, publish, userWriteRelays, descriptionForPublish])
/** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */
const isDrawer = isSmallScreen
@ -257,6 +280,19 @@ export default function GifPicker({ @@ -257,6 +280,19 @@ export default function GifPicker({
</ScrollArea>
</div>
<div className="flex flex-col gap-2 border-t pt-2 shrink-0">
{isLoggedIn && (
<div className="grid gap-1">
<Label className="text-xs text-muted-foreground">
{t('Description (optional, for search)')}
</Label>
<Input
placeholder={t('e.g. happy birthday, thumbs up')}
value={publishDescription}
onChange={(e) => setPublishDescription(e.target.value)}
className="min-w-0"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Button
type="button"
@ -300,7 +336,6 @@ export default function GifPicker({ @@ -300,7 +336,6 @@ export default function GifPicker({
ref={fileInputRef}
type="file"
accept=".gif,image/gif"
multiple
className="hidden"
onChange={handleUpload}
/>

2
src/i18n/locales/de.ts

@ -120,6 +120,8 @@ export default { @@ -120,6 +120,8 @@ export default {
'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',
'Description (optional, for search)': 'Beschreibung (optional, für Suche)',
'e.g. happy birthday, thumbs up': 'z. B. happy birthday, Daumen hoch',
'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',

2
src/i18n/locales/en.ts

@ -174,6 +174,8 @@ export default { @@ -174,6 +174,8 @@ export default {
'Choose a GIF': 'Choose a GIF',
'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs',
'Add your own GIFs': 'Add your own GIFs',
'Description (optional, for search)': 'Description (optional, for search)',
'e.g. happy birthday, thumbs up': 'e.g. happy birthday, thumbs up',
'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',

38
src/services/client.service.ts

@ -1104,20 +1104,31 @@ class ClientService extends EventTarget { @@ -1104,20 +1104,31 @@ class ClientService extends EventTarget {
})
}
/** Once one relay returns results, give others this long (ms) then resolve with what we have */
const FIRST_RESULT_GRACE_MS = 2000
return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = []
let resolveTimeout: ReturnType<typeof setTimeout> | null = null
let firstResultGraceTimeoutId: ReturnType<typeof setTimeout> | null = null
let allEosed = false
let eoseTime: number | null = null
let eventCount = 0
let resolved = false
let globalTimeoutId: ReturnType<typeof setTimeout> | null = null
const resolveWithEvents = () => {
if (resolved) return
resolved = true
if (resolveTimeout) {
clearTimeout(resolveTimeout)
resolveTimeout = null
}
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
if (globalTimeoutId) {
clearTimeout(globalTimeoutId)
globalTimeoutId = null
@ -1134,7 +1145,7 @@ class ClientService extends EventTarget { @@ -1134,7 +1145,7 @@ class ClientService extends EventTarget {
sub.close()
resolve(events)
}
const sub = this.subscribe(urls, filter, {
onevent(evt) {
eventCount++
@ -1147,16 +1158,28 @@ class ClientService extends EventTarget { @@ -1147,16 +1158,28 @@ class ClientService extends EventTarget {
}
onevent?.(evt)
events.push(evt)
// As soon as one relay returns results, give others 2s then resolve (keeps reqs fast)
if (events.length === 1 && !firstResultGraceTimeoutId) {
firstResultGraceTimeoutId = setTimeout(() => {
firstResultGraceTimeoutId = null
resolveWithEvents()
}, FIRST_RESULT_GRACE_MS)
}
// Check if we're looking for a specific event ID (limit: 1 with ids filter)
const filters = Array.isArray(filter) ? filter : [filter]
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0)
const hasLimitOne = filters.some(f => f.limit === 1)
// If we're searching for a specific event and found it, we can resolve early
// But wait a bit (100ms) in case duplicate events arrive
if (hasIdFilter && hasLimitOne && events.length > 0 && allEosed) {
// We've found the event and received EOSE, wait a short moment then resolve
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
if (resolveTimeout) {
clearTimeout(resolveTimeout)
}
@ -1177,6 +1200,11 @@ class ClientService extends EventTarget { @@ -1177,6 +1200,11 @@ class ClientService extends EventTarget {
willWait: eoseTimeout
})
}
// Clear first-result grace timer; we'll use EOSE timeout instead
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
// Clear any existing timeout
if (resolveTimeout) {
clearTimeout(resolveTimeout)

96
src/services/gif.service.ts

@ -1,9 +1,11 @@ @@ -1,9 +1,11 @@
/**
* Fetch GIFs from Nostr NIP-94 file metadata events (kind 1063).
* Same approach as aitherboard: query GIF relays, parse file/imeta/image/url tags.
* Fetch GIFs from Nostr: kind 1063 (NIP-94 file metadata) and from kind 1 / 1111 (notes/comments that contain GIF URLs).
* Same approach as aitherboard for 1063; for 1/1111 we parse content and tags for .gif URLs.
*/
import { ExtendedKind, GIF_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { kinds } from 'nostr-tools'
import type { Event as NEvent } from 'nostr-tools'
import client from './client.service'
@ -19,6 +21,25 @@ export interface GifMetadata { @@ -19,6 +21,25 @@ export interface GifMetadata {
createdAt: number
}
/** Normalize a GIF URL for deduplication: strip fragment and query, lowercase. */
function normalizeGifUrl(url: string): string {
try {
const withoutFragment = url.split('#')[0].trim()
const withoutQuery = withoutFragment.split('?')[0].trim()
const lower = withoutQuery.toLowerCase()
return lower || url
} catch {
return url
}
}
/** Priority for deduplication: higher wins. Own event > other's event > non-event. */
const GIF_PRIORITY = {
OWN_EVENT: 2,
OTHER_EVENT: 1,
NON_EVENT: 0
} as const
function parseGifFromEvent(event: NEvent): GifMetadata | null {
let url: string | undefined
let mimeType: string | undefined
@ -164,37 +185,62 @@ let cacheTime = 0 @@ -164,37 +185,62 @@ let cacheTime = 0
/**
* Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays.
* Optionally filter by search query (content + tags).
* Deduplicates by normalized URL; when the same GIF appears from multiple sources,
* keeps: 1) user's own events, 2) other users' events, 3) non-event sources.
* @param extraReadRelayUrls - Logged-in user's read relays (inboxes) and local relays to include when fetching.
* @param userPubkey - Current user's pubkey; entries from this pubkey get highest priority when deduping.
*/
export async function fetchGifs(
searchQuery?: string,
limit: number = 50,
forceRefresh: boolean = false
forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): 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 * 10, 200)
}
const events = await client.fetchEvents(GIF_RELAY_URLS, filter, {
eoseTimeout: 10000,
globalTimeout: 15000
const readUrls = [
...GIF_RELAY_URLS,
...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter(Boolean)
]
const seen = new Set<string>()
const dedupedUrls = readUrls.filter((u) => {
const n = u.toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
const seenUrls = new Set<string>()
const gifs: GifMetadata[] = []
const fetchOpts = { eoseTimeout: 10000, globalTimeout: 15000 }
// Two separate requests so kind 1063 isn't overwhelmed by the volume of kind 1/1111
const [events1063, eventsNotes] = await Promise.all([
client.fetchEvents(
dedupedUrls,
{ kinds: [ExtendedKind.FILE_METADATA], limit: Math.max(limit * 10, 200) },
fetchOpts
),
client.fetchEvents(
dedupedUrls,
{
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT],
limit: Math.max(limit * 10, 300)
},
fetchOpts
)
])
const events = [...events1063, ...eventsNotes]
// Map: normalized URL -> { gif, priority }. Higher priority wins when same URL appears multiple times.
const byUrl = new Map<string, { gif: GifMetadata; priority: number }>()
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()
@ -202,9 +248,17 @@ export async function fetchGifs( @@ -202,9 +248,17 @@ export async function fetchGifs(
const tags = event.tags.flat().join(' ').toLowerCase()
if (!content.includes(q) && !tags.includes(q)) continue
}
gifs.push(gif)
const key = normalizeGifUrl(gif.url)
const priority =
userPubkey && event.pubkey === userPubkey ? GIF_PRIORITY.OWN_EVENT : GIF_PRIORITY.OTHER_EVENT
const existing = byUrl.get(key)
if (!existing || priority > existing.priority) {
byUrl.set(key, { gif, priority })
}
}
const gifs = Array.from(byUrl.values()).map((v) => v.gif)
gifs.sort((a, b) => b.createdAt - a.createdAt)
const result = gifs.slice(0, limit)
@ -220,7 +274,9 @@ export async function fetchGifs( @@ -220,7 +274,9 @@ export async function fetchGifs(
export async function searchGifs(
query: string,
limit: number = 50,
forceRefresh: boolean = false
forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> {
return fetchGifs(query, limit, forceRefresh)
return fetchGifs(query, limit, forceRefresh, extraReadRelayUrls, userPubkey)
}

Loading…
Cancel
Save