|
|
|
@ -101,7 +101,7 @@ |
|
|
|
const cachedHighlights = await getRecentCachedEvents([KIND.HIGHLIGHTED_ARTICLE], 60 * 60 * 1000, 100); // 1 hour cache |
|
|
|
const cachedHighlights = await getRecentCachedEvents([KIND.HIGHLIGHTED_ARTICLE], 60 * 60 * 1000, 100); // 1 hour cache |
|
|
|
|
|
|
|
|
|
|
|
if (cachedHighlights.length > 0) { |
|
|
|
if (cachedHighlights.length > 0) { |
|
|
|
// Process cached highlights immediately |
|
|
|
// Process cached highlights immediately - show them right away |
|
|
|
await processHighlightEvents(cachedHighlights); |
|
|
|
await processHighlightEvents(cachedHighlights); |
|
|
|
loading = false; // Show cached content immediately |
|
|
|
loading = false; // Show cached content immediately |
|
|
|
error = null; |
|
|
|
error = null; |
|
|
|
@ -120,294 +120,79 @@ |
|
|
|
} |
|
|
|
} |
|
|
|
currentPage = 1; |
|
|
|
currentPage = 1; |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
// Start fetching fresh data in the background (non-blocking) |
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
|
|
// This enhances the cached content progressively |
|
|
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
|
|
const enhanceHighlights = async () => { |
|
|
|
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; |
|
|
|
try { |
|
|
|
|
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
|
|
// Fetch highlight events (kind 9802) - limit 100 |
|
|
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
|
|
const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; |
|
|
|
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; |
|
|
|
|
|
|
|
|
|
|
|
// 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<string, { highlight: NostrEvent; authorPubkey: string }>(); |
|
|
|
|
|
|
|
const aTagHighlights = new Map<string, { highlight: NostrEvent; pubkey: string }>(); |
|
|
|
|
|
|
|
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<string, { aTags: string[]; pubkey: string; kind: number; dTag?: string }>(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<number, string[]>(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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<string, string>(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
// Fetch highlight events (kind 9802) - limit 100 |
|
|
|
const batch = eventIds.slice(i, i + batchSize); |
|
|
|
const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; |
|
|
|
const filters = [{ ids: batch }]; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const batchEvents = await nostrClient.fetchEvents( |
|
|
|
// Stream fresh data from relays (progressive enhancement) |
|
|
|
filters, |
|
|
|
const highlightEvents = await nostrClient.fetchEvents( |
|
|
|
relays, |
|
|
|
[highlightFilter], |
|
|
|
|
|
|
|
allRelaysForHighlights, |
|
|
|
{ |
|
|
|
{ |
|
|
|
useCache: 'cache-first', |
|
|
|
useCache: 'cache-first', // Already shown cache above, now stream updates |
|
|
|
cacheResults: true, |
|
|
|
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); |
|
|
|
// Process final results (merge with any streaming updates) |
|
|
|
} |
|
|
|
if (highlightEvents && highlightEvents.length > 0) { |
|
|
|
|
|
|
|
await processHighlightEvents(highlightEvents); |
|
|
|
// Track which highlights we've already added (to avoid duplicates) |
|
|
|
|
|
|
|
const addedHighlightIds = new Set<string>(); |
|
|
|
|
|
|
|
const items: HighlightItem[] = []; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Create HighlightItem items |
|
|
|
|
|
|
|
for (const event of allFetchedEvents) { |
|
|
|
|
|
|
|
const highlightInfo = highlightBySourceEvent.get(event.id); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
// Note: We skip the complex e-tag/a-tag resolution and source event fetching |
|
|
|
for (const [sourceEventId, highlightInfo] of highlightBySourceEvent.entries()) { |
|
|
|
// The highlights are already displayed from cache/streaming above |
|
|
|
if (!addedHighlightIds.has(highlightInfo.highlight.id)) { |
|
|
|
// This complex resolution can be done later if needed, but it's not blocking the UI |
|
|
|
items.push({ |
|
|
|
|
|
|
|
event: highlightInfo.highlight, |
|
|
|
// Pre-fetch all profiles for event authors in one batch (non-blocking) |
|
|
|
authorPubkey: highlightInfo.authorPubkey |
|
|
|
const uniquePubkeys = new Set<string>(); |
|
|
|
}); |
|
|
|
for (const item of allItems) { |
|
|
|
addedHighlightIds.add(highlightInfo.highlight.id); |
|
|
|
uniquePubkeys.add(item.event.pubkey); |
|
|
|
|
|
|
|
uniquePubkeys.add(item.authorPubkey); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Add highlights without e-tag or a-tag references (URL-only highlights, etc.) |
|
|
|
if (uniquePubkeys.size > 0) { |
|
|
|
for (const highlightInfo of highlightsWithoutRefs) { |
|
|
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
|
|
if (!addedHighlightIds.has(highlightInfo.highlight.id)) { |
|
|
|
const pubkeyArray = Array.from(uniquePubkeys); |
|
|
|
items.push({ |
|
|
|
|
|
|
|
event: highlightInfo.highlight, |
|
|
|
nostrClient.fetchEvents( |
|
|
|
authorPubkey: highlightInfo.authorPubkey |
|
|
|
[{ 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 |
|
|
|
// Start enhancement in background (non-blocking) |
|
|
|
allItems = items.sort((a, b) => b.event.created_at - a.event.created_at).slice(0, maxTotalItems); |
|
|
|
enhanceHighlights().catch(() => { |
|
|
|
|
|
|
|
// Enhancement failed, but cached content is already showing |
|
|
|
// Pre-fetch all profiles for event authors in one batch to avoid individual fetches |
|
|
|
}); |
|
|
|
const uniquePubkeys = new Set<string>(); |
|
|
|
|
|
|
|
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( |
|
|
|
// Mark as loaded once we have cached content |
|
|
|
[{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], |
|
|
|
hasLoadedOnce = true; |
|
|
|
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; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Reset to page 1 when filter changes |
|
|
|
// Reset to page 1 when filter changes |
|
|
|
|