mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 23:24:22 +01:00
182 lines
6.3 KiB
TypeScript
182 lines
6.3 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import { faSpinner, faExclamationCircle, faNewspaper } 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'
|
|
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
|
|
|
interface ExploreProps {
|
|
relayPool: RelayPool
|
|
}
|
|
|
|
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
|
const activeAccount = Hooks.useActiveAccount()
|
|
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
const loadBlogPosts = async () => {
|
|
if (!activeAccount) {
|
|
setError('Please log in to explore content from your friends')
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
// show spinner but keep existing posts
|
|
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)
|
|
}
|
|
|
|
// Fetch the user's contacts (friends)
|
|
const contacts = await fetchContacts(
|
|
relayPool,
|
|
activeAccount.pubkey,
|
|
(partial) => {
|
|
// When local contacts are available, kick off early posts fetch
|
|
if (partial.size > 0) {
|
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
|
fetchBlogPostsFromAuthors(
|
|
relayPool,
|
|
Array.from(partial),
|
|
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
|
|
const next = [...prev, post]
|
|
return next.sort((a, b) => {
|
|
const timeA = a.published || a.event.created_at
|
|
const timeB = b.published || b.event.created_at
|
|
return timeB - timeA
|
|
})
|
|
})
|
|
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)
|
|
const merged = Array.from(byId.values()).sort((a, b) => {
|
|
const timeA = a.published || a.event.created_at
|
|
const timeB = b.published || b.event.created_at
|
|
return timeB - timeA
|
|
})
|
|
setCachedPosts(activeAccount.pubkey, merged)
|
|
return merged
|
|
})
|
|
})
|
|
}
|
|
}
|
|
)
|
|
|
|
if (contacts.size === 0) {
|
|
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
|
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)
|
|
|
|
if (posts.length === 0) {
|
|
setError('No blog posts found from your friends yet')
|
|
}
|
|
|
|
setBlogPosts((prev) => {
|
|
const byId = new Map(prev.map(p => [p.event.id, p]))
|
|
for (const post of posts) byId.set(post.event.id, post)
|
|
const merged = Array.from(byId.values()).sort((a, b) => {
|
|
const timeA = a.published || a.event.created_at
|
|
const timeB = b.published || b.event.created_at
|
|
return timeB - timeA
|
|
})
|
|
setCachedPosts(activeAccount.pubkey, merged)
|
|
return merged
|
|
})
|
|
} 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, blogPosts.length])
|
|
|
|
const getPostUrl = (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
|
|
})
|
|
|
|
return `/a/${naddr}`
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="explore-container">
|
|
<div className="explore-error">
|
|
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
|
<p>{error}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="explore-container">
|
|
<div className="explore-header">
|
|
<h1>
|
|
<FontAwesomeIcon icon={faNewspaper} />
|
|
Explore
|
|
</h1>
|
|
<p className="explore-subtitle">
|
|
Discover blog posts from your friends on Nostr
|
|
</p>
|
|
</div>
|
|
{loading && (
|
|
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
|
<FontAwesomeIcon icon={faSpinner} spin />
|
|
</div>
|
|
)}
|
|
<div className="explore-grid">
|
|
{blogPosts.map((post) => (
|
|
<BlogPostCard
|
|
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
|
post={post}
|
|
href={getPostUrl(post)}
|
|
/>
|
|
))}
|
|
{!loading && blogPosts.length === 0 && (
|
|
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
|
<p>No blog posts found yet.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Explore
|
|
|