diff --git a/public/healthz.json b/public/healthz.json index ff85e69..2be9069 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.2.0", - "buildTime": "2026-02-06T16:11:30.844Z", + "buildTime": "2026-02-06T16:38:51.661Z", "gitCommit": "unknown", - "timestamp": 1770394290844 + "timestamp": 1770395931661 } \ No newline at end of file diff --git a/src/lib/components/find/SearchAddressableEvents.svelte b/src/lib/components/find/SearchAddressableEvents.svelte new file mode 100644 index 0000000..ac22a38 --- /dev/null +++ b/src/lib/components/find/SearchAddressableEvents.svelte @@ -0,0 +1,646 @@ + + + + + diff --git a/src/lib/components/layout/CacheBadge.svelte b/src/lib/components/layout/CacheBadge.svelte new file mode 100644 index 0000000..62bbf3d --- /dev/null +++ b/src/lib/components/layout/CacheBadge.svelte @@ -0,0 +1,112 @@ + + +{#if clickable} + +{:else} + + Cache + Cache + +{/if} + + diff --git a/src/lib/components/layout/RelayBadge.svelte b/src/lib/components/layout/RelayBadge.svelte new file mode 100644 index 0000000..6a7d1aa --- /dev/null +++ b/src/lib/components/layout/RelayBadge.svelte @@ -0,0 +1,105 @@ + + +{#if clickable} + +{:else} + + {displayName} + +{/if} + + diff --git a/src/lib/components/layout/UnifiedSearch.svelte b/src/lib/components/layout/UnifiedSearch.svelte index a5630e7..65ddb0b 100644 --- a/src/lib/components/layout/UnifiedSearch.svelte +++ b/src/lib/components/layout/UnifiedSearch.svelte @@ -17,7 +17,7 @@ selectedKind?: number | null; // Selected kind for filtering onKindChange?: (kind: number | null) => void; // Callback when kind filter changes hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page) - onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) => void; // Callback for search results (events and profile pubkeys, and relays used) + onSearchResults?: (results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map }) => void; // Callback for search results (events, profile pubkeys, relays used, and relay per event) allowedKinds?: number[]; // Hard-coded list of kinds to search (hides kind dropdown) } @@ -37,6 +37,15 @@ // For collecting results when hideDropdownResults is true let foundEvents: NostrEvent[] = []; let foundProfiles: string[] = []; + // Map to track which relay each event came from + const eventRelayMap = new Map(); + + // Clear results at start of search + function clearResults() { + foundEvents = []; + foundProfiles = []; + eventRelayMap.clear(); + } // For filter mode: resolved search result let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null }); @@ -128,6 +137,9 @@ return; } + // Clear results at start of search + clearResults(); + await nostrClient.initialize(); searching = true; resolving = true; @@ -168,7 +180,15 @@ if (mode === 'search') { if (hideDropdownResults && onSearchResults) { foundEvents = [event]; - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); } else { searchResults = [{ event, matchType: 'Event ID' }]; showResults = true; @@ -219,7 +239,15 @@ } foundEvents = Array.from(allEvents.values()); - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); searching = false; resolving = false; return; @@ -293,7 +321,15 @@ } foundEvents = Array.from(allEvents.values()); - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); searching = false; resolving = false; return; @@ -362,7 +398,15 @@ } foundEvents = Array.from(allEvents.values()); - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); searching = false; resolving = false; return; @@ -423,7 +467,15 @@ } foundEvents = Array.from(allEvents.values()); - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); searching = false; resolving = false; return; @@ -493,7 +545,15 @@ if (mode === 'search') { if (hideDropdownResults && onSearchResults) { foundEvents = [event]; - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); } else { searchResults = [{ event, matchType: 'Event ID' }]; showResults = true; @@ -516,34 +576,84 @@ // 6. Anything else is a full-text search if (mode === 'search') { let allEvents: NostrEvent[] = []; + const uniqueEventIds = new Set(); + + // Helper to add event and notify if needed + const addEvent = (event: NostrEvent, relay?: string) => { + if (!uniqueEventIds.has(event.id)) { + uniqueEventIds.add(event.id); + allEvents.push(event); + + // Track relay source + if (relay) { + eventRelayMap.set(event.id, relay); + } + + // If hideDropdownResults, notify incrementally + if (hideDropdownResults && onSearchResults) { + const queryLower = query.toLowerCase(); + const matches = allEvents.filter(event => { + const contentMatch = event.content.toLowerCase().includes(queryLower); + const titleTag = event.tags.find(t => t[0] === 'title'); + const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; + const summaryTag = event.tags.find(t => t[0] === 'summary'); + const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; + return contentMatch || titleMatch || summaryMatch; + }); + + // Sort and limit + const sorted = matches.sort((a, b) => { + const aExact = a.content.toLowerCase() === queryLower; + const bExact = b.content.toLowerCase() === queryLower; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return b.created_at - a.created_at; + }); + + foundEvents = sorted.slice(0, 100); + // Create a map of event IDs to relay URLs for the found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); + } + } + }; // If kinds are specified, search from relays if (effectiveKinds && effectiveKinds.length > 0) { const relays = relayManager.getAllAvailableRelays(); const queryLower = query.toLowerCase(); - // Search each allowed kind + // Search each allowed kind with onUpdate for incremental results for (const kind of effectiveKinds) { - const events = await nostrClient.fetchEvents( + await nostrClient.fetchEvents( [{ kinds: [kind], limit: 100 }], relays, - { useCache: true, cacheResults: true, timeout: 10000 } + { + useCache: 'cache-first', + cacheResults: true, + timeout: 10000, + onUpdateWithRelay: (eventsWithRelay: Array<{ event: NostrEvent; relay: string }>) => { + // Filter and add events as they arrive with relay info + for (const { event, relay } of eventsWithRelay) { + const contentMatch = event.content.toLowerCase().includes(queryLower); + const titleTag = event.tags.find(t => t[0] === 'title'); + const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; + const summaryTag = event.tags.find(t => t[0] === 'summary'); + const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; + + if (contentMatch || titleMatch || summaryMatch) { + addEvent(event, relay); + } + } + } + } ); - - // Filter by text content - const matches = events.filter(event => { - const contentMatch = event.content.toLowerCase().includes(queryLower); - - const titleTag = event.tags.find(t => t[0] === 'title'); - const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; - - const summaryTag = event.tags.find(t => t[0] === 'summary'); - const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; - - return contentMatch || titleMatch || summaryMatch; - }); - - allEvents.push(...matches); } } else { // Text search in cached events (title, summary, content) @@ -554,12 +664,14 @@ const kindEvents = await getEventsByKind(selectedKind, 100); allCached.push(...kindEvents); } else { - // Search all kinds we handle + // Search all kinds we handle (limit each to 20 to keep total reasonable, then limit final to 100) const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind); for (const kind of kindsToSearch) { try { - const kindEvents = await getEventsByKind(kind, 50); + const kindEvents = await getEventsByKind(kind, 20); allCached.push(...kindEvents); + // Stop if we have enough events already + if (allCached.length >= 100) break; } catch (e) { // Skip kinds that fail } @@ -576,34 +688,49 @@ const summaryTag = event.tags.find(t => t[0] === 'summary'); const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; + // Mark all cached events as coming from cache + if (contentMatch || titleMatch || summaryMatch) { + eventRelayMap.set(event.id, 'cache'); + } + return contentMatch || titleMatch || summaryMatch; }); } - // Sort and limit results - const queryLower = query.toLowerCase(); - const sorted = allEvents.sort((a, b) => { - const aExact = a.content.toLowerCase() === queryLower; - const bExact = b.content.toLowerCase() === queryLower; - if (aExact && !bExact) return -1; - if (!aExact && bExact) return 1; - return b.created_at - a.created_at; - }); - - // Deduplicate by event ID - const uniqueEvents = new Map(); - for (const event of sorted) { - uniqueEvents.set(event.id, event); - } - - const limitedResults = Array.from(uniqueEvents.values()).slice(0, 100); - - if (hideDropdownResults && onSearchResults) { - foundEvents = limitedResults; - onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed }); - } else { - searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); - showResults = true; + // Final sort and limit (only if not already handled incrementally) + if (!(hideDropdownResults && onSearchResults && effectiveKinds && effectiveKinds.length > 0)) { + const queryLower = query.toLowerCase(); + const sorted = allEvents.sort((a, b) => { + const aExact = a.content.toLowerCase() === queryLower; + const bExact = b.content.toLowerCase() === queryLower; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return b.created_at - a.created_at; + }); + + // Deduplicate by event ID + const uniqueEvents = new Map(); + for (const event of sorted) { + uniqueEvents.set(event.id, event); + } + + const limitedResults = Array.from(uniqueEvents.values()).slice(0, 100); + + if (hideDropdownResults && onSearchResults) { + foundEvents = limitedResults; + // Create relay map for found events + const foundEventRelays = new Map(); + for (const event of foundEvents) { + const relay = eventRelayMap.get(event.id); + if (relay) { + foundEventRelays.set(event.id, relay); + } + } + onSearchResults({ events: foundEvents, profiles: [], relays: relaysUsed, eventRelays: foundEventRelays }); + } else { + searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); + showResults = true; + } } } else { // Filter mode: treat as text search diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index aee2ae8..37f250e 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -22,6 +22,7 @@ interface FetchOptions { useCache?: boolean | 'cache-first' | 'relay-first'; // Cache strategy: true/'cache-first' = check cache first (default), 'relay-first' = query relays first then cache fallback, false = no cache cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void; + onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void; // Callback with relay information for each event timeout?: number; priority?: 'high' | 'medium' | 'low'; // Priority level: high for critical UI (comments), low for background (reactions, profiles) caller?: string; // Optional caller identifier for logging (e.g., "topics/[name]/+page.svelte") @@ -928,6 +929,7 @@ class NostrClient { events: Map, timeout: number, onUpdate?: (events: NostrEvent[]) => void, + onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void, priority: 'high' | 'medium' | 'low' = 'medium' ): Promise { return new Promise((resolve) => { @@ -954,7 +956,7 @@ class NostrClient { this.totalActiveRequests++; // Make the request - this.makeRelayRequest(relayUrl, filters, events, timeout, onUpdate) + this.makeRelayRequest(relayUrl, filters, events, timeout, onUpdate, onUpdateWithRelay) .finally(() => { const current = this.activeRequestsPerRelay.get(relayUrl) || 0; if (current > 0) { @@ -985,7 +987,8 @@ class NostrClient { filters: Filter[], events: Map, timeout: number, - onUpdate?: (events: NostrEvent[]) => void + onUpdate?: (events: NostrEvent[]) => void, + onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void ): Promise { const relay = this.relays.get(relayUrl); if (!relay || !this.checkAndCleanupRelay(relayUrl)) { @@ -1126,12 +1129,19 @@ class NostrClient { } // Stream event directly to onUpdate callback immediately - if (onUpdate && !resolved) { + if ((onUpdate || onUpdateWithRelay) && !resolved) { try { const filtered = filterEvents([event]); const zapFiltered = filtered.filter(e => !client.shouldFilterZapReceipt(e)); if (zapFiltered.length > 0) { - onUpdate(zapFiltered); + // Call onUpdate if provided (backward compatible) + if (onUpdate) { + onUpdate(zapFiltered); + } + // Call onUpdateWithRelay if provided (includes relay info) + if (onUpdateWithRelay) { + onUpdateWithRelay(zapFiltered.map(e => ({ event: e, relay: relayUrl }))); + } } } catch (error) { // Ignore errors from onUpdate callback @@ -1475,6 +1485,27 @@ class NostrClient { // Return cached data immediately for fast page loads console.log(`[nostr-client] Fetch complete: ${cachedEvents.length} from cache (instant), fetching from relays in background [${filterDesc}] from [${relayDesc}]`); + // Notify about cached events via onUpdateWithRelay callback (if provided) + // Use "cache" as the relay identifier for cached events + if (options.onUpdateWithRelay) { + try { + options.onUpdateWithRelay(cachedEvents.map(event => ({ event, relay: 'cache' }))); + } catch (error) { + // Ignore errors from onUpdateWithRelay callback + console.debug('[nostr-client] Error in onUpdateWithRelay callback for cached events:', error); + } + } + + // Also call regular onUpdate callback for backward compatibility + if (onUpdate) { + try { + onUpdate(cachedEvents); + } catch (error) { + // Ignore errors from onUpdate callback + console.debug('[nostr-client] Error in onUpdate callback for cached events:', error); + } + } + // Fetch from relays in background to enhance/update results // Don't await this - let it run in background this.fetchFromRelays(filters, relays, { @@ -1485,6 +1516,7 @@ class NostrClient { onUpdate(freshEvents); } }, + onUpdateWithRelay: options.onUpdateWithRelay, timeout: relayTimeout, priority: options.priority }).then((freshEvents) => { @@ -1598,6 +1630,7 @@ class NostrClient { const relayEvents = await this.fetchFromRelays(filters, relays, { cacheResults: cacheResults, onUpdate, + onUpdateWithRelay: options.onUpdateWithRelay, timeout: relayTimeout, priority: options.priority }); @@ -1718,7 +1751,7 @@ class NostrClient { private async fetchFromRelays( filters: Filter[], relays: string[], - options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout: number; priority?: 'high' | 'medium' | 'low' } + options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; onUpdateWithRelay?: (events: Array<{ event: NostrEvent; relay: string }>) => void; timeout: number; priority?: 'high' | 'medium' | 'low' } ): Promise { const timeout = options.timeout || config.relayTimeout; @@ -1847,7 +1880,7 @@ class NostrClient { const MAX_EVENTS_IN_MAP = 1000; for (const relayUrl of connectedRelays) { - await this.throttledRelayRequest(relayUrl, filters, events, timeout, options.onUpdate, priority); + await this.throttledRelayRequest(relayUrl, filters, events, timeout, options.onUpdate, options.onUpdateWithRelay, priority); // Limit Map size - remove oldest if over limit if (events.size > MAX_EVENTS_IN_MAP) { diff --git a/src/routes/find/+page.svelte b/src/routes/find/+page.svelte index 84c16b2..ee326be 100644 --- a/src/routes/find/+page.svelte +++ b/src/routes/find/+page.svelte @@ -3,6 +3,9 @@ import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; import FeedPost from '../../lib/modules/feed/FeedPost.svelte'; import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte'; + import RelayBadge from '../../lib/components/layout/RelayBadge.svelte'; + import CacheBadge from '../../lib/components/layout/CacheBadge.svelte'; + import SearchAddressableEvents from '../../lib/components/find/SearchAddressableEvents.svelte'; import { KIND, KIND_LOOKUP } from '../../lib/types/kind-lookup.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import type { NostrEvent } from '../../lib/types/nostr.js'; @@ -12,7 +15,10 @@ let selectedKindString = $state(''); let unifiedSearchComponent: { triggerSearch: () => void; getFilterResult: () => { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } } | null = $state(null); let searchResults = $state<{ events: NostrEvent[]; profiles: string[]; relays?: string[] }>({ events: [], profiles: [] }); + // Map to track which relay each event came from + const eventRelayMap = new Map(); let searching = $state(false); + let searchTimeout: ReturnType | null = $state(null); // Sync selectedKindString with selectedKind $effect(() => { @@ -38,14 +44,40 @@ function handleSearch() { if (unifiedSearchComponent) { + // Clear previous timeout + if (searchTimeout) { + clearTimeout(searchTimeout); + searchTimeout = null; + } + searching = true; unifiedSearchComponent.triggerSearch(); + + // Set 10 second timeout + searchTimeout = setTimeout(() => { + searching = false; + searchTimeout = null; + }, 10000); } } - function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[] }) { + function handleSearchResults(results: { events: NostrEvent[]; profiles: string[]; relays?: string[]; eventRelays?: Map }) { + // Update results incrementally as they arrive searchResults = results; - searching = false; + + // Update eventRelayMap with relay information from results + if (results.eventRelays) { + for (const [eventId, relay] of results.eventRelays) { + eventRelayMap.set(eventId, relay); + } + } + + // Clear timeout when results arrive (search is complete) + if (searchTimeout) { + clearTimeout(searchTimeout); + searchTimeout = null; + searching = false; + } } // Get all kinds for dropdown (sorted by number) @@ -130,9 +162,20 @@

