diff --git a/src/App.tsx b/src/App.tsx index 0b2657af..e01c2a0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts' import { RelayPool } from 'applesauce-relay' import { createAddressLoader } from 'applesauce-loaders/loaders' import Bookmarks from './components/Bookmarks' +import Explore from './components/Explore' import Toast from './components/Toast' import { useToast } from './hooks/useToast' import { RELAYS } from './config/relays' @@ -61,6 +62,12 @@ function AppRoutes({ /> } /> + + } + /> } /> ) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx new file mode 100644 index 00000000..e8508352 --- /dev/null +++ b/src/components/BlogPostCard.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons' +import { formatDistance } from 'date-fns' +import { BlogPostPreview } from '../services/exploreService' +import { Hooks } from 'applesauce-react' + +interface BlogPostCardProps { + post: BlogPostPreview + onClick: () => void +} + +const BlogPostCard: React.FC = ({ post, onClick }) => { + const profile = Hooks.useProfile(post.author) + const displayName = profile?.name || profile?.display_name || + `${post.author.slice(0, 8)}...${post.author.slice(-4)}` + + const publishedDate = post.published || post.event.created_at + const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), { + addSuffix: true + }) + + return ( +
+ {post.image && ( +
+ {post.title} +
+ )} +
+

{post.title}

+ {post.summary && ( +

{post.summary}

+ )} +
+ + + {displayName} + + + + {formattedDate} + +
+
+
+ ) +} + +export default BlogPostCard + diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx new file mode 100644 index 00000000..c058dcf2 --- /dev/null +++ b/src/components/Explore.tsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faSpinner, faExclamationCircle, faCompass } from '@fortawesome/free-solid-svg-icons' +import { Hooks } from 'applesauce-react' +import { RelayPool } from 'applesauce-relay' +import { nip19 } from 'nostr-tools' +import { fetchContacts } from '../services/contactService' +import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' +import BlogPostCard from './BlogPostCard' + +interface ExploreProps { + relayPool: RelayPool +} + +const Explore: React.FC = ({ relayPool }) => { + const navigate = useNavigate() + const activeAccount = Hooks.useActiveAccount() + const [blogPosts, setBlogPosts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadBlogPosts = async () => { + if (!activeAccount) { + setError('Please log in to explore content from your friends') + setLoading(false) + return + } + + try { + setLoading(true) + setError(null) + + // Fetch the user's contacts (friends) + const contacts = await fetchContacts(relayPool, activeAccount.pubkey) + + if (contacts.size === 0) { + setError('You are not following anyone yet. Follow some people to see their blog posts!') + setLoading(false) + return + } + + // Get relay URLs from pool + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + + // Fetch blog posts from friends + const posts = await fetchBlogPostsFromAuthors( + relayPool, + Array.from(contacts), + relayUrls + ) + + if (posts.length === 0) { + setError('No blog posts found from your friends yet') + } + + setBlogPosts(posts) + } catch (err) { + console.error('Failed to load blog posts:', err) + setError('Failed to load blog posts. Please try again.') + } finally { + setLoading(false) + } + } + + loadBlogPosts() + }, [relayPool, activeAccount]) + + const handlePostClick = (post: BlogPostPreview) => { + // Get the d-tag identifier + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + + // Create naddr + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: post.author, + identifier: dTag + }) + + // Navigate to article view + navigate(`/a/${naddr}`) + } + + if (loading) { + return ( +
+
+ +

Loading blog posts from your friends...

