diff --git a/src/App.tsx b/src/App.tsx index 6d35c285..3b0a8575 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,6 +70,15 @@ function AppRoutes({ /> } /> + + } + /> } diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index ba2c4a29..fbe3f8fe 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -36,10 +36,13 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { : undefined const showSettings = location.pathname === '/settings' - const showExplore = location.pathname === '/explore' + const showExplore = location.pathname.startsWith('/explore') const showMe = location.pathname.startsWith('/me') const showProfile = location.pathname.startsWith('/p/') + // Extract tab from explore routes + const exploreTab = location.pathname === '/explore/highlights' ? 'highlights' : 'writings' + // Extract tab from me routes const meTab = location.pathname === '/me' ? 'highlights' : location.pathname === '/me/highlights' ? 'highlights' : @@ -286,7 +289,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 566f7957..d1108deb 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -1,30 +1,47 @@ import React, { useState, useEffect, useRef } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons' +import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' import { RelayPool } from 'applesauce-relay' 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 { Highlight } from '../types/highlights' import BlogPostCard from './BlogPostCard' -import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache' +import { HighlightItem } from './HighlightItem' +import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' import { usePullToRefresh } from '../hooks/usePullToRefresh' import PullToRefreshIndicator from './PullToRefreshIndicator' interface ExploreProps { relayPool: RelayPool + activeTab?: TabType } -const Explore: React.FC = ({ relayPool }) => { +type TabType = 'writings' | 'highlights' + +const Explore: React.FC = ({ relayPool, activeTab: propActiveTab }) => { const activeAccount = Hooks.useActiveAccount() + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState(propActiveTab || 'writings') const [blogPosts, setBlogPosts] = useState([]) + const [highlights, setHighlights] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const exploreContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) + // Update local state when prop changes useEffect(() => { - const loadBlogPosts = async () => { + if (propActiveTab) { + setActiveTab(propActiveTab) + } + }, [propActiveTab]) + + useEffect(() => { + const loadData = async () => { if (!activeAccount) { setError('Please log in to explore content from your friends') setLoading(false) @@ -32,14 +49,18 @@ const Explore: React.FC = ({ relayPool }) => { } try { - // show spinner but keep existing posts + // show spinner but keep existing data setLoading(true) setError(null) // Seed from in-memory cache if available to avoid empty flash - const cached = getCachedPosts(activeAccount.pubkey) - if (cached && cached.length > 0 && blogPosts.length === 0) { - setBlogPosts(cached) + const cachedPosts = getCachedPosts(activeAccount.pubkey) + if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) { + setBlogPosts(cachedPosts) + } + const cachedHighlights = getCachedHighlights(activeAccount.pubkey) + if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) { + setHighlights(cachedHighlights) } // Fetch the user's contacts (friends) @@ -47,15 +68,17 @@ const Explore: React.FC = ({ relayPool }) => { relayPool, activeAccount.pubkey, (partial) => { - // When local contacts are available, kick off early posts fetch + // When local contacts are available, kick off early fetch if (partial.size > 0) { const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const partialArray = Array.from(partial) + + // Fetch blog posts fetchBlogPostsFromAuthors( relayPool, - Array.from(partial), + partialArray, relayUrls, (post) => { - // merge into UI and cache as we stream setBlogPosts((prev) => { const exists = prev.some(p => p.event.id === post.event.id) if (exists) return prev @@ -69,7 +92,6 @@ const Explore: React.FC = ({ relayPool }) => { setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) } ).then((all) => { - // Ensure union of streamed + final is displayed setBlogPosts((prev) => { const byId = new Map(prev.map(p => [p.event.id, p])) for (const post of all) byId.set(post.event.id, post) @@ -82,22 +104,49 @@ const Explore: React.FC = ({ relayPool }) => { return merged }) }) + + // Fetch highlights + fetchHighlightsFromAuthors( + relayPool, + partialArray, + (highlight) => { + setHighlights((prev) => { + const exists = prev.some(h => h.id === highlight.id) + if (exists) return prev + const next = [...prev, highlight] + return next.sort((a, b) => b.timestamp - a.timestamp) + }) + setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight)) + } + ).then((all) => { + setHighlights((prev) => { + const byId = new Map(prev.map(h => [h.id, h])) + for (const highlight of all) byId.set(highlight.id, highlight) + const merged = Array.from(byId.values()).sort((a, b) => b.timestamp - a.timestamp) + setCachedHighlights(activeAccount.pubkey, merged) + return merged + }) + }) } } ) if (contacts.size === 0) { - setError('You are not following anyone yet. Follow some people to see their blog posts!') + setError('You are not following anyone yet. Follow some people to see their content!') setLoading(false) return } // After full contacts, do a final pass for completeness const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls) + const contactsArray = Array.from(contacts) + const [posts, userHighlights] = await Promise.all([ + fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), + fetchHighlightsFromAuthors(relayPool, contactsArray) + ]) - if (posts.length === 0) { - setError('No blog posts found from your friends yet') + if (posts.length === 0 && userHighlights.length === 0) { + setError('No content found from your friends yet') } setBlogPosts((prev) => { @@ -111,16 +160,24 @@ const Explore: React.FC = ({ relayPool }) => { setCachedPosts(activeAccount.pubkey, merged) return merged }) + + setHighlights((prev) => { + const byId = new Map(prev.map(h => [h.id, h])) + for (const highlight of userHighlights) byId.set(highlight.id, highlight) + const merged = Array.from(byId.values()).sort((a, b) => b.timestamp - a.timestamp) + setCachedHighlights(activeAccount.pubkey, merged) + return merged + }) } catch (err) { - console.error('Failed to load blog posts:', err) - setError('Failed to load blog posts. Please try again.') + console.error('Failed to load data:', err) + setError('Failed to load content. Please try again.') } finally { setLoading(false) } } - loadBlogPosts() - }, [relayPool, activeAccount, blogPosts.length, refreshTrigger]) + loadData() + }, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger]) // Pull-to-refresh const pullToRefreshState = usePullToRefresh(exploreContainerRef, { @@ -130,6 +187,16 @@ const Explore: React.FC = ({ relayPool }) => { isRefreshing: loading }) + const handleHighlightDelete = (highlightId: string) => { + setHighlights(prev => { + const updated = prev.filter(h => h.id !== highlightId) + if (activeAccount) { + setCachedHighlights(activeAccount.pubkey, updated) + } + return updated + }) + } + const getPostUrl = (post: BlogPostPreview) => { // Get the d-tag identifier const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' @@ -144,6 +211,61 @@ const Explore: React.FC = ({ relayPool }) => { return `/a/${naddr}` } + const renderTabContent = () => { + switch (activeTab) { + case 'writings': + return blogPosts.length === 0 ? ( +
+

No blog posts found yet.

+
+ ) : ( +
+ {blogPosts.map((post) => ( + t[0] === 'd')?.[1]}`} + post={post} + href={getPostUrl(post)} + /> + ))} +
+ ) + + case 'highlights': + return highlights.length === 0 ? ( +
+

No highlights yet. Your friends should start highlighting content!

+
+ ) : ( +
+ {highlights.map((highlight) => ( + + ))} +
+ ) + + default: + return null + } + } + + // Only show full loading screen if we don't have any data yet + const hasData = highlights.length > 0 || blogPosts.length > 0 + + if (loading && !hasData) { + return ( +
+
+ +
+
+ ) + } + if (error) { return (
@@ -172,27 +294,37 @@ const Explore: React.FC = ({ relayPool }) => { Explore

- Discover blog posts from your friends on Nostr + Discover content from your friends on Nostr

-
- {loading && ( -
- -
- )} -
- {blogPosts.map((post) => ( - t[0] === 'd')?.[1]}`} - post={post} - href={getPostUrl(post)} - /> - ))} - {!loading && blogPosts.length === 0 && ( -
-

No blog posts found yet.

+ + {loading && hasData && ( +
+
)} + +
+ + +
+
+ +
+ {renderTabContent()}
) diff --git a/src/services/exploreCache.ts b/src/services/exploreCache.ts index 45fe3c7e..9ead69e0 100644 --- a/src/services/exploreCache.ts +++ b/src/services/exploreCache.ts @@ -1,4 +1,5 @@ import { NostrEvent } from 'nostr-tools' +import { Highlight } from '../types/highlights' export interface CachedBlogPostPreview { event: NostrEvent @@ -11,6 +12,7 @@ export interface CachedBlogPostPreview { type CacheValue = { posts: CachedBlogPostPreview[] + highlights: Highlight[] timestamp: number } @@ -22,8 +24,28 @@ export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null { return entry.posts } +export function getCachedHighlights(pubkey: string): Highlight[] | null { + const entry = exploreCache.get(pubkey) + if (!entry) return null + return entry.highlights +} + export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void { - exploreCache.set(pubkey, { posts, timestamp: Date.now() }) + const current = exploreCache.get(pubkey) + exploreCache.set(pubkey, { + posts, + highlights: current?.highlights || [], + timestamp: Date.now() + }) +} + +export function setCachedHighlights(pubkey: string, highlights: Highlight[]): void { + const current = exploreCache.get(pubkey) + exploreCache.set(pubkey, { + posts: current?.posts || [], + highlights, + timestamp: Date.now() + }) } export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] { @@ -39,4 +61,13 @@ export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): C return merged } +export function upsertCachedHighlight(pubkey: string, highlight: Highlight): Highlight[] { + const current = exploreCache.get(pubkey)?.highlights || [] + const byId = new Map(current.map(h => [h.id, h])) + byId.set(highlight.id, highlight) + const merged = Array.from(byId.values()).sort((a, b) => b.timestamp - a.timestamp) + setCachedHighlights(pubkey, merged) + return merged +} + diff --git a/src/services/highlightService.ts b/src/services/highlightService.ts index 3b5cb035..a18e8a71 100644 --- a/src/services/highlightService.ts +++ b/src/services/highlightService.ts @@ -1,5 +1,5 @@ export * from './highlights/fetchForArticle' export * from './highlights/fetchForUrl' export * from './highlights/fetchByAuthor' - +export * from './highlights/fetchFromAuthors' diff --git a/src/services/highlights/fetchFromAuthors.ts b/src/services/highlights/fetchFromAuthors.ts new file mode 100644 index 00000000..2eb4ffc6 --- /dev/null +++ b/src/services/highlights/fetchFromAuthors.ts @@ -0,0 +1,79 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { Highlight } from '../../types/highlights' +import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' + +/** + * Fetches highlights (kind:9802) from a list of pubkeys (friends) + * @param relayPool - The relay pool to query + * @param pubkeys - Array of pubkeys to fetch highlights from + * @param onHighlight - Optional callback for streaming highlights as they arrive + * @returns Array of highlights + */ +export const fetchHighlightsFromAuthors = async ( + relayPool: RelayPool, + pubkeys: string[], + onHighlight?: (highlight: Highlight) => void +): Promise => { + try { + if (pubkeys.length === 0) { + console.log('⚠️ No pubkeys to fetch highlights from') + return [] + } + + console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors') + + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const prioritized = prioritizeLocalRelays(relayUrls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) + + const seenIds = new Set() + + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 }) + .pipe( + onlyEvents(), + tap((event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + }), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + + const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + + const uniqueEvents = dedupeHighlights(rawEvents) + const highlights = uniqueEvents.map(eventToHighlight) + + console.log('💡 Processed', highlights.length, 'unique highlights') + + return sortHighlights(highlights) + } catch (error) { + console.error('Failed to fetch highlights from authors:', error) + return [] + } +} +