Events ({searchResults.events.length})

{#each searchResults.events as event} - - - +
+ + + + {#if eventRelayMap.has(event.id)} +
+ {#if eventRelayMap.get(event.id) === 'cache'} + + {:else} + + {/if} +
+ {/if} +
{/each}
@@ -145,6 +188,10 @@ {/if} + +
+ +
@@ -401,14 +448,13 @@ } .event-result-card { - display: block; + display: flex; + flex-direction: column; border: 1px solid var(--fog-border, #e5e7eb); border-radius: 0.375rem; background: var(--fog-post, #ffffff); overflow: hidden; transition: all 0.2s; - text-decoration: none; - color: inherit; } :global(.dark) .event-result-card { @@ -426,6 +472,42 @@ border-color: var(--fog-dark-accent, #94a3b8); } + .event-result-link { + display: block; + text-decoration: none; + color: inherit; + flex: 1; + } + + .event-relay-badge { + padding: 0.5rem 1rem; + border-top: 1px solid var(--fog-border, #e5e7eb); + background: var(--fog-highlight, #f3f4f6); + } + + :global(.dark) .event-relay-badge { + border-color: var(--fog-dark-border, #374151); + background: var(--fog-dark-highlight, #374151); + } + + .event-result-link { + display: block; + text-decoration: none; + color: inherit; + flex: 1; + } + + .event-relay-badge { + padding: 0.5rem 1rem; + border-top: 1px solid var(--fog-border, #e5e7eb); + background: var(--fog-highlight, #f3f4f6); + } + + :global(.dark) .event-relay-badge { + border-color: var(--fog-dark-border, #374151); + background: var(--fog-dark-highlight, #374151); + } + .no-results { padding: 2rem; text-align: center;