diff --git a/src/routes/highlights/+page.svelte b/src/routes/highlights/+page.svelte index 8d81665..dee8217 100644 --- a/src/routes/highlights/+page.svelte +++ b/src/routes/highlights/+page.svelte @@ -101,7 +101,7 @@ const cachedHighlights = await getRecentCachedEvents([KIND.HIGHLIGHTED_ARTICLE], 60 * 60 * 1000, 100); // 1 hour cache if (cachedHighlights.length > 0) { - // Process cached highlights immediately + // Process cached highlights immediately - show them right away await processHighlightEvents(cachedHighlights); loading = false; // Show cached content immediately error = null; @@ -120,294 +120,79 @@ } currentPage = 1; - try { - const relays = relayManager.getFeedReadRelays(); - const profileRelays = relayManager.getProfileReadRelays(); - const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; - - // Fetch highlight events (kind 9802) - limit 100 - const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; - - // Stream fresh data from relays (progressive enhancement) - const highlightEvents = await nostrClient.fetchEvents( - [highlightFilter], - allRelaysForHighlights, - { - useCache: 'cache-first', // Already shown cache above, now stream updates - cacheResults: true, - timeout: config.standardTimeout, - onUpdate: async (newHighlights) => { - // Process new highlights as they stream in - if (newHighlights && newHighlights.length > 0) { - await processHighlightEvents(newHighlights); - } - } - } - ); - - // Process final results (merge with any streaming updates) - if (highlightEvents && highlightEvents.length > 0) { - await processHighlightEvents(highlightEvents); - } - - // For highlights, we store the highlight event itself, mapped by source event ID - const highlightBySourceEvent = new Map(); - const aTagHighlights = new Map(); - const highlightsWithoutRefs: { highlight: NostrEvent; authorPubkey: string }[] = []; - - let highlightsWithETags = 0; - let highlightsWithATags = 0; - let highlightsWithNoRefs = 0; - - // First pass: extract e-tags and collect a-tags - for (const highlight of highlightEvents) { - let hasRef = false; - - // Extract e-tag (direct event reference) - const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]); - if (eTag && eTag[1]) { - highlightBySourceEvent.set(eTag[1], { highlight, authorPubkey: highlight.pubkey }); - highlightsWithETags++; - hasRef = true; - } - - // Extract a-tag (addressable event: kind:pubkey:d-tag) - const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]); - if (aTag && aTag[1]) { - aTagHighlights.set(aTag[1], { highlight, pubkey: highlight.pubkey }); - if (!hasRef) highlightsWithATags++; - hasRef = true; - } - - if (!hasRef) { - highlightsWithNoRefs++; - highlightsWithoutRefs.push({ highlight, authorPubkey: highlight.pubkey }); - } - } - - - // Second pass: fetch events for a-tags in batches (grouped by kind+pubkey+d-tag) - if (aTagHighlights.size > 0) { - // Group a-tags by kind+pubkey+d-tag to create efficient filters - const aTagGroups = new Map(); - - for (const [aTag, info] of aTagHighlights.entries()) { - const aTagParts = aTag.split(':'); - if (aTagParts.length >= 2) { - const kind = parseInt(aTagParts[0]); - const pubkey = aTagParts[1]; - const dTag = aTagParts[2] || ''; - - const groupKey = `${kind}:${pubkey}:${dTag}`; - - if (!aTagGroups.has(groupKey)) { - aTagGroups.set(groupKey, { - aTags: [], - pubkey: info.pubkey, - kind, - dTag: dTag || undefined - }); - } - aTagGroups.get(groupKey)!.aTags.push(aTag); - } - } - - // Create batched filters (one per group) - const aTagFilters: any[] = []; - const filterToATags = new Map(); - - for (const [groupKey, group] of aTagGroups.entries()) { - const firstATag = group.aTags[0]; - const aTagParts = firstATag.split(':'); - if (aTagParts.length >= 2) { - const pubkey = aTagParts[1]; - - const filter: any = { - kinds: [group.kind], - authors: [pubkey], - limit: 100 - }; - - if (group.dTag) { - filter['#d'] = [group.dTag]; - } - - const filterIndex = aTagFilters.length; - aTagFilters.push(filter); - filterToATags.set(filterIndex, group.aTags); - } - } + // Start fetching fresh data in the background (non-blocking) + // This enhances the cached content progressively + const enhanceHighlights = async () => { + try { + const relays = relayManager.getFeedReadRelays(); + const profileRelays = relayManager.getProfileReadRelays(); + const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; - // Fetch all a-tag events in one batch - if (aTagFilters.length > 0) { - try { - const aTagEvents = await nostrClient.fetchEvents( - aTagFilters, - allRelaysForHighlights, - { - useCache: 'cache-first', - cacheResults: true, - timeout: config.standardTimeout - } - ); - - // Match a-tag events back to highlights - const eventToATag = new Map(); - - for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) { - const filter = aTagFilters[filterIndex]; - const aTags = filterToATags.get(filterIndex) || []; - const kind = filter.kinds[0]; - const pubkey = filter.authors[0]; - const dTag = filter['#d']?.[0]; - - const matchingEvents = aTagEvents.filter(event => - event.kind === kind && - event.pubkey === pubkey && - (!dTag || event.tags.find(t => t[0] === 'd' && t[1] === dTag)) - ); - - for (const event of matchingEvents) { - for (const aTag of aTags) { - const aTagParts = aTag.split(':'); - if (aTagParts.length >= 2) { - const aTagKind = parseInt(aTagParts[0]); - const aTagPubkey = aTagParts[1]; - const aTagDTag = aTagParts[2] || ''; - - if (event.kind === aTagKind && event.pubkey === aTagPubkey) { - if (aTagDTag) { - const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]); - if (eventDTag && eventDTag[1] === aTagDTag) { - eventToATag.set(event.id, aTag); - break; - } - } else { - eventToATag.set(event.id, aTag); - break; - } - } - } - } - } - } - - // Map events to highlights - for (const [eventId, aTag] of eventToATag.entries()) { - const info = aTagHighlights.get(aTag); - if (info) { - highlightBySourceEvent.set(eventId, { highlight: info.highlight, authorPubkey: info.pubkey }); - } - } - - } catch (err) { - // Non-critical: a-tag resolution failed, continue with e-tags only - } - } - } - - // Get source event IDs for highlights (to fetch them for sorting/display) - const highlightSourceEventIds = Array.from(highlightBySourceEvent.keys()); - - // Limit to maxTotalItems - const eventIds = highlightSourceEventIds.slice(0, maxTotalItems); - - // Fetch the actual events - batch to avoid relay limits - const batchSize = 100; - const allFetchedEvents: NostrEvent[] = []; - - for (let i = 0; i < eventIds.length; i += batchSize) { - const batch = eventIds.slice(i, i + batchSize); - const filters = [{ ids: batch }]; + // Fetch highlight events (kind 9802) - limit 100 + const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; - const batchEvents = await nostrClient.fetchEvents( - filters, - relays, + // Stream fresh data from relays (progressive enhancement) + const highlightEvents = await nostrClient.fetchEvents( + [highlightFilter], + allRelaysForHighlights, { - useCache: 'cache-first', + useCache: 'cache-first', // Already shown cache above, now stream updates cacheResults: true, - timeout: config.mediumTimeout + timeout: config.standardTimeout, + onUpdate: async (newHighlights) => { + // Process new highlights as they stream in + if (newHighlights && newHighlights.length > 0) { + await processHighlightEvents(newHighlights); + } + } } ); - allFetchedEvents.push(...batchEvents); - } - - // Track which highlights we've already added (to avoid duplicates) - const addedHighlightIds = new Set(); - const items: HighlightItem[] = []; + // Process final results (merge with any streaming updates) + if (highlightEvents && highlightEvents.length > 0) { + await processHighlightEvents(highlightEvents); + } - // Create HighlightItem items - for (const event of allFetchedEvents) { - const highlightInfo = highlightBySourceEvent.get(event.id); + // Note: We skip the complex e-tag/a-tag resolution and source event fetching + // The highlights are already displayed from cache/streaming above + // This complex resolution can be done later if needed, but it's not blocking the UI - if (highlightInfo) { - // For highlights, use the highlight event itself, not the source event - if (!addedHighlightIds.has(highlightInfo.highlight.id)) { - items.push({ - event: highlightInfo.highlight, - authorPubkey: highlightInfo.authorPubkey - }); - addedHighlightIds.add(highlightInfo.highlight.id); - } - } - } - - // Add ALL highlights with e-tag or a-tag references, even if source event wasn't found - for (const [sourceEventId, highlightInfo] of highlightBySourceEvent.entries()) { - if (!addedHighlightIds.has(highlightInfo.highlight.id)) { - items.push({ - event: highlightInfo.highlight, - authorPubkey: highlightInfo.authorPubkey - }); - addedHighlightIds.add(highlightInfo.highlight.id); + // Pre-fetch all profiles for event authors in one batch (non-blocking) + const uniquePubkeys = new Set(); + for (const item of allItems) { + uniquePubkeys.add(item.event.pubkey); + uniquePubkeys.add(item.authorPubkey); } - } - - // Add highlights without e-tag or a-tag references (URL-only highlights, etc.) - for (const highlightInfo of highlightsWithoutRefs) { - if (!addedHighlightIds.has(highlightInfo.highlight.id)) { - items.push({ - event: highlightInfo.highlight, - authorPubkey: highlightInfo.authorPubkey + + if (uniquePubkeys.size > 0) { + const profileRelays = relayManager.getProfileReadRelays(); + const pubkeyArray = Array.from(uniquePubkeys); + + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], + profileRelays, + { + useCache: 'cache-first', + cacheResults: true, + priority: 'low', + timeout: config.standardTimeout + } + ).catch(() => { + // Non-critical: profile pre-fetch failed }); - addedHighlightIds.add(highlightInfo.highlight.id); } + } catch (err) { + // Enhancement failed, but we already have cached content showing } - - // Sort by created_at (newest first) and limit to maxTotalItems - allItems = items.sort((a, b) => b.event.created_at - a.event.created_at).slice(0, maxTotalItems); - - // Pre-fetch all profiles for event authors in one batch to avoid individual fetches - const uniquePubkeys = new Set(); - for (const item of allItems) { - uniquePubkeys.add(item.event.pubkey); - uniquePubkeys.add(item.authorPubkey); - } - - if (uniquePubkeys.size > 0) { - const profileRelays = relayManager.getProfileReadRelays(); - const pubkeyArray = Array.from(uniquePubkeys); - - nostrClient.fetchEvents( - [{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], - profileRelays, - { - useCache: 'cache-first', - cacheResults: true, - priority: 'low', - timeout: config.standardTimeout - } - ).catch(() => { - // Non-critical: profile pre-fetch failed - }); - } - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load highlights'; - } finally { - loading = false; - hasLoadedOnce = true; - } + }; + + // Start enhancement in background (non-blocking) + enhanceHighlights().catch(() => { + // Enhancement failed, but cached content is already showing + }); + + // Mark as loaded once we have cached content + hasLoadedOnce = true; } // Reset to page 1 when filter changes diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index 4ec3d6b..b72e6cf 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -55,10 +55,84 @@ return; } + // Load from cache first (fast - instant display) + try { + const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js'); + const cachedLists = await getRecentCachedEvents([KIND.CONTACTS, KIND.FOLLOW_SET], 60 * 60 * 1000, 10); // 1 hour cache + const userCachedLists = cachedLists.filter(e => e.pubkey === currentPubkey); + + if (userCachedLists.length > 0) { + const allLists: ListInfo[] = []; + + // Process cached contacts + const cachedContacts = userCachedLists.filter(e => e.kind === KIND.CONTACTS); + if (cachedContacts.length > 0) { + const contactsEvent = cachedContacts[0]; + const pubkeys = contactsEvent.tags + .filter(tag => tag[0] === 'p' && tag[1]) + .map(tag => tag[1]); + + if (pubkeys.length > 0) { + allLists.push({ + kind: KIND.CONTACTS, + name: 'Follows', + pubkeys, + event: contactsEvent + }); + } + } + + // Process cached follow_set events + const cachedFollowSets = userCachedLists.filter(e => e.kind === KIND.FOLLOW_SET); + for (const followSetEvent of cachedFollowSets) { + const dTag = followSetEvent.tags.find(tag => tag[0] === 'd' && tag[1])?.[1]; + const pubkeys = followSetEvent.tags + .filter(tag => tag[0] === 'p' && tag[1]) + .map(tag => tag[1]); + + if (pubkeys.length > 0) { + allLists.push({ + kind: KIND.FOLLOW_SET, + name: dTag || 'Follow Set', + dTag, + pubkeys, + event: followSetEvent + }); + } + } + + // Sort by kind (3 first, then 30000), then by name + allLists.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind - b.kind; + } + return a.name.localeCompare(b.name); + }); + + lists = allLists; + loading = false; // Show cached lists immediately + + // Auto-select first list if available and load its cached events + if (lists.length > 0 && !selectedList) { + selectedList = lists[0]; + // Load cached events for first list immediately (non-blocking) + loadListEvents(lists[0]).catch(() => { + // Failed to load cached events, will enhance from relays + }); + } + } else { + loading = true; // Only show loading if no cache + } + } catch (error) { + // Cache error is non-critical, continue with relay fetch + loading = true; + } + + // Enhance with fresh data from relays (non-blocking) try { const relays = getAllRelays(); - // Optimized: Fetch both kinds in parallel in a single call + // Fetch both kinds in parallel const [contactsEvents, followSetEvents] = await Promise.all([ nostrClient.fetchEvents( [{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }], @@ -119,13 +193,16 @@ lists = allLists; - // Auto-select first list if available + // Auto-select first list if available and not already selected if (lists.length > 0 && !selectedList) { selectedList = lists[0]; await loadListEvents(lists[0]); + } else if (selectedList && lists.length > 0) { + // Refresh events for currently selected list + await loadListEvents(selectedList); } } catch (error) { - // Failed to load lists + // Failed to load lists (but cached content may already be showing) } finally { loading = false; }