+
+
+ ) + } + + if (error) { + return ( +
+
+ +

{error}

+
+
+ ) + } + + return ( +
+
+

+ + Explore +

+

+ Discover blog posts from your friends on Nostr +

+
+
+ {blogPosts.map((post) => ( + t[0] === 'd')?.[1]}`} + post={post} + onClick={() => handlePostClick(post)} + /> + ))} +
+
+ ) +} + +export default Explore + diff --git a/src/index.css b/src/index.css index 93ec06ca..656380e4 100644 --- a/src/index.css +++ b/src/index.css @@ -2555,3 +2555,163 @@ body { .three-pane.sidebar-collapsed .relay-status-indicator { left: calc(var(--sidebar-collapsed-width) + 1.5rem); } + +/* Explore Page Styles */ +.explore-container { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + min-height: 100vh; +} + +.explore-header { + text-align: center; + margin-bottom: 3rem; +} + +.explore-header h1 { + font-size: 2.5rem; + margin: 0 0 1rem 0; + color: #646cff; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} + +.explore-subtitle { + font-size: 1.125rem; + color: rgba(255, 255, 255, 0.7); + margin: 0; +} + +.explore-loading, +.explore-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + min-height: 50vh; + color: rgba(255, 255, 255, 0.7); +} + +.explore-error { + color: #ff6b6b; +} + +.explore-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.blog-post-card { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + overflow: hidden; + transition: all 0.3s ease; + cursor: pointer; + display: flex; + flex-direction: column; + height: 100%; +} + +.blog-post-card:hover { + border-color: #646cff; + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15); +} + +.blog-post-card-image { + width: 100%; + height: 200px; + overflow: hidden; + background: #0f0f0f; +} + +.blog-post-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.blog-post-card:hover .blog-post-card-image img { + transform: scale(1.05); +} + +.blog-post-card-content { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + flex: 1; +} + +.blog-post-card-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: rgba(255, 255, 255, 0.95); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.blog-post-card-summary { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.6); + margin: 0; + line-height: 1.6; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + flex: 1; +} + +.blog-post-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 0.75rem; + border-top: 1px solid #333; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + flex-wrap: wrap; +} + +.blog-post-card-author, +.blog-post-card-date { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.blog-post-card-author svg, +.blog-post-card-date svg { + opacity: 0.7; +} + +@media (max-width: 768px) { + .explore-container { + padding: 1rem; + } + + .explore-header h1 { + font-size: 2rem; + } + + .explore-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } +} diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts new file mode 100644 index 00000000..38b7d0ce --- /dev/null +++ b/src/services/exploreService.ts @@ -0,0 +1,87 @@ +import { RelayPool, completeOnEose } from 'applesauce-relay' +import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { Helpers } from 'applesauce-core' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers + +export interface BlogPostPreview { + event: NostrEvent + title: string + summary?: string + image?: string + published?: number + author: string +} + +/** + * Fetches blog posts (kind:30023) from a list of pubkeys (friends) + * @param relayPool - The relay pool to query + * @param pubkeys - Array of pubkeys to fetch posts from + * @param relayUrls - Array of relay URLs to query + * @returns Array of blog post previews + */ +export const fetchBlogPostsFromAuthors = async ( + relayPool: RelayPool, + pubkeys: string[], + relayUrls: string[] +): Promise => { + try { + if (pubkeys.length === 0) { + console.log('⚠️ No pubkeys to fetch blog posts from') + return [] + } + + console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') + + const events = await lastValueFrom( + relayPool + .req(relayUrls, { + kinds: [30023], + authors: pubkeys, + limit: 100 // Fetch up to 100 recent posts + }) + .pipe(completeOnEose(), takeUntil(timer(15000)), toArray()) + ) + + console.log('📊 Blog post events fetched:', events.length) + + // Deduplicate replaceable events by keeping the most recent version + // Group by author + d-tag identifier + const uniqueEvents = new Map() + + for (const event of events) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${event.pubkey}:${dTag}` + + const existing = uniqueEvents.get(key) + if (!existing || event.created_at > existing.created_at) { + uniqueEvents.set(key, event) + } + } + + // Convert to blog post previews and sort by published date (most recent first) + const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) + .map(event => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + })) + .sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA // Most recent first + }) + + console.log('📰 Processed', blogPosts.length, 'unique blog posts') + + return blogPosts + } catch (error) { + console.error('Failed to fetch blog posts:', error) + return [] + } +} +