Browse Source

fix gif service

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

65
src/components/GifPicker/index.tsx

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

2
src/i18n/locales/de.ts

@ -120,6 +120,8 @@ export default {
'Choose a GIF': 'GIF auswählen', 'Choose a GIF': 'GIF auswählen',
'Search GifBuddy for more GIFs': 'Bei GifBuddy nach weiteren GIFs suchen', 'Search GifBuddy for more GIFs': 'Bei GifBuddy nach weiteren GIFs suchen',
'Add your own GIFs': 'Eigene GIFs hinzufügen', '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...', '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.', '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', '{{name}} is not a GIF file': '{{name}} ist keine GIF-Datei',

2
src/i18n/locales/en.ts

@ -174,6 +174,8 @@ export default {
'Choose a GIF': 'Choose a GIF', 'Choose a GIF': 'Choose a GIF',
'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs', 'Search GifBuddy for more GIFs': 'Search GifBuddy for more GIFs',
'Add your own GIFs': 'Add your own 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...', '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.', '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', '{{name}} is not a GIF file': '{{name}} is not a GIF file',

28
src/services/client.service.ts

@ -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) => { return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = [] const events: NEvent[] = []
let resolveTimeout: ReturnType<typeof setTimeout> | null = null let resolveTimeout: ReturnType<typeof setTimeout> | null = null
let firstResultGraceTimeoutId: ReturnType<typeof setTimeout> | null = null
let allEosed = false let allEosed = false
let eoseTime: number | null = null let eoseTime: number | null = null
let eventCount = 0 let eventCount = 0
let resolved = false
let globalTimeoutId: ReturnType<typeof setTimeout> | null = null let globalTimeoutId: ReturnType<typeof setTimeout> | null = null
const resolveWithEvents = () => { const resolveWithEvents = () => {
if (resolved) return
resolved = true
if (resolveTimeout) { if (resolveTimeout) {
clearTimeout(resolveTimeout) clearTimeout(resolveTimeout)
resolveTimeout = null resolveTimeout = null
} }
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
if (globalTimeoutId) { if (globalTimeoutId) {
clearTimeout(globalTimeoutId) clearTimeout(globalTimeoutId)
globalTimeoutId = null globalTimeoutId = null
@ -1148,6 +1159,14 @@ class ClientService extends EventTarget {
onevent?.(evt) onevent?.(evt)
events.push(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) // Check if we're looking for a specific event ID (limit: 1 with ids filter)
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0) const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0)
@ -1157,6 +1176,10 @@ class ClientService extends EventTarget {
// But wait a bit (100ms) in case duplicate events arrive // But wait a bit (100ms) in case duplicate events arrive
if (hasIdFilter && hasLimitOne && events.length > 0 && allEosed) { if (hasIdFilter && hasLimitOne && events.length > 0 && allEosed) {
// We've found the event and received EOSE, wait a short moment then resolve // We've found the event and received EOSE, wait a short moment then resolve
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
if (resolveTimeout) { if (resolveTimeout) {
clearTimeout(resolveTimeout) clearTimeout(resolveTimeout)
} }
@ -1177,6 +1200,11 @@ class ClientService extends EventTarget {
willWait: eoseTimeout willWait: eoseTimeout
}) })
} }
// Clear first-result grace timer; we'll use EOSE timeout instead
if (firstResultGraceTimeoutId) {
clearTimeout(firstResultGraceTimeoutId)
firstResultGraceTimeoutId = null
}
// Clear any existing timeout // Clear any existing timeout
if (resolveTimeout) { if (resolveTimeout) {
clearTimeout(resolveTimeout) clearTimeout(resolveTimeout)

96
src/services/gif.service.ts

@ -1,9 +1,11 @@
/** /**
* Fetch GIFs from Nostr NIP-94 file metadata events (kind 1063). * 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: query GIF relays, parse file/imeta/image/url tags. * 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 { 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 type { Event as NEvent } from 'nostr-tools'
import client from './client.service' import client from './client.service'
@ -19,6 +21,25 @@ export interface GifMetadata {
createdAt: number 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 { function parseGifFromEvent(event: NEvent): GifMetadata | null {
let url: string | undefined let url: string | undefined
let mimeType: string | undefined let mimeType: string | undefined
@ -164,37 +185,62 @@ let cacheTime = 0
/** /**
* Fetch GIFs from Nostr kind 1063 (NIP-94) events on GIF relays. * 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( export async function fetchGifs(
searchQuery?: string, searchQuery?: string,
limit: number = 50, limit: number = 50,
forceRefresh: boolean = false forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> { ): Promise<GifMetadata[]> {
const useCache = !forceRefresh && cachedGifs.length > 0 && Date.now() - cacheTime < CACHE_MAX_AGE_MS const useCache = !forceRefresh && cachedGifs.length > 0 && Date.now() - cacheTime < CACHE_MAX_AGE_MS
if (useCache && !searchQuery) { if (useCache && !searchQuery) {
return cachedGifs.slice(0, limit) return cachedGifs.slice(0, limit)
} }
const filter = { const readUrls = [
kinds: [ExtendedKind.FILE_METADATA], ...GIF_RELAY_URLS,
limit: Math.max(limit * 10, 200) ...extraReadRelayUrls.map((u) => normalizeUrl(u)).filter(Boolean)
} ]
const seen = new Set<string>()
const events = await client.fetchEvents(GIF_RELAY_URLS, filter, { const dedupedUrls = readUrls.filter((u) => {
eoseTimeout: 10000, const n = u.toLowerCase()
globalTimeout: 15000 if (seen.has(n)) return false
seen.add(n)
return true
}) })
const seenUrls = new Set<string>() const fetchOpts = { eoseTimeout: 10000, globalTimeout: 15000 }
const gifs: GifMetadata[] = []
// 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) { for (const event of events) {
const gif = parseGifFromEvent(event) const gif = parseGifFromEvent(event)
if (!gif) continue if (!gif) continue
const normalizedUrl = gif.url.split('?')[0].split('#')[0]
if (seenUrls.has(normalizedUrl)) continue
seenUrls.add(normalizedUrl)
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim()
@ -202,9 +248,17 @@ export async function fetchGifs(
const tags = event.tags.flat().join(' ').toLowerCase() const tags = event.tags.flat().join(' ').toLowerCase()
if (!content.includes(q) && !tags.includes(q)) continue 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) gifs.sort((a, b) => b.createdAt - a.createdAt)
const result = gifs.slice(0, limit) const result = gifs.slice(0, limit)
@ -220,7 +274,9 @@ export async function fetchGifs(
export async function searchGifs( export async function searchGifs(
query: string, query: string,
limit: number = 50, limit: number = 50,
forceRefresh: boolean = false forceRefresh: boolean = false,
extraReadRelayUrls: string[] = [],
userPubkey: string | null = null
): Promise<GifMetadata[]> { ): Promise<GifMetadata[]> {
return fetchGifs(query, limit, forceRefresh) return fetchGifs(query, limit, forceRefresh, extraReadRelayUrls, userPubkey)
} }

Loading…
Cancel
Save