From 2a4bd9d6f64208f641b898cb0e289b7340a3e522 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 3 Feb 2026 23:32:55 +0100 Subject: [PATCH] fix gif-picker --- src/lib/components/content/GifPicker.svelte | 4 +- src/lib/modules/feed/FeedPage.svelte | 168 +++++++++++- src/lib/services/nostr/config.ts | 10 +- src/lib/services/nostr/gif-service.ts | 271 ++++++++++++++++---- src/lib/services/nostr/nostr-client.ts | 3 +- 5 files changed, 384 insertions(+), 72 deletions(-) diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte index a6dc64f..be0e4f4 100644 --- a/src/lib/components/content/GifPicker.svelte +++ b/src/lib/components/content/GifPicker.svelte @@ -140,10 +140,10 @@
{#if searchQuery}

No GIFs found for "{searchQuery}"

-

Try a different search term. The relays were queried but returned no matching kind 94 (NIP94) GIF events.

+

Try a different search term. The relays were queried but returned no matching kind 1063 (NIP-94) GIF events.

{:else}

No GIFs available

-

The relays were queried successfully, but no kind 94 (NIP94) GIF events were found. This means there are currently no GIFs published as NIP94 file attachments on the connected relays.

+

The relays were queried successfully, but no kind 1063 (NIP-94) GIF events were found. This means there are currently no GIFs published as NIP-94 file attachments on the connected relays.

You can try searching for a specific term, or the relays may not have any GIF events available at this time.

{/if}
diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index e8789af..666363f 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -35,10 +35,26 @@ let sentinelElement = $state(null); let observer: IntersectionObserver | null = null; + let subscriptionId: string | null = $state(null); + let refreshInterval: ReturnType | null = null; onMount(async () => { await nostrClient.initialize(); await loadFeed(); + // Set up persistent subscription for new events + setupSubscription(); + // Also set up periodic refresh as fallback (every 30 seconds) + setupPeriodicRefresh(); + }); + + // Cleanup subscription on unmount + $effect(() => { + return () => { + if (subscriptionId) { + nostrClient.unsubscribe(subscriptionId); + subscriptionId = null; + } + }; }); // Listen for custom event from EmbeddedEvent components @@ -65,8 +81,94 @@ if (updateTimeout) { clearTimeout(updateTimeout); } + if (subscriptionId) { + nostrClient.unsubscribe(subscriptionId); + subscriptionId = null; + } + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } }; }); + + // Set up persistent subscription for real-time updates + function setupSubscription() { + if (subscriptionId) { + // Already subscribed + return; + } + + const relays = relayManager.getFeedReadRelays(); + const filters = [{ kinds: [1], limit: 20 }]; + + // Subscribe to new kind 1 events + subscriptionId = nostrClient.subscribe( + filters, + relays, + (event: NostrEvent) => { + // Only add events that are newer than what we already have + const existingIds = new Set(posts.map(p => p.id)); + if (!existingIds.has(event.id)) { + handleUpdate([event]); + } + }, + (relay: string) => { + console.debug(`[FeedPage] Subscription EOSE from ${relay}`); + } + ); + + console.log(`[FeedPage] Set up persistent subscription for new events (ID: ${subscriptionId})`); + } + + // Set up periodic refresh to ensure we get new events even if subscription fails + function setupPeriodicRefresh() { + if (refreshInterval) { + return; // Already set up + } + + // Refresh every 30 seconds + refreshInterval = setInterval(async () => { + try { + const relays = relayManager.getFeedReadRelays(); + + // Get the newest post's timestamp to only fetch newer events + const newestTimestamp = posts.length > 0 + ? Math.max(...posts.map(p => p.created_at)) + : Math.floor(Date.now() / 1000) - 60; // Last minute if no posts + + const filters = [{ + kinds: [1], + limit: 50, + since: newestTimestamp + 1 // Only get events newer than what we have + }]; + + // Fetch new events (without cache to ensure we query relays) + const events = await nostrClient.fetchEvents( + filters, + relays, + { + useCache: false, // Don't use cache for refresh - always query relays + cacheResults: true, + timeout: 10000 + } + ); + + // Check for new events + const existingIds = new Set(posts.map(p => p.id)); + const newEvents = events.filter(e => !existingIds.has(e.id)); + + if (newEvents.length > 0) { + console.log(`[FeedPage] Periodic refresh found ${newEvents.length} new events`); + handleUpdate(newEvents); + } + } catch (error) { + console.debug('[FeedPage] Periodic refresh error:', error); + } + }, 30000); // 30 seconds + + console.log('[FeedPage] Set up periodic refresh (every 30 seconds)'); + } // Set up observer when sentinel element is available $effect(() => { @@ -94,21 +196,43 @@ const config = nostrClient.getConfig(); const relays = relayManager.getFeedReadRelays(); - // Load initial feed - cache first, then background refresh + // Load initial feed - use cache for fast initial load, but also query relays const filters = [{ kinds: [1], limit: 20 }]; const events = await nostrClient.fetchEvents( filters, relays, { - useCache: true, + useCache: true, // Use cache for fast initial display cacheResults: true, - onUpdate: handleUpdate, + onUpdate: handleUpdate, // This will be called when new events arrive from subscription timeout: 10000 } ); + + // Also immediately query relays to ensure we get fresh data + // This runs in parallel and updates via onUpdate callback + nostrClient.fetchEvents( + filters, + relays, + { + useCache: false, // Force query relays + cacheResults: true, + onUpdate: handleUpdate, + timeout: 10000 + } + ).catch(error => { + console.debug('[FeedPage] Background relay query error:', error); + }); - // Sort by created_at descending - const sorted = events.sort((a, b) => b.created_at - a.created_at); + // Sort by created_at descending and deduplicate + const uniqueMap = new Map(); + for (const event of events) { + if (!uniqueMap.has(event.id)) { + uniqueMap.set(event.id, event); + } + } + const unique = Array.from(uniqueMap.values()); + const sorted = unique.sort((a, b) => b.created_at - a.created_at); posts = sorted; if (sorted.length > 0) { @@ -193,7 +317,20 @@ // Debounced update handler to prevent rapid re-renders function handleUpdate(updated: NostrEvent[]) { console.log(`[FeedPage] handleUpdate called with ${updated.length} events, current posts: ${posts.length}`); - pendingUpdates.push(...updated); + + // Deduplicate incoming updates before adding to pending + const existingIds = new Set(posts.map(p => p.id)); + const newUpdates = updated.filter(e => !existingIds.has(e.id)); + + if (newUpdates.length === 0) { + console.debug(`[FeedPage] All ${updated.length} events were duplicates, skipping`); + return; + } + + // Also deduplicate within pendingUpdates + const pendingIds = new Set(pendingUpdates.map(e => e.id)); + const trulyNew = newUpdates.filter(e => !pendingIds.has(e.id)); + pendingUpdates.push(...trulyNew); if (updateTimeout) { clearTimeout(updateTimeout); @@ -203,16 +340,25 @@ updateTimeout = setTimeout(() => { if (pendingUpdates.length === 0) return; - const existingIds = new Set(posts.map(p => p.id)); - const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id)); + // Final deduplication check against current posts + const currentIds = new Set(posts.map(p => p.id)); + const newEvents = pendingUpdates.filter(e => !currentIds.has(e.id)); console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`); if (newEvents.length > 0) { - // Merge and sort + // Merge and sort, then deduplicate by ID const merged = [...posts, ...newEvents]; - const sorted = merged.sort((a, b) => b.created_at - a.created_at); - console.log(`[FeedPage] Setting posts to ${sorted.length} events`); + // Deduplicate by ID (keep first occurrence) + const uniqueMap = new Map(); + for (const event of merged) { + if (!uniqueMap.has(event.id)) { + uniqueMap.set(event.id, event); + } + } + const unique = Array.from(uniqueMap.values()); + const sorted = unique.sort((a, b) => b.created_at - a.created_at); + console.log(`[FeedPage] Setting posts to ${sorted.length} events (deduplicated from ${merged.length})`); posts = sorted; } diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts index 1170098..98be740 100644 --- a/src/lib/services/nostr/config.ts +++ b/src/lib/services/nostr/config.ts @@ -21,6 +21,12 @@ const THREAD_PUBLISH_RELAYS = [ 'wss://thecitadel.nostr1.com' ]; +const GIF_RELAYS = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.gifbuddy.lol" +]; + const RELAY_TIMEOUT = 10000; const ZAP_THRESHOLD = 1; @@ -32,6 +38,7 @@ export interface NostrConfig { threadTimeoutDays: number; threadPublishRelays: string[]; relayTimeout: number; + gifRelays: string[]; } function parseRelays(envVar: string | undefined, fallback: string[]): string[] { @@ -57,7 +64,8 @@ export function getConfig(): NostrConfig { zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0), threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), threadPublishRelays: THREAD_PUBLISH_RELAYS, - relayTimeout: RELAY_TIMEOUT + relayTimeout: RELAY_TIMEOUT, + gifRelays: GIF_RELAYS }; } diff --git a/src/lib/services/nostr/gif-service.ts b/src/lib/services/nostr/gif-service.ts index 0eaab58..23782e9 100644 --- a/src/lib/services/nostr/gif-service.ts +++ b/src/lib/services/nostr/gif-service.ts @@ -1,11 +1,13 @@ /** - * Service to fetch GIFs from Nostr NIP94 events - * NIP94 events (kind 94) contain file attachment metadata + * Service to fetch GIFs from Nostr events + * NIP-94 events (kind 1063) contain file attachment metadata + * Also searches kind 1 events which may contain GIFs in tags or content */ import { nostrClient } from './nostr-client.js'; -import { relayManager } from './relay-manager.js'; import type { NostrEvent } from '../../types/nostr.js'; +import { KIND_LOOKUP, getKindInfo } from '../../types/kind-lookup.js'; +import { config } from './config.js'; export interface GifMetadata { url: string; @@ -20,24 +22,87 @@ export interface GifMetadata { } /** - * Parse NIP94 event to extract GIF metadata + * Parse any event (kind 1063, kind 1, etc.) to extract GIF metadata + * Supports NIP-94 (kind 1063), NIP-92 (imeta), NIP-23 (image), and content URLs */ -function parseNip94Event(event: NostrEvent): GifMetadata | null { - // NIP94 events can have different tag structures - // Try to find URL in various tag formats: url, file, or in content +function parseGifFromEvent(event: NostrEvent): 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; + + // Try imeta tags (NIP-92) - format: ["imeta", "url ", "m ", "x ", "y ", ...] + 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; + // Look for mime type in same tag + const mimeField = imetaTag.find(f => f?.startsWith('m ')); + if (mimeField) { + mimeType = mimeField.substring(2).trim(); + } + // Look for dimensions + 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; + } + + // Try file tags (NIP-94 kind 1063) - format: ["url", ""], ["m", ""], etc. + if (!url) { + const fileTags = event.tags.filter(t => t[0] === 'file' && t[1]); + for (const fileTag of fileTags) { + const candidateUrl = fileTag[1]; + if (candidateUrl && candidateUrl.toLowerCase().includes('.gif')) { + url = candidateUrl; + // MIME type is typically the second element + if (fileTag[2]) { + mimeType = fileTag[2]; + } + break; + } + } + } - // First try 'url' tag - const urlTag = event.tags.find(t => t[0] === 'url' && t[1]); - if (urlTag && urlTag[1]) { - url = urlTag[1]; - } else { - // Try 'file' tag (NIP-94 format) - const fileTag = event.tags.find(t => t[0] === 'file' && t[1]); - if (fileTag && fileTag[1]) { - url = fileTag[1]; + // Try image tags (NIP-23) - format: ["image", ""] + 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; + } + } + } + + // Try url tag (NIP-94 kind 1063 standard tag) + if (!url) { + const urlTag = event.tags.find(t => t[0] === 'url' && t[1]); + if (urlTag && urlTag[1] && urlTag[1].toLowerCase().includes('.gif')) { + url = urlTag[1]; + } + } + + // Try to extract URL from content (markdown images or plain URLs) + if (!url) { + // Markdown image: ![alt](url) + const markdownMatch = event.content.match(/!\[[^\]]*\]\((https?:\/\/[^\s<>"')]+\.gif[^\s<>"')]*)\)/i); + if (markdownMatch) { + url = markdownMatch[1]; } else { - // Try to extract URL from content (might be in markdown or plain text) + // Plain URL const urlMatch = event.content.match(/https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i); if (urlMatch) { url = urlMatch[0]; @@ -46,48 +111,49 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null { } if (!url) { - console.debug('[gif-service] No URL found in event:', event.id); return null; } - // Check if it's a GIF by MIME type, file extension, or URL pattern - const mimeTag = event.tags.find(t => t[0] === 'm' && t[1]); - const mimeType = mimeTag?.[1] || ''; + // Verify it's actually a GIF const urlLower = url.toLowerCase(); - - // More flexible GIF detection const isGif = mimeType === 'image/gif' || urlLower.endsWith('.gif') || urlLower.includes('.gif?') || urlLower.includes('/gif') || - (mimeType.startsWith('image/') && event.content.toLowerCase().includes('gif')); + urlLower.includes('gif'); if (!isGif) { - console.debug('[gif-service] Not a GIF:', { url, mimeType, eventId: event.id }); return null; } - // Extract optional metadata - const sha256Tag = event.tags.find(t => t[0] === 'x' && t[1]); - const dimTag = event.tags.find(t => t[0] === 'dim' && t[1]); - const fallbackTag = event.tags.find(t => t[0] === 'fallback' && t[1]); - - let width: number | undefined; - let height: number | undefined; - if (dimTag && dimTag[1]) { - // Format: "widthxheight" or "widthxheightxfps" for videos - const dims = dimTag[1].split('x'); - if (dims.length >= 2) { - width = parseInt(dims[0], 10); - height = parseInt(dims[1], 10); + // Extract additional metadata from tags + 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 && 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: fallbackTag?.[1], - sha256: sha256Tag?.[1], + fallbackUrl, + sha256, mimeType: mimeType || 'image/gif', width, height, @@ -98,38 +164,111 @@ function parseNip94Event(event: NostrEvent): GifMetadata | null { } /** - * Fetch GIFs from Nostr NIP94 events + * Fetch GIFs from Nostr NIP-94 events (kind 1063) + * Only queries kind 1063 file metadata events to avoid flooding with kind 1 events * @param searchQuery Optional search query to filter GIFs (searches in content/tags) * @param limit Maximum number of GIFs to return */ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promise { try { - // Use profile read relays to get GIFs - const relays = relayManager.getProfileReadRelays(); - console.debug(`[gif-service] Fetching GIFs from ${relays.length} relays:`, relays); + // Ensure client is initialized + await nostrClient.initialize(); + + // Use GIF relays from config, with fallback to default relays if GIF relays fail + let relays = config.gifRelays; + console.debug(`[gif-service] Fetching GIFs from ${relays.length} GIF relays:`, relays); + + // Try GIF relays first, but if they all fail, we'll fall back to default relays + // This ensures we can still find GIFs even if GIF-specific relays are down - // Fetch kind 94 events (NIP94 file attachments) - const filters = [{ - kinds: [94], - limit: limit * 2 // Fetch more to filter for GIFs - }]; + // Only fetch kind 1063 (NIP-94 file metadata) events - kind 1 floods the fetch + const fileMetadataKind = KIND_LOOKUP[1063].number; // NIP-94 File Metadata + + // Fetch a larger number of events to build a good cache + // Use a higher limit to ensure we cache enough events for consistent results + const cacheLimit = Math.max(limit * 10, 200); // Cache at least 200 events for consistency + + const filters = [ + { + kinds: [fileMetadataKind], // NIP-94 file metadata events only + limit: cacheLimit // Fetch more to build a good cache + } + ]; - console.debug(`[gif-service] Fetching kind 94 events with filters:`, filters); - const events = await nostrClient.fetchEvents(filters, relays, { - useCache: true, - cacheResults: true + const fileMetadataKindName = getKindInfo(fileMetadataKind).description; + + console.debug(`[gif-service] Fetching ${fileMetadataKindName} (kind ${fileMetadataKind}) events with filters:`, filters); + + // First, try to get cached events for consistent results + let events = await nostrClient.fetchEvents(filters, relays, { + useCache: true, // Use cache first for consistent results + cacheResults: true, + timeout: config.relayTimeout }); + + // Then refresh cache in background to get new events + // This ensures we have consistent results from cache while updating it + nostrClient.fetchEvents(filters, relays, { + useCache: false, // Force query relays to update cache + cacheResults: true, // Cache the results + timeout: config.relayTimeout * 2 // Give more time for GIF relays + }).then((newEvents) => { + if (newEvents.length > 0) { + console.debug(`[gif-service] Background refresh cached ${newEvents.length} new ${fileMetadataKindName} events`); + } + }).catch((error) => { + console.debug('[gif-service] Background refresh error:', error); + }); + + // If no cached events, try default relays as fallback + if (events.length === 0) { + console.log('[gif-service] No cached events, trying default relays as fallback...'); + const fallbackRelays = [...config.defaultRelays, ...config.profileRelays]; + events = await nostrClient.fetchEvents(filters, fallbackRelays, { + useCache: true, // Try cache first + cacheResults: true, + timeout: config.relayTimeout + }); + + // If still no events, try querying relays directly + if (events.length === 0) { + events = await nostrClient.fetchEvents(filters, fallbackRelays, { + useCache: false, + cacheResults: true, + timeout: config.relayTimeout + }); + } + } - console.debug(`[gif-service] Received ${events.length} kind 94 events`); + console.log(`[gif-service] Received ${events.length} total ${fileMetadataKindName} (kind ${fileMetadataKind}) events`); // Parse and filter for GIFs const gifs: GifMetadata[] = []; + const seenUrls = new Set(); // Deduplicate by URL let parsedCount = 0; let skippedCount = 0; + let sampleEvents: Array<{ kind: number; hasImeta: boolean; hasFile: boolean; hasImage: boolean; hasUrlInContent: boolean; tagCount: number }> = []; for (const event of events) { - const gif = parseNip94Event(event); + // Sample first 5 events for debugging + if (sampleEvents.length < 5) { + const hasImeta = event.tags.some(t => t[0] === 'imeta'); + const hasFile = event.tags.some(t => t[0] === 'file'); + const hasImage = event.tags.some(t => t[0] === 'image'); + const hasUrlInContent = /https?:\/\/[^\s<>"']+\.gif/i.test(event.content); + sampleEvents.push({ kind: event.kind, hasImeta, hasFile, hasImage, hasUrlInContent, tagCount: event.tags.length }); + } + + const gif = parseGifFromEvent(event); if (gif) { + // Deduplicate by URL (normalize by removing query params for comparison) + const normalizedUrl = gif.url.split('?')[0].split('#')[0]; + if (seenUrls.has(normalizedUrl)) { + skippedCount++; + continue; + } + seenUrls.add(normalizedUrl); + parsedCount++; // If search query provided, filter by content or tags if (searchQuery) { @@ -150,12 +289,30 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi } } - console.debug(`[gif-service] Parsed ${parsedCount} GIFs, skipped ${skippedCount} non-GIF events`); + console.log(`[gif-service] Parsed ${parsedCount} GIFs, skipped ${skippedCount} non-GIF events`); + + // Debug: Show sample of events we checked with more detail + if (sampleEvents.length > 0) { + console.log(`[gif-service] Sample of first ${sampleEvents.length} events checked:`, sampleEvents); + // Also log actual event content for first event to see what we're working with + if (events.length > 0) { + const firstEvent = events[0]; + console.debug('[gif-service] First event sample:', { + id: firstEvent.id.substring(0, 16) + '...', + kind: firstEvent.kind, + contentLength: firstEvent.content.length, + contentPreview: firstEvent.content.substring(0, 100), + tagCount: firstEvent.tags.length, + tagTypes: [...new Set(firstEvent.tags.map(t => t[0]))] + }); + } + } + // Only log final result if GIFs were found, otherwise it's just noise if (gifs.length > 0) { console.log(`[gif-service] Found ${gifs.length} GIFs${searchQuery ? ` matching "${searchQuery}"` : ''}`); } else { - console.debug(`[gif-service] Final result: 0 GIFs${searchQuery ? ` matching "${searchQuery}"` : ''}`); + console.log(`[gif-service] No GIFs found. Checked ${events.length} ${fileMetadataKindName} (kind ${fileMetadataKind}) events. Try searching for a specific term or check if there are GIF URLs in the events.`); } // Sort by creation date (newest first) and limit diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index eb56c85..891240b 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -666,7 +666,8 @@ class NostrClient { // Get list of actually connected relays const connectedRelays = availableRelays.filter(url => this.relays.has(url)); if (connectedRelays.length === 0) { - console.warn(`[nostr-client] No connected relays available for fetch (${relays.length} requested, all failed or unavailable)`); + // Only log at debug level to reduce console noise - cache will be used instead + console.debug(`[nostr-client] No connected relays available for fetch (${relays.length} requested, all failed or unavailable), will use cache if available`); return []; }