Browse Source

fix gif-picker

master
Silberengel 1 month ago
parent
commit
2a4bd9d6f6
  1. 4
      src/lib/components/content/GifPicker.svelte
  2. 168
      src/lib/modules/feed/FeedPage.svelte
  3. 10
      src/lib/services/nostr/config.ts
  4. 271
      src/lib/services/nostr/gif-service.ts
  5. 3
      src/lib/services/nostr/nostr-client.ts

4
src/lib/components/content/GifPicker.svelte

@ -140,10 +140,10 @@ @@ -140,10 +140,10 @@
<div class="gif-empty">
{#if searchQuery}
<p>No GIFs found for "{searchQuery}"</p>
<p class="gif-hint">Try a different search term. The relays were queried but returned no matching kind 94 (NIP94) GIF events.</p>
<p class="gif-hint">Try a different search term. The relays were queried but returned no matching kind 1063 (NIP-94) GIF events.</p>
{:else}
<p>No GIFs available</p>
<p class="gif-hint">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.</p>
<p class="gif-hint">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.</p>
<p class="gif-hint">You can try searching for a specific term, or the relays may not have any GIF events available at this time.</p>
{/if}
</div>

168
src/lib/modules/feed/FeedPage.svelte

@ -35,10 +35,26 @@ @@ -35,10 +35,26 @@
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
let subscriptionId: string | null = $state(null);
let refreshInterval: ReturnType<typeof setInterval> | 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 @@ @@ -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 @@ @@ -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<string, NostrEvent>();
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 @@ @@ -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 @@ @@ -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<string, NostrEvent>();
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;
}

10
src/lib/services/nostr/config.ts

@ -21,6 +21,12 @@ const THREAD_PUBLISH_RELAYS = [ @@ -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 { @@ -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 { @@ -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
};
}

271
src/lib/services/nostr/gif-service.ts

@ -1,11 +1,13 @@ @@ -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 { @@ -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 <URL>", "m <mime-type>", "x <width>", "y <height>", ...]
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", "<URL>"], ["m", "<mime-type>"], 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", "<URL>"]
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 { @@ -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 { @@ -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<GifMetadata[]> {
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<string>(); // 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 @@ -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

3
src/lib/services/nostr/nostr-client.ts

@ -666,7 +666,8 @@ class NostrClient { @@ -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 [];
}

Loading…
Cancel
Save