diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index 3db7ba0b..80280db2 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -305,7 +305,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { onCreateHighlight={handleCreateHighlight} hasActiveAccount={!!(activeAccount && relayPool)} explore={showExplore ? ( - relayPool ? : null + relayPool ? : null ) : undefined} me={showMe ? ( relayPool ? : null diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 5a7d91c3..68db0bc6 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -3,12 +3,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' import { nip19 } from 'nostr-tools' import { useNavigate } from 'react-router-dom' import { fetchContacts } from '../services/contactService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchHighlightsFromAuthors } from '../services/highlightService' +import { fetchProfiles } from '../services/profileService' import { Highlight } from '../types/highlights' +import { UserSettings } from '../services/settingsService' import BlogPostCard from './BlogPostCard' import { HighlightItem } from './HighlightItem' import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' @@ -18,12 +21,14 @@ import { classifyHighlights } from '../utils/highlightClassification' interface ExploreProps { relayPool: RelayPool + eventStore: IEventStore + settings?: UserSettings activeTab?: TabType } type TabType = 'writings' | 'highlights' -const Explore: React.FC = ({ relayPool, activeTab: propActiveTab }) => { +const Explore: React.FC = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => { const activeAccount = Hooks.useActiveAccount() const navigate = useNavigate() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') @@ -152,6 +157,14 @@ const Explore: React.FC = ({ relayPool, activeTab: propActiveTab } fetchHighlightsFromAuthors(relayPool, contactsArray) ]) + // Fetch profiles for all blog post authors to cache them + if (posts.length > 0) { + const authorPubkeys = Array.from(new Set(posts.map(p => p.author))) + fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => { + console.error('Failed to fetch author profiles:', err) + }) + } + if (posts.length === 0 && userHighlights.length === 0) { setError('No content found from your friends yet') } diff --git a/src/services/profileService.ts b/src/services/profileService.ts new file mode 100644 index 00000000..5c09e9b2 --- /dev/null +++ b/src/services/profileService.ts @@ -0,0 +1,81 @@ +import { RelayPool, completeOnEose, onlyEvents, mapEventsToStore } from 'applesauce-relay' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { rebroadcastEvents } from './rebroadcastService' +import { UserSettings } from './settingsService' + +/** + * Fetches profile metadata (kind:0) for a list of pubkeys + * Stores profiles in the event store and optionally to local relays + */ +export const fetchProfiles = async ( + relayPool: RelayPool, + eventStore: IEventStore, + pubkeys: string[], + settings?: UserSettings +): Promise => { + try { + if (pubkeys.length === 0) { + return [] + } + + const uniquePubkeys = Array.from(new Set(pubkeys)) + console.log('👤 Fetching profiles (kind:0) for', uniquePubkeys.length, 'authors') + + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const prioritized = prioritizeLocalRelays(relayUrls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) + + // Keep only the most recent profile for each pubkey + const profilesByPubkey = new Map() + + const processEvent = (event: NostrEvent) => { + const existing = profilesByPubkey.get(event.pubkey) + if (!existing || event.created_at > existing.created_at) { + profilesByPubkey.set(event.pubkey, event) + } + } + + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [0], authors: uniquePubkeys }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(1200)), + mapEventsToStore(eventStore) + ) + : new Observable((sub) => sub.complete()) + + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [0], authors: uniquePubkeys }) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(6000)), + mapEventsToStore(eventStore) + ) + : new Observable((sub) => sub.complete()) + + const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + + events.forEach(processEvent) + + const profiles = Array.from(profilesByPubkey.values()) + console.log('✅ Fetched', profiles.length, 'unique profiles') + + // Rebroadcast profiles to local/all relays based on settings + if (profiles.length > 0) { + await rebroadcastEvents(profiles, relayPool, settings) + } + + return profiles + } catch (error) { + console.error('Failed to fetch profiles:', error) + return [] + } +} +