diff --git a/public/healthz.json b/public/healthz.json index 2b71ff3..c988467 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-04T11:12:13.164Z", + "buildTime": "2026-02-04T11:59:22.072Z", "gitCommit": "unknown", - "timestamp": 1770203533165 + "timestamp": 1770206362072 } \ No newline at end of file diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte index 6f8eef4..c0a2942 100644 --- a/src/lib/components/content/GifPicker.svelte +++ b/src/lib/components/content/GifPicker.svelte @@ -1,11 +1,12 @@ {#if open} @@ -295,6 +627,116 @@ {/if} + +{#if showMetadataForm && pendingUpload} +
e.key === 'Escape' && cancelMetadataForm()} + role="button" + tabindex="0" + aria-label="Close metadata form" + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="metadata-modal-title" + tabindex="-1" + > + + +
+
+{/if} + diff --git a/src/lib/services/cache/cache-manager.ts b/src/lib/services/cache/cache-manager.ts index d7ca4af..a9a5ab0 100644 --- a/src/lib/services/cache/cache-manager.ts +++ b/src/lib/services/cache/cache-manager.ts @@ -19,11 +19,80 @@ export interface CacheStats { * Get statistics about the cache */ export async function getCacheStats(): Promise { - const db = await getDB(); - const tx = db.transaction('events', 'readonly'); - const store = tx.store; + // Retry logic to handle transaction conflicts + const maxRetries = 3; + let lastError: Error | null = null; - const stats: CacheStats = { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const db = await getDB(); + const tx = db.transaction('events', 'readonly'); + const store = tx.store; + + const stats: CacheStats = { + totalEvents: 0, + eventsByKind: new Map(), + eventsByPubkey: new Map(), + oldestEvent: null, + newestEvent: null, + totalSize: 0 + }; + + let count = 0; + + // Process events during transaction - transaction must stay active during iteration + for await (const cursor of store.iterate()) { + const event = cursor.value as CachedEvent; + count++; + + // Count by kind + const kindCount = stats.eventsByKind.get(event.kind) || 0; + stats.eventsByKind.set(event.kind, kindCount + 1); + + // Count by pubkey + const pubkeyCount = stats.eventsByPubkey.get(event.pubkey) || 0; + stats.eventsByPubkey.set(event.pubkey, pubkeyCount + 1); + + // Track oldest/newest + if (stats.oldestEvent === null || event.created_at < stats.oldestEvent) { + stats.oldestEvent = event.created_at; + } + if (stats.newestEvent === null || event.created_at > stats.newestEvent) { + stats.newestEvent = event.created_at; + } + + // Estimate size (rough calculation) + const eventSize = JSON.stringify(event).length; + stats.totalSize += eventSize; + } + + // Wait for transaction to complete + await tx.done; + + stats.totalEvents = count; + + return stats; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // If it's a transaction error and we have retries left, wait and retry + if (error instanceof DOMException && + error.message.includes('transaction') && + attempt < maxRetries - 1) { + // Wait before retrying with exponential backoff + await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1))); + continue; + } + + // If it's not a transaction error or we're out of retries, break + break; + } + } + + // If we get here, all retries failed + console.error('Error getting cache stats (all retries failed):', lastError); + // Return empty stats on error + return { totalEvents: 0, eventsByKind: new Map(), eventsByPubkey: new Map(), @@ -31,37 +100,6 @@ export async function getCacheStats(): Promise { newestEvent: null, totalSize: 0 }; - - let count = 0; - for await (const cursor of store.iterate()) { - const event = cursor.value as CachedEvent; - count++; - - // Count by kind - const kindCount = stats.eventsByKind.get(event.kind) || 0; - stats.eventsByKind.set(event.kind, kindCount + 1); - - // Count by pubkey - const pubkeyCount = stats.eventsByPubkey.get(event.pubkey) || 0; - stats.eventsByPubkey.set(event.pubkey, pubkeyCount + 1); - - // Track oldest/newest - if (stats.oldestEvent === null || event.created_at < stats.oldestEvent) { - stats.oldestEvent = event.created_at; - } - if (stats.newestEvent === null || event.created_at > stats.newestEvent) { - stats.newestEvent = event.created_at; - } - - // Estimate size (rough calculation) - const eventSize = JSON.stringify(event).length; - stats.totalSize += eventSize; - } - - stats.totalEvents = count; - await tx.done; - - return stats; } /** @@ -148,6 +186,30 @@ export async function clearCacheByKind(kind: number): Promise { return deleted; } +/** + * Clear events by multiple kinds + */ +export async function clearCacheByKinds(kinds: number[]): Promise { + const db = await getDB(); + const tx = db.transaction('events', 'readwrite'); + const store = tx.store; + + let deleted = 0; + const kindSet = new Set(kinds); + + // Iterate through all events and delete those matching any of the specified kinds + for await (const cursor of store.iterate()) { + const event = cursor.value as CachedEvent; + if (kindSet.has(event.kind)) { + await cursor.delete(); + deleted++; + } + } + + await tx.done; + return deleted; +} + /** * Clear events older than timestamp */ diff --git a/src/lib/services/nostr/auth-handler.ts b/src/lib/services/nostr/auth-handler.ts index 8cdd318..f1b5138 100644 --- a/src/lib/services/nostr/auth-handler.ts +++ b/src/lib/services/nostr/auth-handler.ts @@ -139,6 +139,32 @@ async function loadUserPreferences(pubkey: string): Promise { } } +/** + * Sign HTTP auth (NIP-98) for authenticated HTTP requests + * Returns Authorization header value: "Nostr " + */ +export async function signHttpAuth( + url: string, + method: string, + description: string = '' +): Promise { + const event = await sessionManager.signEvent({ + kind: KIND.HTTP_AUTH, + pubkey: sessionManager.getCurrentPubkey()!, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', url], + ['method', method] + ], + content: description + }); + + // Base64 encode the event JSON and return as "Nostr " + const eventJson = JSON.stringify(event); + const base64 = btoa(eventJson); + return `Nostr ${base64}`; +} + /** * Sign and publish event */ diff --git a/src/lib/services/nostr/event-hierarchy.ts b/src/lib/services/nostr/event-hierarchy.ts index 9c1a635..6c99d7a 100644 --- a/src/lib/services/nostr/event-hierarchy.ts +++ b/src/lib/services/nostr/event-hierarchy.ts @@ -16,65 +16,207 @@ export interface EventHierarchy { /** * Build full event hierarchy starting from a given event - * Recursively fetches parent events until reaching root (no references) + * Optimized to batch-fetch all parent events in parallel */ export async function buildEventHierarchy(event: NostrEvent): Promise { - const hierarchy: EventHierarchy = { - event, - children: [] - }; + const eventsMap = new Map(); + const replaceableEventsToFetch = new Map(); + const maxDepth = 20; // Prevent infinite loops - // Build parent chain - const parent = await findParentEvent(event); - if (parent) { - hierarchy.parent = await buildEventHierarchy(parent); + // Helper to get parent reference from an event + function getParentReference(evt: NostrEvent): { type: 'e' | 'q' | 'a'; value: string } | null { + // Check for e-tag (reply to event) - prioritize this + const eTag = evt.tags.find(t => t[0] === 'e' && t[1]); + if (eTag && eTag[1]) { + return { type: 'e', value: eTag[1] }; + } + + // Check for q-tag (quoted event) + const qTag = evt.tags.find(t => t[0] === 'q' && t[1]); + if (qTag && qTag[1]) { + return { type: 'q', value: qTag[1] }; + } + + // Check for a-tag (reply to replaceable event) + const aTag = evt.tags.find(t => t[0] === 'a' && t[1]); + if (aTag && aTag[1]) { + return { type: 'a', value: aTag[1] }; + } + + return null; } - return hierarchy; + // Collect all parent IDs we need to fetch (breadth-first) + const eventIdsToFetch = new Set(); + const visitedIds = new Set(); + + function collectParentIds(evt: NostrEvent, depth: number): void { + if (depth > maxDepth || visitedIds.has(evt.id)) { + return; + } + visitedIds.add(evt.id); + + const parentRef = getParentReference(evt); + if (!parentRef) { + return; // No parent + } + + if (parentRef.type === 'a') { + // Parse a-tag: kind:pubkey:d-tag + const parts = parentRef.value.split(':'); + if (parts.length === 3) { + const kind = parseInt(parts[0], 10); + const pubkey = parts[1]; + const dTag = parts[2]; + if (!isNaN(kind) && pubkey && dTag) { + const key = `${kind}:${pubkey}:${dTag}`; + if (!replaceableEventsToFetch.has(key)) { + replaceableEventsToFetch.set(key, { kind, pubkey, dTag }); + } + } + } + } else { + // e-tag or q-tag - add to fetch list + if (!visitedIds.has(parentRef.value)) { + eventIdsToFetch.add(parentRef.value); + } + } + } + + // Start collecting from the root event + eventsMap.set(event.id, event); + collectParentIds(event, 0); + + // Iteratively fetch events and discover more parents + const relays = relayManager.getProfileReadRelays(); + let depth = 0; + + while ((eventIdsToFetch.size > 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) { + // Check cache for event IDs first + const uncachedIds: string[] = []; + for (const eventId of eventIdsToFetch) { + if (eventsMap.has(eventId)) { + continue; // Already have it + } + + const cached = await getEvent(eventId); + if (cached) { + eventsMap.set(eventId, cached); + collectParentIds(cached, depth + 1); + } else { + uncachedIds.push(eventId); + } + } + + // Batch fetch uncached events + if (uncachedIds.length > 0) { + try { + const fetchedEvents = await nostrClient.fetchEvents( + [{ ids: uncachedIds, limit: uncachedIds.length }], + relays, + { useCache: true, cacheResults: true } + ); + + for (const fetchedEvent of fetchedEvents) { + eventsMap.set(fetchedEvent.id, fetchedEvent); + collectParentIds(fetchedEvent, depth + 1); + } + } catch (error) { + console.warn('Error batch fetching events:', error); + } + } + + // Fetch replaceable events + const replaceableKeys = Array.from(replaceableEventsToFetch.keys()); + for (const key of replaceableKeys) { + const { kind, pubkey, dTag } = replaceableEventsToFetch.get(key)!; + replaceableEventsToFetch.delete(key); + + // Check if we already have this event in the map + let found = false; + for (const [id, candidate] of eventsMap.entries()) { + if (candidate.kind === kind && candidate.pubkey === pubkey) { + const candidateDTag = candidate.tags.find(t => t[0] === 'd' && t[1]); + if (candidateDTag && candidateDTag[1] === dTag) { + found = true; + collectParentIds(candidate, depth + 1); + break; + } + } + } + + if (!found) { + try { + const fetchedEvents = await nostrClient.fetchEvents( + [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], + relays, + { useCache: true, cacheResults: true } + ); + + if (fetchedEvents.length > 0) { + const replaceableEvent = fetchedEvents.sort((a, b) => b.created_at - a.created_at)[0]; + eventsMap.set(replaceableEvent.id, replaceableEvent); + collectParentIds(replaceableEvent, depth + 1); + } + } catch (error) { + console.warn('Error fetching replaceable event:', error); + } + } + } + + // Remove fetched IDs from the set + for (const id of Array.from(eventIdsToFetch)) { + if (eventsMap.has(id)) { + eventIdsToFetch.delete(id); + } + } + + depth++; + } + + // Now build the hierarchy from the fetched events + return buildHierarchyFromMap(event, eventsMap); } /** - * Get all events in hierarchy as a flat array (root to leaf) + * Build hierarchy structure from events map */ -export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] { - const chain: NostrEvent[] = []; - - // Build chain from root to leaf - let current: EventHierarchy | undefined = hierarchy; - const parents: EventHierarchy[] = []; +function buildHierarchyFromMap(event: NostrEvent, eventsMap: Map): EventHierarchy { + const hierarchy: EventHierarchy = { + event, + children: [] + }; - // Collect all parents - while (current) { - parents.unshift(current); - current = current.parent; + // Find parent event + const parent = findParentInMap(event, eventsMap); + if (parent) { + hierarchy.parent = buildHierarchyFromMap(parent, eventsMap); } - // Return chain from root to leaf - return parents.map(h => h.event); + return hierarchy; } /** - * Find parent event by following e-tags, q-tags, or a-tags + * Find parent event in events map */ -async function findParentEvent(event: NostrEvent): Promise { - // Check for e-tag (reply to event) +function findParentInMap(event: NostrEvent, eventsMap: Map): NostrEvent | null { + // Check for e-tag (reply to event) - prioritize this const eTag = event.tags.find(t => t[0] === 'e' && t[1]); if (eTag && eTag[1]) { - const parent = await fetchEventById(eTag[1]); + const parent = eventsMap.get(eTag[1]); if (parent) return parent; } // Check for q-tag (quoted event) const qTag = event.tags.find(t => t[0] === 'q' && t[1]); if (qTag && qTag[1]) { - const parent = await fetchEventById(qTag[1]); + const parent = eventsMap.get(qTag[1]); if (parent) return parent; } // Check for a-tag (reply to replaceable event) const aTag = event.tags.find(t => t[0] === 'a' && t[1]); if (aTag && aTag[1]) { - // Parse a-tag: kind:pubkey:d-tag const parts = aTag[1].split(':'); if (parts.length === 3) { const kind = parseInt(parts[0], 10); @@ -82,64 +224,40 @@ async function findParentEvent(event: NostrEvent): Promise { const dTag = parts[2]; if (!isNaN(kind) && pubkey && dTag) { - const parent = await fetchReplaceableEvent(kind, pubkey, dTag); - if (parent) return parent; + // Find replaceable event in map by matching kind, pubkey, and d-tag + for (const candidate of eventsMap.values()) { + if (candidate.kind === kind && candidate.pubkey === pubkey) { + const candidateDTag = candidate.tags.find(t => t[0] === 'd' && t[1]); + if (candidateDTag && candidateDTag[1] === dTag) { + return candidate; + } + } + } } } } - // No parent found return null; } /** - * Fetch event by ID (check cache first, then relays) + * Get all events in hierarchy as a flat array (root to leaf) */ -async function fetchEventById(eventId: string): Promise { - // Check cache first - const cached = await getEvent(eventId); - if (cached) { - return cached; - } +export function getHierarchyChain(hierarchy: EventHierarchy): NostrEvent[] { + const chain: NostrEvent[] = []; - // Fetch from relays - try { - const relays = relayManager.getProfileReadRelays(); - const events = await nostrClient.fetchEvents( - [{ ids: [eventId], limit: 1 }], - relays, - { useCache: true, cacheResults: true } - ); - - return events.length > 0 ? events[0] : null; - } catch (error) { - console.warn('Error fetching event by ID:', error); - return null; - } -} - -/** - * Fetch replaceable event by kind, pubkey, and d-tag - */ -async function fetchReplaceableEvent(kind: number, pubkey: string, dTag: string): Promise { - try { - const relays = relayManager.getProfileReadRelays(); - const events = await nostrClient.fetchEvents( - [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], - relays, - { useCache: true, cacheResults: true } - ); - - // Return newest (replaceable events can have multiple versions) - if (events.length > 0) { - return events.sort((a, b) => b.created_at - a.created_at)[0]; - } - - return null; - } catch (error) { - console.warn('Error fetching replaceable event:', error); - return null; + // Build chain from root to leaf + let current: EventHierarchy | undefined = hierarchy; + const parents: EventHierarchy[] = []; + + // Collect all parents + while (current) { + parents.unshift(current); + current = current.parent; } + + // Return chain from root to leaf + return parents.map(h => h.event); } /** diff --git a/src/lib/services/nostr/gif-service.ts b/src/lib/services/nostr/gif-service.ts index b0b922d..7361041 100644 --- a/src/lib/services/nostr/gif-service.ts +++ b/src/lib/services/nostr/gif-service.ts @@ -59,16 +59,24 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null { if (url) break; } - // Try file tags (NIP-94 kind 1063) - format: ["url", ""], ["m", ""], etc. + // Try file tags (NIP-94 kind 1063) - format: ["file", "", "", "size ", ...] 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')) { + const candidateMimeType = fileTag[2]; // MIME type is typically the third element + + // Check if it's a GIF by URL extension, mime type, or data URL + const isGifUrl = candidateUrl && ( + candidateUrl.toLowerCase().includes('.gif') || + candidateUrl.toLowerCase().startsWith('data:image/gif') || + candidateMimeType === 'image/gif' + ); + + if (isGifUrl) { url = candidateUrl; - // MIME type is typically the second element - if (fileTag[2]) { - mimeType = fileTag[2]; + if (candidateMimeType) { + mimeType = candidateMimeType; } break; } @@ -168,8 +176,9 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null { * 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 + * @param forceRefresh If true, skip cache and query relays directly (useful after uploading new GIFs) */ -export async function fetchGifs(searchQuery?: string, limit: number = 50): Promise { +export async function fetchGifs(searchQuery?: string, limit: number = 50, forceRefresh: boolean = false): Promise { try { // Ensure client is initialized await nostrClient.initialize(); @@ -199,26 +208,38 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi 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 - }); + let events: NostrEvent[]; - // 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 (forceRefresh) { + // Force refresh: skip cache and query relays directly + console.debug('[gif-service] Force refresh: querying relays directly (skipping cache)'); + events = await nostrClient.fetchEvents(filters, relays, { + useCache: false, // Skip cache + cacheResults: true, // Cache the results for next time + timeout: config.relayTimeout * 2 // Give more time for GIF relays + }); + } else { + // First, try to get cached events for consistent results + 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) { @@ -329,6 +350,6 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi /** * Search GIFs by query */ -export async function searchGifs(query: string, limit: number = 50): Promise { - return fetchGifs(query, limit); +export async function searchGifs(query: string, limit: number = 50, forceRefresh: boolean = false): Promise { + return fetchGifs(query, limit, forceRefresh); } diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 5fcb613..3ce74a6 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -136,17 +136,16 @@ class NostrClient { // Check if this relay has failed too many times - skip permanently for this session const failureInfo = this.failedRelays.get(url); if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { - console.debug(`[nostr-client] Relay ${url} has failed ${failureInfo.failureCount} times, skipping for this session`); - throw new Error(`Relay has failed too many times (${failureInfo.failureCount}), skipping for this session`); + // Silently skip - don't throw or log, just return + return; } // Check if this relay has failed recently and we should wait if (failureInfo) { const timeSinceFailure = Date.now() - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { - const waitTime = failureInfo.retryAfter - timeSinceFailure; - console.debug(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`); - throw new Error(`Relay failed recently, retry after ${Math.round(waitTime / 1000)}s`); + // Still in backoff period, silently skip + return; } } @@ -166,7 +165,11 @@ class NostrClient { const ws = (relay as any).ws; if (ws) { ws.addEventListener('close', () => { - console.debug(`[nostr-client] Relay ${url} connection closed, removing from active relays`); + // Only log if relay wasn't already marked as permanently failed + const failureInfo = this.failedRelays.get(url); + if (!failureInfo || failureInfo.failureCount < this.PERMANENT_FAILURE_THRESHOLD) { + // Don't log - connection closes are normal + } this.relays.delete(url); this.authenticatedRelays.delete(url); }); @@ -181,8 +184,7 @@ class NostrClient { // Clear failure tracking on successful connection this.failedRelays.delete(url); - // Log successful connection at debug level to reduce console noise - console.debug(`[nostr-client] Successfully connected to relay: ${url}`); + // Don't log successful connections - too verbose } catch (error) { // Track the failure but don't throw - allow graceful degradation like jumble const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; @@ -207,14 +209,16 @@ class NostrClient { failureCount }); - // Only log at debug level to reduce console noise - connection failures are expected - // Only warn if it's a persistent failure (after several attempts) - if (failureCount > 3) { - console.debug(`[nostr-client] Relay ${url} connection failed (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`); - } - // Warn if approaching permanent failure threshold + // Only log warnings for persistent failures to reduce console noise + // Connection failures are expected and normal, so we don't log every attempt if (failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { - console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`); + // Only log once when threshold is reached + if (failureCount === this.PERMANENT_FAILURE_THRESHOLD) { + console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`); + } + } else if (failureCount === 10) { + // Log once at 10 failures as a warning + console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will stop retrying after ${this.PERMANENT_FAILURE_THRESHOLD} failures`); } // Don't throw - allow graceful degradation like jumble does // The caller can check if relay was added by checking this.relays.has(url) @@ -243,8 +247,7 @@ class NostrClient { // Status values: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED const status = (relay as any).status; if (status === 3 || status === 2) { - // Relay is closed or closing, remove it - console.debug(`[nostr-client] Relay ${relayUrl} is closed (status: ${status}), removing from active relays`); + // Relay is closed or closing, remove it silently this.relays.delete(relayUrl); this.authenticatedRelays.delete(relayUrl); return false; @@ -596,16 +599,27 @@ class NostrClient { // Check if relay should be skipped before attempting connection const failureInfo = this.failedRelays.get(url); if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { - console.debug(`[nostr-client] Skipping permanently failed relay ${url} for subscription`); + // Skip permanently failed relays silently continue; } + // Check if relay failed recently and is still in backoff period + if (failureInfo) { + const timeSinceFailure = Date.now() - failureInfo.lastFailure; + if (timeSinceFailure < failureInfo.retryAfter) { + // Still in backoff period, skip this attempt + continue; + } + } + // addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble) this.addRelay(url).then(() => { const newRelay = this.relays.get(url); if (newRelay) { this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); } + }).catch(() => { + // Ignore errors - addRelay handles failures gracefully }); continue; } @@ -615,7 +629,7 @@ class NostrClient { // Check relay status before setting up subscription if (!this.checkAndCleanupRelay(url)) { - console.debug(`[nostr-client] Relay ${url} is closed, skipping subscription`); + // Relay is closed, skip silently continue; } @@ -942,10 +956,8 @@ class NostrClient { } } return cachedEvents; - } else { - // No cached events - this is expected and normal, so use debug level - console.debug(`[nostr-client] No cached events found for filter:`, filters); } + // No cached events - this is expected and normal, so don't log it } catch (error) { console.error('[nostr-client] Error querying cache:', error); // Continue to fetch from relays diff --git a/src/lib/types/kind-lookup.ts b/src/lib/types/kind-lookup.ts index c6549f7..3c5007c 100644 --- a/src/lib/types/kind-lookup.ts +++ b/src/lib/types/kind-lookup.ts @@ -89,7 +89,8 @@ export const KIND = { EMOJI_PACK: 30030, MUTE_LIST: 10000, BADGES: 30008, - FOLOW_SET: 30000 + FOLOW_SET: 30000, + HTTP_AUTH: 27235 // NIP-98 HTTP Auth (matches nostr-tools and jumble) } as const; export const KIND_LOOKUP: Record = { diff --git a/src/routes/cache/+page.svelte b/src/routes/cache/+page.svelte index d0f966c..03c7e87 100644 --- a/src/routes/cache/+page.svelte +++ b/src/routes/cache/+page.svelte @@ -2,9 +2,13 @@ import Header from '../../lib/components/layout/Header.svelte'; import { onMount } from 'svelte'; import { goto } from '$app/navigation'; - import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js'; + import { getCacheStats, getAllCachedEvents, clearAllCache, clearCacheByKind, clearCacheByKinds, clearCacheByDate, deleteEventById, type CacheStats } from '../../lib/services/cache/cache-manager.js'; import type { CachedEvent } from '../../lib/services/cache/event-cache.js'; - import { KIND } from '../../lib/types/kind-lookup.js'; + import { KIND, getKindInfo } from '../../lib/types/kind-lookup.js'; + import { nip19 } from 'nostr-tools'; + import { sessionManager } from '../../lib/services/auth/session-manager.js'; + import { signAndPublish } from '../../lib/services/nostr/auth-handler.js'; + import type { NostrEvent } from '../../lib/types/nostr.js'; let stats = $state(null); let events = $state([]); @@ -103,7 +107,9 @@ try { await clearAllCache(); events = []; + expandedEvents.clear(); await loadStats(); + await loadEvents(true); alert('Cache cleared successfully'); } catch (error) { console.error('Error clearing cache:', error); @@ -119,8 +125,11 @@ try { const deleted = await clearCacheByKind(kind); + // Remove deleted events from expanded set + events.filter(e => e.kind === kind).forEach(e => expandedEvents.delete(e.id)); events = events.filter(e => e.kind !== kind); await loadStats(); + await loadEvents(true); alert(`Deleted ${deleted} events`); } catch (error) { console.error('Error clearing cache by kind:', error); @@ -136,8 +145,11 @@ try { const olderThan = Math.floor(Date.now() / 1000) - (days * 86400); const deleted = await clearCacheByDate(olderThan); + // Remove deleted events from expanded set + events.filter(e => e.created_at < olderThan).forEach(e => expandedEvents.delete(e.id)); events = events.filter(e => e.created_at >= olderThan); await loadStats(); + await loadEvents(true); alert(`Deleted ${deleted} events`); } catch (error) { console.error('Error clearing cache by date:', error); @@ -145,6 +157,26 @@ } } + async function handleClearShortTextNotes() { + const kinds: number[] = [KIND.SHORT_TEXT_NOTE, KIND.REACTION, KIND.COMMENT]; + if (!confirm(`Are you sure you want to clear all short text notes (1), reactions (7), and comments (1111) from cache?`)) { + return; + } + + try { + const deleted = await clearCacheByKinds(kinds); + // Remove deleted events from expanded set + events.filter(e => kinds.includes(e.kind)).forEach(e => expandedEvents.delete(e.id)); + events = events.filter(e => !kinds.includes(e.kind)); + await loadStats(); + await loadEvents(true); + alert(`Deleted ${deleted} events`); + } catch (error) { + console.error('Error clearing cache by kinds:', error); + alert('Failed to clear cache'); + } + } + function toggleExpand(eventId: string) { if (expandedEvents.has(eventId)) { expandedEvents.delete(eventId); @@ -178,16 +210,201 @@ } function getKindName(kind: number): string { - const kindNames: Record = { - [KIND.METADATA]: 'Metadata', - [KIND.SHORT_TEXT_NOTE]: 'Short Text Note', - [KIND.REACTION]: 'Reaction', - [KIND.DISCUSSION_THREAD]: 'Discussion Thread', - [KIND.COMMENT]: 'Comment', - }; - return kindNames[kind] || `Kind ${kind}`; + return getKindInfo(kind).description; + } + + /** + * Decode bech32 pubkey (npub or nprofile) to hex + */ + function decodePubkeyToHex(input: string): string { + if (!input || input.trim() === '') return ''; + + const trimmed = input.trim(); + + // If it's already hex (64 chars), return as-is + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + // Try to decode bech32 + try { + if (trimmed.startsWith('npub')) { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'npub') { + return decoded.data; + } + } else if (trimmed.startsWith('nprofile')) { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'nprofile') { + return decoded.data.pubkey; + } + } + } catch (e) { + // Not a valid bech32, return as-is for search + } + + return trimmed; + } + + /** + * Decode bech32 event ID (nevent, naddr, or note) to hex + */ + function decodeEventIdToHex(input: string): string { + if (!input || input.trim() === '') return ''; + + const trimmed = input.trim(); + + // If it's already hex (64 chars), return as-is + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + // Try to decode bech32 + try { + if (trimmed.startsWith('nevent')) { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'nevent') { + return decoded.data.id; + } + } else if (trimmed.startsWith('naddr')) { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'naddr') { + // naddr doesn't have an event ID, return empty + return ''; + } + } else if (trimmed.startsWith('note')) { + const decoded = nip19.decode(trimmed); + if (decoded.type === 'note') { + return decoded.data; + } + } + } catch (e) { + // Not a valid bech32, return as-is for search + } + + return trimmed; + } + + /** + * Get npub from hex pubkey + */ + function getNpubFromHex(hex: string): string { + try { + return nip19.npubEncode(hex); + } catch { + return ''; + } + } + + /** + * Handle pubkey filter input - decode bech32 to hex + */ + function handlePubkeyFilterInput(value: string) { + selectedPubkey = value; + const timeout = setTimeout(() => { + const hexPubkey = decodePubkeyToHex(value); + if (hexPubkey !== value) { + selectedPubkey = hexPubkey; + } + handleFilterChange(); + }, 500); + return () => clearTimeout(timeout); + } + + /** + * Handle event search input - decode bech32 to hex + */ + function handleEventSearchInput(value: string) { + searchTerm = value; + const timeout = setTimeout(() => { + const hexId = decodeEventIdToHex(value); + if (hexId !== value && hexId) { + searchTerm = hexId; + } + handleFilterChange(); + }, 500); + return () => clearTimeout(timeout); + } + + /** + * Handle kind click - filter by that kind + */ + function handleKindClick(kind: number) { + selectedKind = kind; + handleFilterChange(); } + /** + * Publish delete request (NIP-09) for own events + */ + async function handlePublishDeleteRequest(event: CachedEvent) { + if (!confirm('Are you sure you want to publish a delete request for this event? This will notify relays to delete it and remove it from cache.')) { + return; + } + + deletingEventId = event.id; + try { + const deleteEvent: Omit = { + kind: KIND.EVENT_DELETION, + pubkey: sessionManager.getCurrentPubkey()!, + created_at: Math.floor(Date.now() / 1000), + tags: [['e', event.id]], + content: '' + }; + + const result = await signAndPublish(deleteEvent); + if (result.success.length > 0) { + // Also delete from cache + try { + await deleteEventById(event.id); + events = events.filter(e => e.id !== event.id); + expandedEvents.delete(event.id); + + // Wait longer for the delete transaction to fully complete before reloading stats + // IndexedDB transactions need time to commit + await new Promise(resolve => setTimeout(resolve, 500)); + + // Retry loading stats with exponential backoff + let retries = 3; + let lastError: Error | null = null; + while (retries > 0) { + try { + await loadStats(); + break; // Success, exit retry loop + } catch (statsError) { + lastError = statsError instanceof Error ? statsError : new Error(String(statsError)); + retries--; + if (retries > 0) { + // Wait before retry with exponential backoff + await new Promise(resolve => setTimeout(resolve, 300 * (4 - retries))); + } + } + } + + if (retries === 0 && lastError) { + console.error('Error reloading stats after delete (all retries failed):', lastError); + // Don't show error to user - stats will update on next manual refresh + } + + alert(`Delete request published to ${result.success.length} relay(s) and event removed from cache`); + } catch (deleteError) { + console.error('Error deleting from cache:', deleteError); + alert(`Delete request published to ${result.success.length} relay(s), but failed to remove from cache: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); + } + } else { + alert('Failed to publish delete request'); + } + } catch (error) { + console.error('Error publishing delete request:', error); + alert('Failed to publish delete request'); + } finally { + deletingEventId = null; + } + } + + // Get current user pubkey + let currentUserPubkey = $derived(sessionManager.getCurrentPubkey()); + function getKindOptions(): number[] { if (!stats) return []; return Array.from(stats.eventsByKind.keys()).sort((a, b) => a - b); @@ -233,12 +450,12 @@

Events by Kind

{#each Array.from(stats.eventsByKind.entries()).sort((a, b) => b[1] - a[1]) as [kind, count]} -
+
handleKindClick(kind)} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleKindClick(kind)}> {getKindName(kind)} ({kind}) {count.toLocaleString()}
- + { - // Debounce search - const timeout = setTimeout(() => handleFilterChange(), 500); - return () => clearTimeout(timeout); - }} - placeholder="Filter by pubkey..." + oninput={(e) => handlePubkeyFilterInput(e.currentTarget.value)} + placeholder="Filter by pubkey (hex, npub, or nprofile)..." class="filter-input" />
- + { - const timeout = setTimeout(() => handleFilterChange(), 500); - return () => clearTimeout(timeout); - }} - placeholder="Search event ID or content..." + oninput={(e) => handleEventSearchInput(e.currentTarget.value)} + placeholder="Search event ID (hex, naddr, nevent, note) or content..." class="filter-input" />
@@ -309,6 +519,9 @@ + @@ -334,37 +547,22 @@
{#each events as event (event.id)}
-
ID: {event.id.substring(0, 16)}... - View
Kind: {getKindName(event.kind)} ({event.kind}) Pubkey: {event.pubkey.substring(0, 16)}... + {#if getNpubFromHex(event.pubkey)} + npub: {getNpubFromHex(event.pubkey)} + {/if} Created: {formatDate(event.created_at)} Cached: {formatDate(event.cached_at / 1000)}
-
- -
{#if expandedEvents.has(event.id)} @@ -372,16 +570,62 @@
{JSON.stringify(event, null, 2)}
- +
+ View + + + {#if currentUserPubkey === event.pubkey} + + {/if} + +
{:else}

{event.content.substring(0, 200)}{event.content.length > 200 ? '...' : ''}

- +
+ View + + + {#if currentUserPubkey === event.pubkey} + + {/if} + +
{/if}
@@ -521,6 +765,18 @@ background: var(--fog-highlight, #f3f4f6); border: 1px solid var(--fog-border, #e5e7eb); border-radius: 0.375rem; + cursor: pointer; + transition: background 0.2s; + } + + .kind-item:hover { + background: var(--fog-accent, #64748b); + color: white; + } + + .kind-item:hover .kind-name, + .kind-item:hover .kind-count { + color: white; } :global(.dark) .kind-item { @@ -632,34 +888,13 @@ background: var(--fog-post, #ffffff); padding: 1rem; position: relative; + overflow: hidden; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; + box-sizing: border-box; } - .copy-button-top { - position: absolute; - top: 0.75rem; - right: 0.75rem; - padding: 0.5rem; - border: 1px solid var(--fog-border, #e5e7eb); - border-radius: 0.375rem; - background: var(--fog-highlight, #f3f4f6); - color: var(--fog-text, #1f2937); - cursor: pointer; - font-size: 1rem; - transition: all 0.2s; - z-index: 10; - } - - :global(.dark) .copy-button-top { - background: var(--fog-dark-highlight, #475569); - border-color: var(--fog-dark-border, #475569); - color: var(--fog-dark-text, #f9fafb); - } - - .copy-button-top:hover { - background: var(--fog-accent, #64748b); - color: white; - border-color: var(--fog-accent, #64748b); - } :global(.dark) .event-card { background: var(--fog-dark-post, #334155); @@ -682,6 +917,9 @@ .event-id { margin-bottom: 0.5rem; color: var(--fog-text, #1f2937); + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; } :global(.dark) .event-id { @@ -695,29 +933,24 @@ padding: 0.25rem 0.5rem; border-radius: 0.25rem; margin: 0 0.5rem; + word-break: break-all; + overflow-wrap: break-word; + max-width: 100%; + display: inline-block; } :global(.dark) .event-id-code { background: var(--fog-dark-highlight, #475569); } - .event-link { - color: var(--fog-accent, #64748b); - text-decoration: none; - margin-left: 0.5rem; - font-size: 0.875rem; - } - - .event-link:hover { - text-decoration: underline; - } - .event-meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.875rem; color: var(--fog-text-light, #6b7280); + word-wrap: break-word; + overflow-wrap: break-word; } :global(.dark) .event-meta { @@ -727,53 +960,31 @@ .event-meta code { font-family: monospace; font-size: 0.8125rem; + word-break: break-all; + overflow-wrap: break-word; + max-width: 100%; } - .event-actions { - display: flex; - gap: 0.5rem; - flex-shrink: 0; - } - - .delete-button { - padding: 0.5rem 0.75rem; - border: 1px solid var(--fog-border, #e5e7eb); - border-radius: 0.375rem; - background: var(--fog-highlight, #f3f4f6); - color: var(--fog-text, #1f2937); - cursor: pointer; - font-size: 0.875rem; - transition: all 0.2s; - } - - :global(.dark) .copy-button, - :global(.dark) .delete-button { - background: var(--fog-dark-highlight, #475569); - border-color: var(--fog-dark-border, #475569); - color: var(--fog-dark-text, #f9fafb); - } - - .copy-button:hover { - background: var(--fog-accent, #64748b); - color: white; - border-color: var(--fog-accent, #64748b); - } - - .delete-button:hover { - background: #ef4444; - color: white; - border-color: #ef4444; - } - - .delete-button:disabled { - opacity: 0.6; - cursor: not-allowed; + .event-meta span { + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; } .event-content { margin-top: 0.75rem; } + .event-actions-bottom { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + align-items: center; + max-width: 100%; + overflow: hidden; + } + .event-json { font-family: monospace; font-size: 0.8125rem; @@ -783,13 +994,17 @@ padding: 1rem; white-space: pre-wrap; word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-all; max-height: 500px; overflow-y: auto; + overflow-x: hidden; user-select: text; -webkit-user-select: text; -moz-user-select: text; -ms-user-select: text; color: var(--fog-text, #1f2937); + max-width: 100%; } :global(.dark) .event-json { @@ -806,14 +1021,16 @@ margin: 0 0 0.5rem 0; color: var(--fog-text-light, #6b7280); font-size: 0.875rem; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: 100%; } :global(.dark) .event-content-preview { color: var(--fog-dark-text-light, #9ca3af); } - .expand-button, - .collapse-button { + .action-button { padding: 0.5rem 1rem; background: var(--fog-highlight, #f3f4f6); border: 1px solid var(--fog-border, #e5e7eb); @@ -821,22 +1038,47 @@ color: var(--fog-text, #1f2937); cursor: pointer; font-size: 0.875rem; + text-decoration: none; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; } - :global(.dark) .expand-button, - :global(.dark) .collapse-button { + :global(.dark) .action-button { background: var(--fog-dark-highlight, #475569); border-color: var(--fog-dark-border, #475569); color: var(--fog-dark-text, #f9fafb); } - .expand-button:hover, - .collapse-button:hover { + .action-button:hover { background: var(--fog-accent, #64748b); color: white; border-color: var(--fog-accent, #64748b); } + .action-button.delete-action:hover { + background: #ef4444; + border-color: #ef4444; + } + + .action-button.delete-request-action { + background: #f59e0b; + border-color: #f59e0b; + color: white; + } + + .action-button.delete-request-action:hover { + background: #d97706; + border-color: #d97706; + } + + .action-button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + .load-more-section { text-align: center; margin-top: 2rem; diff --git a/src/routes/rss/[pubkey]/+page.server.ts b/src/routes/rss/[pubkey]/+server.ts similarity index 97% rename from src/routes/rss/[pubkey]/+page.server.ts rename to src/routes/rss/[pubkey]/+server.ts index 988b0b9..e6fddbc 100644 --- a/src/routes/rss/[pubkey]/+page.server.ts +++ b/src/routes/rss/[pubkey]/+server.ts @@ -1,12 +1,9 @@ import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; -import { getEventsByPubkey } from '../../../lib/services/cache/event-cache.js'; import { stripMarkdown } from '../../../lib/services/text-utils.js'; import type { RequestHandler } from '@sveltejs/kit'; import { KIND } from '../../../lib/types/kind-lookup.js'; -const RSS_FEED_KIND = 10015; - export const GET: RequestHandler = async ({ params, url }) => { const pubkey = params.pubkey;