From b5c97770abc248b87ed4ad21a0696ab9b314e39c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 07:56:49 +0100 Subject: [PATCH] added articles to profiles --- src/components/Profile/ProfileArticles.tsx | 248 +++++++++++++++++++++ src/components/Profile/index.tsx | 18 +- 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/components/Profile/ProfileArticles.tsx diff --git a/src/components/Profile/ProfileArticles.tsx b/src/components/Profile/ProfileArticles.tsx new file mode 100644 index 0000000..9cedb86 --- /dev/null +++ b/src/components/Profile/ProfileArticles.tsx @@ -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([]) + 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 ( +
+ {isRetrying && retryCount > 0 && ( +
+ Retrying... ({retryCount}/{maxRetries}) +
+ )} + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + if (!pubkey) { + return ( +
+
No profile selected
+
+ ) + } + + if (events.length === 0) { + return ( +
+
No articles or highlights found
+
+ ) + } + + if (filteredEvents.length === 0 && searchQuery.trim()) { + return ( +
+
No articles or highlights match your search
+
+ ) + } + + return ( +
+ {isRefreshing && ( +
+ 🔄 Refreshing articles... +
+ )} + {searchQuery.trim() && ( +
+ {filteredEvents.length} of {events.length} articles +
+ )} +
+ {filteredEvents.map((event) => ( + + ))} +
+
+ ) +}) + +ProfileArticles.displayName = 'ProfileArticles' + +export default ProfileArticles diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 81bcad2..4645f12 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -26,12 +26,13 @@ import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' import ProfileFeed from './ProfileFeed' +import ProfileArticles from './ProfileArticles' import ProfileBookmarksAndHashtags from './ProfileBookmarksAndHashtags' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' -type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' +type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' export default function Profile({ id }: { id?: string }) { const { t } = useTranslation() @@ -44,6 +45,7 @@ export default function Profile({ id }: { id?: string }) { // Refs for child components const profileFeedRef = useRef<{ refresh: () => void }>(null) const profileBookmarksRef = useRef<{ refresh: () => void }>(null) + const profileArticlesRef = useRef<{ refresh: () => void }>(null) const isFollowingYou = useMemo(() => { // This will be handled by the FollowedBy component @@ -59,6 +61,8 @@ export default function Profile({ id }: { id?: string }) { const handleRefresh = () => { if (activeTab === 'posts') { profileFeedRef.current?.refresh() + } else if (activeTab === 'articles') { + profileArticlesRef.current?.refresh() } else { profileBookmarksRef.current?.refresh() } @@ -70,6 +74,10 @@ export default function Profile({ id }: { id?: string }) { value: 'posts', label: 'Posts' }, + { + value: 'articles', + label: 'Articles' + }, { value: 'pins', label: 'Pins' @@ -232,6 +240,14 @@ export default function Profile({ id }: { id?: string }) { searchQuery={searchQuery} /> )} + {activeTab === 'articles' && ( + + )} {(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && (