2 changed files with 265 additions and 1 deletions
@ -0,0 +1,248 @@ |
|||||||
|
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { useCallback, useEffect, useState, forwardRef, useImperativeHandle, useMemo } from 'react' |
||||||
|
import NoteCard from '@/components/NoteCard' |
||||||
|
import { Skeleton } from '@/components/ui/skeleton' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
|
||||||
|
interface ProfileArticlesProps { |
||||||
|
pubkey: string |
||||||
|
topSpace?: number |
||||||
|
searchQuery?: string |
||||||
|
} |
||||||
|
|
||||||
|
const ProfileArticles = forwardRef<{ refresh: () => void }, ProfileArticlesProps>(({ pubkey, topSpace, searchQuery = '' }, ref) => { |
||||||
|
console.log('[ProfileArticles] Component rendered with pubkey:', pubkey) |
||||||
|
const [events, setEvents] = useState<Event[]>([]) |
||||||
|
const [isLoading, setIsLoading] = useState(true) |
||||||
|
const [retryCount, setRetryCount] = useState(0) |
||||||
|
const [isRetrying, setIsRetrying] = useState(false) |
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false) |
||||||
|
const { favoriteRelays } = useFavoriteRelays() |
||||||
|
const maxRetries = 3 |
||||||
|
|
||||||
|
// Build comprehensive relay list including user's personal relays
|
||||||
|
const buildComprehensiveRelayList = useCallback(async () => { |
||||||
|
try { |
||||||
|
// Get user's relay list (kind 10002)
|
||||||
|
const userRelayList = await client.fetchRelayList(pubkey) |
||||||
|
|
||||||
|
// Get all relays: user's + fast read + favorite relays
|
||||||
|
const allRelays = [ |
||||||
|
...(userRelayList.read || []), // User's read relays
|
||||||
|
...(userRelayList.write || []), // User's write relays
|
||||||
|
...FAST_READ_RELAY_URLS, // Fast read relays
|
||||||
|
...(favoriteRelays || []) // User's favorite relays
|
||||||
|
] |
||||||
|
|
||||||
|
// Normalize URLs and remove duplicates
|
||||||
|
const normalizedRelays = allRelays |
||||||
|
.map(url => normalizeUrl(url)) |
||||||
|
.filter((url): url is string => !!url) |
||||||
|
|
||||||
|
const uniqueRelays = Array.from(new Set(normalizedRelays)) |
||||||
|
|
||||||
|
console.log('[ProfileArticles] Comprehensive relay list:', uniqueRelays.length, 'relays') |
||||||
|
console.log('[ProfileArticles] User relays (read):', userRelayList.read?.length || 0) |
||||||
|
console.log('[ProfileArticles] User relays (write):', userRelayList.write?.length || 0) |
||||||
|
console.log('[ProfileArticles] Favorite relays:', favoriteRelays?.length || 0) |
||||||
|
|
||||||
|
return uniqueRelays |
||||||
|
} catch (error) { |
||||||
|
console.warn('[ProfileArticles] Error building relay list, using fallback:', error) |
||||||
|
return FAST_READ_RELAY_URLS |
||||||
|
} |
||||||
|
}, [pubkey, favoriteRelays]) |
||||||
|
|
||||||
|
const fetchArticles = useCallback(async (isRetry = false, isRefresh = false) => { |
||||||
|
if (!pubkey) { |
||||||
|
setEvents([]) |
||||||
|
setIsLoading(false) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (!isRetry && !isRefresh) { |
||||||
|
setIsLoading(true) |
||||||
|
setRetryCount(0) |
||||||
|
} else if (isRetry) { |
||||||
|
setIsRetrying(true) |
||||||
|
} else if (isRefresh) { |
||||||
|
setIsRefreshing(true) |
||||||
|
} |
||||||
|
|
||||||
|
console.log('[ProfileArticles] Fetching events for pubkey:', pubkey, isRetry ? `(retry ${retryCount + 1}/${maxRetries})` : '') |
||||||
|
|
||||||
|
// Build comprehensive relay list including user's personal relays
|
||||||
|
const comprehensiveRelays = await buildComprehensiveRelayList() |
||||||
|
console.log('[ProfileArticles] Using comprehensive relay list:', comprehensiveRelays.length, 'relays') |
||||||
|
|
||||||
|
// Fetch longform articles (kind 30023) and highlights (kind 9802)
|
||||||
|
const allEvents = await client.fetchEvents(comprehensiveRelays, { |
||||||
|
authors: [pubkey], |
||||||
|
kinds: [kinds.LongFormArticle, kinds.Highlights], // LongFormArticle and Highlights
|
||||||
|
limit: 100 |
||||||
|
}) |
||||||
|
|
||||||
|
console.log('[ProfileArticles] Fetched total events:', allEvents.length) |
||||||
|
console.log('[ProfileArticles] Sample events:', allEvents.slice(0, 3).map(e => ({ id: e.id, kind: e.kind, content: e.content.substring(0, 50) + '...', tags: e.tags.slice(0, 3) }))) |
||||||
|
|
||||||
|
// Show ALL events (both longform articles and highlights)
|
||||||
|
console.log('[ProfileArticles] Showing all events (articles + highlights):', allEvents.length) |
||||||
|
console.log('[ProfileArticles] Events sample:', allEvents.slice(0, 2).map(e => ({ id: e.id, kind: e.kind, content: e.content.substring(0, 50) + '...' }))) |
||||||
|
|
||||||
|
const eventsToShow = allEvents |
||||||
|
|
||||||
|
// Sort by creation time (newest first)
|
||||||
|
eventsToShow.sort((a, b) => b.created_at - a.created_at) |
||||||
|
|
||||||
|
if (isRefresh) { |
||||||
|
// For refresh, append new events and deduplicate
|
||||||
|
setEvents(prevEvents => { |
||||||
|
const existingIds = new Set(prevEvents.map(e => e.id)) |
||||||
|
const newEvents = eventsToShow.filter(event => !existingIds.has(event.id)) |
||||||
|
const combinedEvents = [...newEvents, ...prevEvents] |
||||||
|
// Re-sort the combined events
|
||||||
|
return combinedEvents.sort((a, b) => b.created_at - a.created_at) |
||||||
|
}) |
||||||
|
} else { |
||||||
|
// For initial load or retry, replace events
|
||||||
|
setEvents(eventsToShow) |
||||||
|
} |
||||||
|
|
||||||
|
// Reset retry count on successful fetch
|
||||||
|
if (isRetry) { |
||||||
|
setRetryCount(0) |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('[ProfileArticles] Error fetching events:', error) |
||||||
|
logger.component('ProfileArticles', 'Initialization failed', { pubkey, error: (error as Error).message, retryCount: isRetry ? retryCount + 1 : 0 }) |
||||||
|
|
||||||
|
// If this is not a retry and we haven't exceeded max retries, schedule a retry
|
||||||
|
if (!isRetry && retryCount < maxRetries) { |
||||||
|
console.log('[ProfileArticles] Scheduling retry', retryCount + 1, 'of', maxRetries) |
||||||
|
// Use shorter delays for initial retries, then exponential backoff
|
||||||
|
const delay = retryCount === 0 ? 1000 : retryCount === 1 ? 2000 : 3000 |
||||||
|
setTimeout(() => { |
||||||
|
setRetryCount(prev => prev + 1) |
||||||
|
fetchArticles(true) |
||||||
|
}, delay) |
||||||
|
} else { |
||||||
|
setEvents([]) |
||||||
|
} |
||||||
|
} finally { |
||||||
|
setIsLoading(false) |
||||||
|
setIsRetrying(false) |
||||||
|
setIsRefreshing(false) |
||||||
|
} |
||||||
|
}, [pubkey, buildComprehensiveRelayList, maxRetries]) |
||||||
|
|
||||||
|
// Expose refresh function to parent component
|
||||||
|
const refresh = useCallback(() => { |
||||||
|
setRetryCount(0) |
||||||
|
setIsRefreshing(true) |
||||||
|
fetchArticles(false, true) // isRetry = false, isRefresh = true
|
||||||
|
}, [fetchArticles]) |
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ |
||||||
|
refresh |
||||||
|
}), [refresh]) |
||||||
|
|
||||||
|
// Filter events based on search query
|
||||||
|
const filteredEvents = useMemo(() => { |
||||||
|
if (!searchQuery.trim()) { |
||||||
|
return events |
||||||
|
} |
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase() |
||||||
|
return events.filter(event =>
|
||||||
|
event.content.toLowerCase().includes(query) || |
||||||
|
event.tags.some(tag =>
|
||||||
|
tag.length > 1 && tag[1]?.toLowerCase().includes(query) |
||||||
|
) |
||||||
|
) |
||||||
|
}, [events, searchQuery]) |
||||||
|
|
||||||
|
// Separate effect for initial fetch only with a small delay
|
||||||
|
useEffect(() => { |
||||||
|
if (pubkey) { |
||||||
|
// Add a small delay to let the component fully mount and relays to be ready
|
||||||
|
const timer = setTimeout(() => { |
||||||
|
fetchArticles() |
||||||
|
}, 500) // 500ms delay
|
||||||
|
|
||||||
|
return () => clearTimeout(timer) |
||||||
|
} |
||||||
|
}, [pubkey]) // Only depend on pubkey to avoid loops
|
||||||
|
|
||||||
|
if (isLoading || isRetrying) { |
||||||
|
return ( |
||||||
|
<div className="space-y-2"> |
||||||
|
{isRetrying && retryCount > 0 && ( |
||||||
|
<div className="text-center py-2 text-sm text-muted-foreground"> |
||||||
|
Retrying... ({retryCount}/{maxRetries}) |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{Array.from({ length: 3 }).map((_, i) => ( |
||||||
|
<Skeleton key={i} className="h-32 w-full" /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return ( |
||||||
|
<div className="flex justify-center items-center py-8"> |
||||||
|
<div className="text-sm text-muted-foreground">No profile selected</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (events.length === 0) { |
||||||
|
return ( |
||||||
|
<div className="flex justify-center items-center py-8"> |
||||||
|
<div className="text-sm text-muted-foreground">No articles or highlights found</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (filteredEvents.length === 0 && searchQuery.trim()) { |
||||||
|
return ( |
||||||
|
<div className="flex justify-center items-center py-8"> |
||||||
|
<div className="text-sm text-muted-foreground">No articles or highlights match your search</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ marginTop: topSpace || 0 }}> |
||||||
|
{isRefreshing && ( |
||||||
|
<div className="px-4 py-2 text-sm text-green-500 text-center"> |
||||||
|
🔄 Refreshing articles... |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{searchQuery.trim() && ( |
||||||
|
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||||
|
{filteredEvents.length} of {events.length} articles |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="space-y-2"> |
||||||
|
{filteredEvents.map((event) => ( |
||||||
|
<NoteCard |
||||||
|
key={event.id} |
||||||
|
className="w-full" |
||||||
|
event={event} |
||||||
|
filterMutedNotes={false} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
ProfileArticles.displayName = 'ProfileArticles' |
||||||
|
|
||||||
|
export default ProfileArticles |
||||||
Loading…
Reference in new issue