mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 16:04:29 +01:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e2e060e3 | ||
|
|
3007ae83c2 | ||
|
|
a862eb880e | ||
|
|
016e369fb1 | ||
|
|
4f21982c48 | ||
|
|
f6d3fe9aba | ||
|
|
fc60e6b80a | ||
|
|
d9cdbb7279 | ||
|
|
401d333e0f | ||
|
|
d32a47e3c3 | ||
|
|
35efdb6d3f | ||
|
|
c7f7792d73 | ||
|
|
8aa26caae0 | ||
|
|
6c00904bd5 | ||
|
|
23526954ea | ||
|
|
9a437dd97b | ||
|
|
0baf75462c | ||
|
|
30b8f1af92 | ||
|
|
07aea9d35f | ||
|
|
41a4abff37 | ||
|
|
c9998984c3 | ||
|
|
a799709e62 | ||
|
|
18c6c3e68a | ||
|
|
5e7395652f | ||
|
|
83076e7b01 | ||
|
|
c79f4122da | ||
|
|
179fe0bbc2 | ||
|
|
20b4f2b1b2 | ||
|
|
936f9093cf | ||
|
|
3149e5b824 | ||
|
|
8619cecaf3 |
104
CHANGELOG.md
104
CHANGELOG.md
@@ -7,6 +7,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.7.3] - 2025-10-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Centralized nostrverse writings controller for kind 30023 content
|
||||||
|
- Automatically starts at app initialization
|
||||||
|
- Streams nostrverse blog posts progressively to Explore page
|
||||||
|
- Provides non-blocking, cache-first loading strategy
|
||||||
|
- Centralized nostrverse highlights controller
|
||||||
|
- Pre-loads nostrverse highlights at app start for instant toggling
|
||||||
|
- Streams highlights progressively to Explore page
|
||||||
|
- Integrated with EventStore for caching
|
||||||
|
- Writings loading debug section on `/debug` page
|
||||||
|
- Diagnostics for writings controller and loading states
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Explore page now uses centralized `writingsController` for user's own writings
|
||||||
|
- Auto-loads user writings at login for instant availability
|
||||||
|
- Non-blocking fetch with progressive streaming
|
||||||
|
- Explore page loading strategy optimized
|
||||||
|
- Shows skeleton placeholders instead of blocking spinners
|
||||||
|
- Seeds from cache, then streams and merges results progressively
|
||||||
|
- Keeps nostrverse fetches non-blocking
|
||||||
|
- User's own writings now included in Explore when enabled
|
||||||
|
- Lazy-loads on 'mine' toggle when logged in
|
||||||
|
- Streams in parallel with friends/nostrverse content
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Explore page works correctly in logged-out mode
|
||||||
|
- Relies solely on centralized nostrverse controllers
|
||||||
|
- Controllers start even when logged out
|
||||||
|
- Fetches nostrverse content properly without authentication
|
||||||
|
- Explore page no longer allows disabling all scope filters
|
||||||
|
- Ensures at least one filter (mine/friends/nostrverse) remains active
|
||||||
|
- Prevents blank content state
|
||||||
|
- Explore page reflects default scope setting immediately
|
||||||
|
- No more blank lists on initial load
|
||||||
|
- Pre-loads and merges nostrverse from event store
|
||||||
|
- Explore page highlights properly scoped
|
||||||
|
- Nostrverse highlights never block the page
|
||||||
|
- Shows empty state instead of spinner
|
||||||
|
- Streams results into store immediately
|
||||||
|
- Highlights are merged and loaded correctly
|
||||||
|
- Article-specific highlights properly filtered
|
||||||
|
- Highlights scoped to current article on `/a/` and `/r/` routes
|
||||||
|
- Derives coordinate from naddr for early filtering
|
||||||
|
- Sidebar and content only show relevant highlights
|
||||||
|
- ContentPanel shows only article-specific highlights for nostr articles
|
||||||
|
- Explore writings properly deduplicated
|
||||||
|
- Deduplication by replaceable event (author:d-tag) happens before visibility filtering
|
||||||
|
- Consistent dedupe/sort behavior across all loading scenarios
|
||||||
|
- Debug page writings loading section added
|
||||||
|
- No infinite loop when loading nostrverse content
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Non-blocking explore page loading
|
||||||
|
- Fully non-blocking loading strategy
|
||||||
|
- Seeds caches then streams and merges results progressively
|
||||||
|
- Lazy-loading for content filters
|
||||||
|
- Nostrverse writings lazy-load when toggled on while logged in
|
||||||
|
- Avoids redundant loading with guard flags
|
||||||
|
- Streaming callbacks for progressive updates
|
||||||
|
- Writings stream to UI via onPost callback
|
||||||
|
- Posts appear instantly as they arrive from cache or network
|
||||||
|
|
||||||
|
## [0.7.2] - 2025-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Cached-first loading with EventStore across the app
|
||||||
|
- Instant display of cached highlights and writings from local event store
|
||||||
|
- Progressive loading with streaming updates from relays
|
||||||
|
- Centralized event storage for improved performance and offline support
|
||||||
|
- Default explore scope setting for controlling content visibility
|
||||||
|
- Configurable default scope for explore page content
|
||||||
|
- Dedicated Explore section in settings for better organization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Highlights and writings now load from cache first, then stream from relays
|
||||||
|
- Explore page shows cached content instantly before network updates
|
||||||
|
- Article-specific highlights stored in centralized event store for faster access
|
||||||
|
- Nostrverse content cached locally for improved performance
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prevent "No highlights yet" flash on `/me/highlights` page
|
||||||
|
- Force React to remount tab content when switching tabs for proper state management
|
||||||
|
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
|
||||||
|
- Show skeleton placeholders while highlights are loading for better UX
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Local-first loading strategy reduces perceived loading times
|
||||||
|
- Cached content displays immediately while background sync occurs
|
||||||
|
- Centralized event storage eliminates redundant network requests
|
||||||
|
|
||||||
## [0.7.0] - 2025-10-18
|
## [0.7.0] - 2025-10-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1878,7 +1978,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- Use applesauce-react event models for better profile handling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
|
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.3...HEAD
|
||||||
|
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
|
||||||
|
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
|
||||||
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
|
||||||
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
|
||||||
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.7.2",
|
"version": "0.7.4",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
24
src/App.tsx
24
src/App.tsx
@@ -23,6 +23,10 @@ import { Bookmark } from './types/bookmarks'
|
|||||||
import { bookmarkController } from './services/bookmarkController'
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
import { highlightsController } from './services/highlightsController'
|
import { highlightsController } from './services/highlightsController'
|
||||||
|
import { writingsController } from './services/writingsController'
|
||||||
|
// import { fetchNostrverseHighlights } from './services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
|
||||||
|
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -109,9 +113,29 @@ function AppRoutes({
|
|||||||
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
|
||||||
highlightsController.start({ relayPool, eventStore, pubkey })
|
highlightsController.start({ relayPool, eventStore, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load writings (controller manages its own state)
|
||||||
|
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
|
||||||
|
console.log('[writings] 🚀 Auto-loading writings on mount/login')
|
||||||
|
writingsController.start({ relayPool, eventStore, pubkey })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start centralized nostrverse highlights controller (non-blocking)
|
||||||
|
if (eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
|
||||||
|
|
||||||
|
// Ensure nostrverse controllers run even when logged out
|
||||||
|
useEffect(() => {
|
||||||
|
if (relayPool && eventStore) {
|
||||||
|
nostrverseHighlightsController.start({ relayPool, eventStore })
|
||||||
|
nostrverseWritingsController.start({ relayPool, eventStore })
|
||||||
|
}
|
||||||
|
}, [relayPool, eventStore])
|
||||||
|
|
||||||
// Manual refresh (for sidebar button)
|
// Manual refresh (for sidebar button)
|
||||||
const handleRefreshBookmarks = useCallback(async () => {
|
const handleRefreshBookmarks = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) {
|
if (!relayPool || !activeAccount) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
|||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { contactsController } from '../services/contactsController'
|
import { contactsController } from '../services/contactsController'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
|
||||||
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
||||||
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
|
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Writings loading state
|
||||||
|
const [isLoadingWritings, setIsLoadingWritings] = useState(false)
|
||||||
|
const [writingPosts, setWritingPosts] = useState<BlogPostPreview[]>([])
|
||||||
|
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
|
||||||
|
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
|
||||||
|
|
||||||
// Live timing state
|
// Live timing state
|
||||||
const [liveTiming, setLiveTiming] = useState<{
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
@@ -538,6 +546,188 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLoadMyWritings = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey || !eventStore) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load your writings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
setWritingPosts([])
|
||||||
|
setIsLoadingWritings(true)
|
||||||
|
setTLoadWritings(null)
|
||||||
|
setTFirstWriting(null)
|
||||||
|
DebugBus.info('debug', 'Loading my writings via writingsController...')
|
||||||
|
try {
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const unsub = writingsController.onWritings((posts) => {
|
||||||
|
if (firstEventTime === null && posts.length > 0) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstWriting(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setWritingPosts(posts)
|
||||||
|
})
|
||||||
|
|
||||||
|
await writingsController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: activeAccount.pubkey,
|
||||||
|
force: true
|
||||||
|
})
|
||||||
|
|
||||||
|
unsub()
|
||||||
|
const currentWritings = writingsController.getWritings()
|
||||||
|
setWritingPosts(currentWritings)
|
||||||
|
DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingWritings(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadWritings(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadFriendsWritings = async () => {
|
||||||
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
|
DebugBus.warn('debug', 'Please log in to load friends writings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
setWritingPosts([])
|
||||||
|
setIsLoadingWritings(true)
|
||||||
|
setTLoadWritings(null)
|
||||||
|
setTFirstWriting(null)
|
||||||
|
DebugBus.info('debug', 'Loading friends writings...')
|
||||||
|
try {
|
||||||
|
// Get contacts first
|
||||||
|
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey })
|
||||||
|
const friends = contactsController.getContacts()
|
||||||
|
const friendsArray = Array.from(friends)
|
||||||
|
DebugBus.info('debug', `Found ${friendsArray.length} friends`)
|
||||||
|
|
||||||
|
if (friendsArray.length === 0) {
|
||||||
|
DebugBus.warn('debug', 'No friends found to load writings from')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const posts = await fetchBlogPostsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
friendsArray,
|
||||||
|
relayUrls,
|
||||||
|
(post) => {
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstWriting(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
setWritingPosts(prev => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const exists = prev.find(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
if (exists) return prev
|
||||||
|
return [...prev, post].sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
setWritingPosts(posts)
|
||||||
|
DebugBus.info('debug', `Loaded ${posts.length} friend writings`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingWritings(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadWritings(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadNostrverseWritings = async () => {
|
||||||
|
if (!relayPool) {
|
||||||
|
DebugBus.warn('debug', 'Relay pool not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = performance.now()
|
||||||
|
setWritingPosts([])
|
||||||
|
setIsLoadingWritings(true)
|
||||||
|
setTLoadWritings(null)
|
||||||
|
setTFirstWriting(null)
|
||||||
|
DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...')
|
||||||
|
try {
|
||||||
|
let firstEventTime: number | null = null
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
|
||||||
|
const { queryEvents } = await import('../services/dataFetch')
|
||||||
|
const { Helpers } = await import('applesauce-core')
|
||||||
|
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||||
|
|
||||||
|
const uniqueEvents = new Map<string, NostrEvent>()
|
||||||
|
await queryEvents(relayPool, { kinds: [30023], limit: 50 }, {
|
||||||
|
relayUrls,
|
||||||
|
onEvent: (evt) => {
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${evt.pubkey}:${dTag}`
|
||||||
|
const existing = uniqueEvents.get(key)
|
||||||
|
if (!existing || evt.created_at > existing.created_at) {
|
||||||
|
uniqueEvents.set(key, evt)
|
||||||
|
|
||||||
|
if (firstEventTime === null) {
|
||||||
|
firstEventTime = performance.now() - start
|
||||||
|
setTFirstWriting(Math.round(firstEventTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = Array.from(uniqueEvents.values()).map(event => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
} as BlogPostPreview)).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
|
||||||
|
setWritingPosts(posts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalPosts = Array.from(uniqueEvents.values()).map(event => ({
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
} as BlogPostPreview)).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
|
||||||
|
setWritingPosts(finalPosts)
|
||||||
|
DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingWritings(false)
|
||||||
|
const elapsed = Math.round(performance.now() - start)
|
||||||
|
setTLoadWritings(elapsed)
|
||||||
|
DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearWritings = () => {
|
||||||
|
setWritingPosts([])
|
||||||
|
setTLoadWritings(null)
|
||||||
|
setTFirstWriting(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleLoadFriendsList = async () => {
|
const handleLoadFriendsList = async () => {
|
||||||
if (!relayPool || !activeAccount?.pubkey) {
|
if (!relayPool || !activeAccount?.pubkey) {
|
||||||
DebugBus.warn('debug', 'Please log in to load friends list')
|
DebugBus.warn('debug', 'Please log in to load friends list')
|
||||||
@@ -1070,6 +1260,99 @@ const Debug: React.FC<DebugProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Writings Loading Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Writings Loading</h3>
|
||||||
|
|
||||||
|
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
|
||||||
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadMyWritings}
|
||||||
|
disabled={isLoadingWritings || !relayPool || !activeAccount || !eventStore}
|
||||||
|
>
|
||||||
|
{isLoadingWritings ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Load My Writings'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadFriendsWritings}
|
||||||
|
disabled={isLoadingWritings || !relayPool || !activeAccount}
|
||||||
|
>
|
||||||
|
{isLoadingWritings ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Load Friends Writings'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
onClick={handleLoadNostrverseWritings}
|
||||||
|
disabled={isLoadingWritings || !relayPool}
|
||||||
|
>
|
||||||
|
{isLoadingWritings ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
'Load Nostrverse Writings'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary text-sm ml-auto"
|
||||||
|
onClick={handleClearWritings}
|
||||||
|
disabled={writingPosts.length === 0}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex gap-2 flex-wrap">
|
||||||
|
<Stat label="total" value={tLoadWritings} />
|
||||||
|
<Stat label="first event" value={tFirstWriting} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{writingPosts.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-sm opacity-70 mb-2">Loaded Writings ({writingPosts.length}):</div>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{writingPosts.map((post, idx) => {
|
||||||
|
const title = post.title
|
||||||
|
const summary = post.summary
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<div className="font-semibold mb-1">Writing #{idx + 1}</div>
|
||||||
|
<div className="opacity-70 mb-1">
|
||||||
|
<div>Author: {post.author.slice(0, 16)}...</div>
|
||||||
|
<div>Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}</div>
|
||||||
|
<div>d-tag: {dTag || '(empty)'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="font-semibold text-[11px]">Title:</div>
|
||||||
|
<div>"{title}"</div>
|
||||||
|
</div>
|
||||||
|
{summary && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-70">
|
||||||
|
<div>Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.image && (
|
||||||
|
<div className="mt-1 text-[11px] opacity-70">
|
||||||
|
<div>Image: {post.image.substring(0, 40)}...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {post.event.id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Web of Trust Section */}
|
{/* Web of Trust Section */}
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Web of Trust</h3>
|
<h3 className="section-title">Web of Trust</h3>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS
|
|||||||
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
import { fetchProfiles } from '../services/profileService'
|
import { fetchProfiles } from '../services/profileService'
|
||||||
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
@@ -27,6 +28,8 @@ import { KINDS } from '../config/kinds'
|
|||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
import { useStoreTimeline } from '../hooks/useStoreTimeline'
|
||||||
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
|
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -48,10 +51,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
|
||||||
|
const [hasLoadedMine, setHasLoadedMine] = useState(false)
|
||||||
|
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
|
||||||
|
|
||||||
// Get myHighlights directly from controller
|
// Get myHighlights directly from controller
|
||||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
// Remove unused loading state to avoid warnings
|
||||||
|
|
||||||
// Load cached content from event store (instant display)
|
// Load cached content from event store (instant display)
|
||||||
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
|
||||||
@@ -66,24 +72,123 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
|
||||||
|
|
||||||
|
|
||||||
// Visibility filters (defaults from settings)
|
|
||||||
|
// Visibility filters (defaults from settings or nostrverse when logged out)
|
||||||
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
|
||||||
friends: settings?.defaultExploreScopeFriends ?? true,
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
mine: settings?.defaultExploreScopeMine ?? false
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Ensure at least one scope remains active
|
||||||
|
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
|
||||||
|
setVisibility(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
if (!next.nostrverse && !next.friends && !next.mine) {
|
||||||
|
return prev // ignore toggle that would disable all scopes
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Subscribe to highlights controller
|
// Subscribe to highlights controller
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
|
||||||
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubHighlights()
|
unsubHighlights()
|
||||||
unsubLoading()
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse highlights controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: Highlight[]) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
|
for (const h of incoming) byId.set(h.id, h)
|
||||||
|
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// seed immediately
|
||||||
|
apply(nostrverseHighlightsController.getHighlights())
|
||||||
|
const unsub = nostrverseHighlightsController.onHighlights(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to nostrverse writings controller for global stream
|
||||||
|
useEffect(() => {
|
||||||
|
const apply = (incoming: BlogPostPreview[]) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of prev) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
byKey.set(key, p)
|
||||||
|
}
|
||||||
|
for (const p of incoming) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
apply(nostrverseWritingsController.getWritings())
|
||||||
|
const unsub = nostrverseWritingsController.onWritings(apply)
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to writings controller for "mine" posts and seed immediately
|
||||||
|
useEffect(() => {
|
||||||
|
// Seed from controller's current state
|
||||||
|
const seed = writingsController.getWritings()
|
||||||
|
if (seed.length > 0) {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream updates
|
||||||
|
const unsub = writingsController.onWritings((posts) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
|
||||||
|
return merged.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => unsub()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update visibility when settings/login state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount) {
|
||||||
|
// When logged out, show nostrverse by default
|
||||||
|
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
|
||||||
|
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
} else {
|
||||||
|
// When logged in, use settings defaults immediately
|
||||||
|
setVisibility({
|
||||||
|
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
|
||||||
|
friends: settings?.defaultExploreScopeFriends ?? true,
|
||||||
|
mine: settings?.defaultExploreScopeMine ?? false
|
||||||
|
})
|
||||||
|
setHasLoadedNostrverse(false)
|
||||||
|
setHasLoadedNostrverseHighlights(false)
|
||||||
|
}
|
||||||
|
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -93,21 +198,22 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!activeAccount) {
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// show spinner but keep existing data
|
// begin load, but do not block rendering
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
|
// If not logged in, only fetch nostrverse content with streaming posts
|
||||||
|
if (!activeAccount) {
|
||||||
|
// Logged out: rely entirely on centralized controllers; do not fetch here
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Seed from in-memory cache if available to avoid empty flash
|
// Seed from in-memory cache if available to avoid empty flash
|
||||||
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
|
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
|
||||||
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
|
||||||
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
|
||||||
}
|
}
|
||||||
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
|
||||||
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
|
||||||
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
|
||||||
}
|
}
|
||||||
@@ -133,10 +239,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At this point, we have seeded any available data; lift the loading state
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
// Fetch the user's contacts (friends)
|
// Fetch the user's contacts (friends)
|
||||||
const contacts = await fetchContacts(
|
const contacts = await fetchContacts(
|
||||||
relayPool,
|
relayPool,
|
||||||
activeAccount.pubkey,
|
activeAccount?.pubkey || '',
|
||||||
(partial) => {
|
(partial) => {
|
||||||
// Store followed pubkeys for highlight classification
|
// Store followed pubkeys for highlight classification
|
||||||
setFollowedPubkeys(partial)
|
setFollowedPubkeys(partial)
|
||||||
@@ -184,7 +293,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||||
}
|
}
|
||||||
).then((all) => {
|
).then((all) => {
|
||||||
setBlogPosts((prev) => {
|
setBlogPosts((prev) => {
|
||||||
@@ -213,7 +322,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const timeB = b.published || b.event.created_at
|
const timeB = b.published || b.event.created_at
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
})
|
})
|
||||||
setCachedPosts(activeAccount.pubkey, merged)
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
|
||||||
return merged
|
return merged
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -229,14 +338,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const next = [...prev, highlight]
|
const next = [...prev, highlight]
|
||||||
return next.sort((a, b) => b.created_at - a.created_at)
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
})
|
})
|
||||||
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||||
}
|
}
|
||||||
).then((all) => {
|
).then((all) => {
|
||||||
setHighlights((prev) => {
|
setHighlights((prev) => {
|
||||||
const byId = new Map(prev.map(h => [h.id, h]))
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
for (const highlight of all) byId.set(highlight.id, highlight)
|
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||||
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
setCachedHighlights(activeAccount.pubkey, merged)
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
return merged
|
return merged
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -250,52 +359,143 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
// Store final followed pubkeys
|
// Store final followed pubkeys
|
||||||
setFollowedPubkeys(contacts)
|
setFollowedPubkeys(contacts)
|
||||||
|
|
||||||
// Fetch both friends content and nostrverse content in parallel
|
// Fetch friends content and (optionally) nostrverse + mine content in parallel
|
||||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
const contactsArray = Array.from(contacts)
|
const contactsArray = Array.from(contacts)
|
||||||
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
// Use centralized writingsController for my posts (non-blocking)
|
||||||
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
// pull from writingsController; no need to store promise
|
||||||
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
|
setHasLoadedMine(true)
|
||||||
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
const nostrversePostsPromise = visibility.nostrverse
|
||||||
])
|
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
|
||||||
|
// Stream nostrverse posts too when logged in
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existingIndex = prev.findIndex(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = prev[existingIndex]
|
||||||
|
if (post.event.created_at <= existing.event.created_at) return prev
|
||||||
|
const next = [...prev]
|
||||||
|
next[existingIndex] = post
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
}
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: Promise.resolve([] as BlogPostPreview[])
|
||||||
|
|
||||||
// Merge and deduplicate all posts
|
// Fire non-blocking fetches and merge as they resolve
|
||||||
const allPosts = [...friendsPosts, ...nostrversePosts]
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
|
||||||
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
|
.then((friendsPosts) => {
|
||||||
const timeA = a.published || a.event.created_at
|
setBlogPosts(prev => {
|
||||||
const timeB = b.published || b.event.created_at
|
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
|
||||||
return timeB - timeA
|
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
})
|
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
|
||||||
|
// Pre-cache profiles in background
|
||||||
|
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
|
fetchHighlightsFromAuthors(relayPool, contactsArray)
|
||||||
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
|
.then((friendsHighlights) => {
|
||||||
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
|
setHighlights(prev => {
|
||||||
|
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
|
||||||
|
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
// Fetch profiles for all blog post authors to cache them
|
nostrversePostsPromise.then((nostrversePosts) => {
|
||||||
if (uniquePosts.length > 0) {
|
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
|
||||||
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
}).catch(() => {})
|
||||||
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
|
||||||
console.error('Failed to fetch author profiles:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// No blocking errors - let empty states handle messaging
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
setBlogPosts(uniquePosts)
|
.then((nostriverseHighlights) => {
|
||||||
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}).catch(() => {})
|
||||||
setHighlights(uniqueHighlights)
|
|
||||||
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.error('Failed to load data:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
// No blocking error - user can pull-to-refresh
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
// loading is already turned off after seeding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse writings when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
setHasLoadedNostrverse(true)
|
||||||
|
fetchNostrverseBlogPosts(
|
||||||
|
relayPool,
|
||||||
|
relayUrls,
|
||||||
|
50,
|
||||||
|
eventStore || undefined,
|
||||||
|
(post) => {
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${post.author}:${dTag}`
|
||||||
|
const existingIndex = prev.findIndex(p => {
|
||||||
|
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
return `${p.author}:${pDTag}` === key
|
||||||
|
})
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = prev[existingIndex]
|
||||||
|
if (post.event.created_at <= existing.event.created_at) return prev
|
||||||
|
const next = [...prev]
|
||||||
|
next[existingIndex] = post
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
}
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
).then((finalPosts) => {
|
||||||
|
// Ensure final deduped list
|
||||||
|
setBlogPosts(prev => {
|
||||||
|
const byKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const p of [...prev, ...finalPosts]) {
|
||||||
|
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${p.author}:${dTag}`
|
||||||
|
const existing = byKey.get(key)
|
||||||
|
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
|
||||||
|
}
|
||||||
|
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
|
||||||
|
|
||||||
|
// Lazy-load nostrverse highlights when user toggles it on (logged in)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
|
||||||
|
setHasLoadedNostrverseHighlights(true)
|
||||||
|
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
|
||||||
|
.then((hl) => {
|
||||||
|
if (hl && hl.length > 0) {
|
||||||
|
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
|
||||||
|
|
||||||
|
// Lazy-load my writings when user toggles "mine" on (logged in)
|
||||||
|
// No direct fetch here; writingsController streams my posts centrally
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeAccount || !visibility.mine || hasLoadedMine) return
|
||||||
|
setHasLoadedMine(true)
|
||||||
|
}, [visibility.mine, activeAccount, hasLoadedMine])
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Pull-to-refresh
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
@@ -333,10 +533,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
})
|
})
|
||||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Dedupe and sort posts once for rendering
|
||||||
|
const uniqueSortedPosts = useMemo(() => {
|
||||||
|
const unique = dedupeWritingsByReplaceable(blogPosts)
|
||||||
|
return unique.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}, [blogPosts])
|
||||||
|
|
||||||
// Filter blog posts by future dates and visibility, and add level classification
|
// Filter blog posts by future dates and visibility, and add level classification
|
||||||
const filteredBlogPosts = useMemo(() => {
|
const filteredBlogPosts = useMemo(() => {
|
||||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||||
return blogPosts
|
return uniqueSortedPosts
|
||||||
.filter(post => {
|
.filter(post => {
|
||||||
// Filter out future dates
|
// Filter out future dates
|
||||||
const publishedTime = post.published || post.event.created_at
|
const publishedTime = post.published || post.event.created_at
|
||||||
@@ -360,7 +570,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
return { ...post, level }
|
return { ...post, level }
|
||||||
})
|
})
|
||||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
@@ -403,7 +613,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
return classifiedHighlights.length === 0 ? (
|
return classifiedHighlights.length === 0 ? (
|
||||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
<span>No highlights to show for the selected scope.</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -422,9 +632,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show skeletons while first load in this session
|
||||||
const hasData = highlights.length > 0 || blogPosts.length > 0
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
const showSkeletons = (loading || myHighlightsLoading) && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="explore-container">
|
<div className="explore-container">
|
||||||
@@ -451,7 +661,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faNetworkWired}
|
icon={faNetworkWired}
|
||||||
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
onClick={() => toggleScope('nostrverse')}
|
||||||
title="Toggle nostrverse content"
|
title="Toggle nostrverse content"
|
||||||
ariaLabel="Toggle nostrverse content"
|
ariaLabel="Toggle nostrverse content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -462,7 +672,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUserGroup}
|
icon={faUserGroup}
|
||||||
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
onClick={() => toggleScope('friends')}
|
||||||
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||||
ariaLabel="Toggle friends content"
|
ariaLabel="Toggle friends content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -474,7 +684,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faUser}
|
icon={faUser}
|
||||||
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
onClick={() => toggleScope('mine')}
|
||||||
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||||
ariaLabel="Toggle my content"
|
ariaLabel="Toggle my content"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -46,36 +46,38 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
{currentUserPubkey && (
|
||||||
icon={faUserGroup}
|
<>
|
||||||
onClick={() => onHighlightVisibilityChange({
|
<IconButton
|
||||||
...highlightVisibility,
|
icon={faUserGroup}
|
||||||
friends: !highlightVisibility.friends
|
onClick={() => onHighlightVisibilityChange({
|
||||||
})}
|
...highlightVisibility,
|
||||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
friends: !highlightVisibility.friends
|
||||||
ariaLabel="Toggle friends highlights"
|
})}
|
||||||
variant="ghost"
|
title="Toggle friends highlights"
|
||||||
disabled={!currentUserPubkey}
|
ariaLabel="Toggle friends highlights"
|
||||||
style={{
|
variant="ghost"
|
||||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
style={{
|
||||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
}}
|
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||||
/>
|
}}
|
||||||
<IconButton
|
/>
|
||||||
icon={faUser}
|
<IconButton
|
||||||
onClick={() => onHighlightVisibilityChange({
|
icon={faUser}
|
||||||
...highlightVisibility,
|
onClick={() => onHighlightVisibilityChange({
|
||||||
mine: !highlightVisibility.mine
|
...highlightVisibility,
|
||||||
})}
|
mine: !highlightVisibility.mine
|
||||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
})}
|
||||||
ariaLabel="Toggle my highlights"
|
title="Toggle my highlights"
|
||||||
variant="ghost"
|
ariaLabel="Toggle my highlights"
|
||||||
disabled={!currentUserPubkey}
|
variant="ghost"
|
||||||
style={{
|
style={{
|
||||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react'
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { IEventStore, Helpers } from 'applesauce-core'
|
import { IEventStore, Helpers } from 'applesauce-core'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
@@ -11,6 +11,7 @@ import { Highlight } from '../types/highlights'
|
|||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { highlightsController } from '../services/highlightsController'
|
import { highlightsController } from '../services/highlightsController'
|
||||||
|
import { writingsController } from '../services/writingsController'
|
||||||
import { fetchAllReads, ReadItem } from '../services/readsService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
import { fetchLinks } from '../services/linksService'
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
@@ -20,7 +21,6 @@ import AuthorCard from './AuthorCard'
|
|||||||
import BlogPostCard from './BlogPostCard'
|
import BlogPostCard from './BlogPostCard'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
|
||||||
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
@@ -81,6 +81,10 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||||
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Get myWritings directly from controller
|
||||||
|
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
|
||||||
|
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
|
||||||
|
|
||||||
// Load cached data from event store for OTHER profiles (not own)
|
// Load cached data from event store for OTHER profiles (not own)
|
||||||
const cachedHighlights = useStoreTimeline(
|
const cachedHighlights = useStoreTimeline(
|
||||||
eventStore,
|
eventStore,
|
||||||
@@ -104,7 +108,6 @@ const Me: React.FC<MeProps> = ({
|
|||||||
toBlogPostPreview,
|
toBlogPostPreview,
|
||||||
[viewingPubkey, isOwnProfile]
|
[viewingPubkey, isOwnProfile]
|
||||||
)
|
)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
|
||||||
@@ -138,6 +141,20 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to writings controller
|
||||||
|
useEffect(() => {
|
||||||
|
// Get initial state immediately
|
||||||
|
setMyWritings(writingsController.getWritings())
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
const unsubWritings = writingsController.onWritings(setMyWritings)
|
||||||
|
const unsubLoading = writingsController.onLoading(setMyWritingsLoading)
|
||||||
|
return () => {
|
||||||
|
unsubWritings()
|
||||||
|
unsubLoading()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (propActiveTab) {
|
if (propActiveTab) {
|
||||||
@@ -169,30 +186,41 @@ const Me: React.FC<MeProps> = ({
|
|||||||
const loadHighlightsTab = async () => {
|
const loadHighlightsTab = async () => {
|
||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
// Only show loading skeleton if tab hasn't been loaded yet
|
// Only show loading skeleton if tab hasn't been loaded yet AND no cached data
|
||||||
const hasBeenLoaded = loadedTabs.has('highlights')
|
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||||
|
const hasCachedData = cachedHighlights.length > 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
|
||||||
|
|
||||||
// For own profile, highlights come from controller subscription (sync effect handles it)
|
// For own profile, highlights come from controller subscription (sync effect handles it)
|
||||||
// For viewing other users, seed with cached data then fetch fresh
|
if (isOwnProfile) {
|
||||||
if (!isOwnProfile) {
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
// Seed with cached highlights first
|
setLoading(false)
|
||||||
if (cachedHighlights.length > 0) {
|
return
|
||||||
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch fresh highlights
|
|
||||||
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
|
||||||
setHighlights(userHighlights)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
// For viewing other users, seed with cached data immediately (non-blocking)
|
||||||
|
if (hasCachedData) {
|
||||||
|
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
setLoading(false)
|
||||||
|
} else if (!hasBeenLoaded) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh highlights in background and merge
|
||||||
|
fetchHighlights(relayPool, viewingPubkey)
|
||||||
|
.then(userHighlights => {
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load highlights:', err)
|
console.error('Failed to load highlights:', err)
|
||||||
} finally {
|
setLoading(false)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,27 +228,49 @@ const Me: React.FC<MeProps> = ({
|
|||||||
if (!viewingPubkey) return
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
const hasBeenLoaded = loadedTabs.has('writings')
|
const hasBeenLoaded = loadedTabs.has('writings')
|
||||||
|
const hasCachedData = cachedWritings.length > 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasBeenLoaded) setLoading(true)
|
// For own profile, use centralized controller
|
||||||
|
if (isOwnProfile) {
|
||||||
|
await writingsController.start({
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
pubkey: viewingPubkey,
|
||||||
|
force: refreshTrigger > 0
|
||||||
|
})
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Seed with cached writings first
|
// For other profiles, seed with cached writings immediately (non-blocking)
|
||||||
if (!isOwnProfile && cachedWritings.length > 0) {
|
if (hasCachedData) {
|
||||||
setWritings(cachedWritings.sort((a, b) => {
|
setWritings(cachedWritings.sort((a, b) => {
|
||||||
const timeA = a.published || a.event.created_at
|
const timeA = a.published || a.event.created_at
|
||||||
const timeB = b.published || b.event.created_at
|
const timeB = b.published || b.event.created_at
|
||||||
return timeB - timeA
|
return timeB - timeA
|
||||||
}))
|
}))
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
setLoading(false)
|
||||||
|
} else if (!hasBeenLoaded) {
|
||||||
|
setLoading(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh writings
|
// Fetch fresh writings in background and merge
|
||||||
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
setWritings(userWritings)
|
.then(userWritings => {
|
||||||
setLoadedTabs(prev => new Set(prev).add('writings'))
|
setWritings(userWritings)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load writings:', err)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load writings:', err)
|
console.error('Failed to load writings:', err)
|
||||||
} finally {
|
setLoading(false)
|
||||||
if (!hasBeenLoaded) setLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +425,41 @@ const Me: React.FC<MeProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isOwnProfile, myHighlights])
|
}, [isOwnProfile, myHighlights])
|
||||||
|
|
||||||
|
// Sync myWritings from controller when viewing own profile
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOwnProfile) {
|
||||||
|
setWritings(myWritings)
|
||||||
|
}
|
||||||
|
}, [isOwnProfile, myWritings])
|
||||||
|
|
||||||
|
// Preload all highlights and writings for profile pages (non-blocking)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOwnProfile && viewingPubkey && relayPool && eventStore) {
|
||||||
|
// Fire and forget - non-blocking background fetch
|
||||||
|
console.log('🔄 [Profile] Preloading highlights and writings for', viewingPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
// Fetch highlights in background
|
||||||
|
fetchHighlights(relayPool, viewingPubkey, undefined, undefined, false, eventStore)
|
||||||
|
.then(highlights => {
|
||||||
|
console.log('✅ [Profile] Preloaded', highlights.length, 'highlights into event store')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('⚠️ [Profile] Failed to preload highlights:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch writings in background
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
.then(writings => {
|
||||||
|
// Store writings in event store
|
||||||
|
writings.forEach(w => eventStore.add(w.event))
|
||||||
|
console.log('✅ [Profile] Preloaded', writings.length, 'writings into event store')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('⚠️ [Profile] Failed to preload writings:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isOwnProfile, viewingPubkey, relayPool, eventStore])
|
||||||
|
|
||||||
// Pull-to-refresh - reload active tab without clearing state
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
@@ -529,9 +614,9 @@ const Me: React.FC<MeProps> = ({
|
|||||||
if (showSkeletons) {
|
if (showSkeletons) {
|
||||||
return (
|
return (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className="bookmarks-grid bookmarks-cards">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
<BookmarkSkeleton key={i} viewMode="cards" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -557,13 +642,13 @@ const Me: React.FC<MeProps> = ({
|
|||||||
sections.filter(s => s.items.length > 0).map(section => (
|
sections.filter(s => s.items.length > 0).map(section => (
|
||||||
<div key={section.key} className="bookmarks-section">
|
<div key={section.key} className="bookmarks-section">
|
||||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
<div className="bookmarks-grid bookmarks-cards">
|
||||||
{section.items.map((individualBookmark, index) => (
|
{section.items.map((individualBookmark, index) => (
|
||||||
<BookmarkItem
|
<BookmarkItem
|
||||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
bookmark={individualBookmark}
|
bookmark={individualBookmark}
|
||||||
index={index}
|
index={index}
|
||||||
viewMode={viewMode}
|
viewMode="cards"
|
||||||
onSelectUrl={handleSelectUrl}
|
onSelectUrl={handleSelectUrl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -585,27 +670,6 @@ const Me: React.FC<MeProps> = ({
|
|||||||
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
|
||||||
icon={faList}
|
|
||||||
onClick={() => setViewMode('compact')}
|
|
||||||
title="Compact list view"
|
|
||||||
ariaLabel="Compact list view"
|
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faThLarge}
|
|
||||||
onClick={() => setViewMode('cards')}
|
|
||||||
title="Cards view"
|
|
||||||
ariaLabel="Cards view"
|
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
icon={faImage}
|
|
||||||
onClick={() => setViewMode('large')}
|
|
||||||
title="Large preview view"
|
|
||||||
ariaLabel="Large preview view"
|
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -714,7 +778,7 @@ const Me: React.FC<MeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 && !loading ? (
|
return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles written yet.
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
summary={props.readerContent?.summary}
|
summary={props.readerContent?.summary}
|
||||||
published={props.readerContent?.published}
|
published={props.readerContent?.published}
|
||||||
selectedUrl={props.selectedUrl}
|
selectedUrl={props.selectedUrl}
|
||||||
highlights={props.classifiedHighlights}
|
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
|
||||||
|
? props.highlights // article-specific highlights only
|
||||||
|
: props.classifiedHighlights}
|
||||||
showHighlights={props.showHighlights}
|
showHighlights={props.showHighlights}
|
||||||
highlightStyle={props.settings.highlightStyle || 'marker'}
|
highlightStyle={props.settings.highlightStyle || 'marker'}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
|
|||||||
import { useStoreTimeline } from './useStoreTimeline'
|
import { useStoreTimeline } from './useStoreTimeline'
|
||||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Determine effective article coordinate as early as possible
|
||||||
|
// Prefer state-derived coordinate, but fall back to route naddr before content loads
|
||||||
|
const effectiveArticleCoordinate = useMemo(() => {
|
||||||
|
if (currentArticleCoordinate) return currentArticleCoordinate
|
||||||
|
if (!naddr) return undefined
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type === 'naddr') {
|
||||||
|
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
|
||||||
|
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore decode failure; treat as no coordinate yet
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [currentArticleCoordinate, naddr])
|
||||||
|
|
||||||
// Load cached article-specific highlights from event store
|
// Load cached article-specific highlights from event store
|
||||||
const articleFilter = useMemo(() => {
|
const articleFilter = useMemo(() => {
|
||||||
if (!currentArticleCoordinate) return null
|
if (!effectiveArticleCoordinate) return null
|
||||||
return {
|
return {
|
||||||
kinds: [KINDS.Highlights],
|
kinds: [KINDS.Highlights],
|
||||||
'#a': [currentArticleCoordinate],
|
'#a': [effectiveArticleCoordinate],
|
||||||
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
|
||||||
}
|
}
|
||||||
}, [currentArticleCoordinate, currentArticleEventId])
|
}, [effectiveArticleCoordinate, currentArticleEventId])
|
||||||
|
|
||||||
const cachedArticleHighlights = useStoreTimeline(
|
const cachedArticleHighlights = useStoreTimeline(
|
||||||
eventStore || null,
|
eventStore || null,
|
||||||
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
|
||||||
eventToHighlight,
|
eventToHighlight,
|
||||||
[currentArticleCoordinate, currentArticleEventId]
|
[effectiveArticleCoordinate, currentArticleEventId]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Subscribe to centralized controllers
|
// Subscribe to centralized controllers
|
||||||
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
setHighlightsLoading(true)
|
setHighlightsLoading(true)
|
||||||
try {
|
try {
|
||||||
if (currentArticleCoordinate) {
|
if (effectiveArticleCoordinate) {
|
||||||
// Seed with cached highlights first
|
// Seed with cached highlights first
|
||||||
if (cachedArticleHighlights.length > 0) {
|
if (cachedArticleHighlights.length > 0) {
|
||||||
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
|
||||||
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
await fetchHighlightsForArticle(
|
await fetchHighlightsForArticle(
|
||||||
relayPool,
|
relayPool,
|
||||||
currentArticleCoordinate,
|
effectiveArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
(highlight) => {
|
(highlight) => {
|
||||||
// Deduplicate highlights by ID as they arrive
|
// Deduplicate highlights by ID as they arrive
|
||||||
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
|
||||||
|
|
||||||
const handleRefreshAll = useCallback(async () => {
|
const handleRefreshAll = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount || isRefreshing) return
|
if (!relayPool || !activeAccount || isRefreshing) return
|
||||||
@@ -143,19 +161,20 @@ export const useBookmarksData = ({
|
|||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
// Fetch article-specific highlights when viewing an article
|
// Fetch article-specific highlights when viewing an article
|
||||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
if (currentArticleCoordinate && !externalUrl) {
|
if (effectiveArticleCoordinate && !externalUrl) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
} else if (!naddr && !externalUrl) {
|
} else if (!naddr && !externalUrl) {
|
||||||
// Clear article highlights when not viewing an article
|
// Clear article highlights when not viewing an article
|
||||||
setArticleHighlights([])
|
setArticleHighlights([])
|
||||||
setHighlightsLoading(false)
|
setHighlightsLoading(false)
|
||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
|
||||||
|
|
||||||
// Merge highlights from controller with article-specific highlights
|
// When viewing an article, show only article-specific highlights
|
||||||
const highlights = [...myHighlights, ...articleHighlights]
|
// Otherwise, show user's highlights from controller
|
||||||
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
|
const highlights = effectiveArticleCoordinate || externalUrl
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
: myHighlights
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlights,
|
highlights,
|
||||||
|
|||||||
139
src/services/nostrverseHighlightsController.ts
Normal file
139
src/services/nostrverseHighlightsController.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
|
||||||
|
|
||||||
|
type HighlightsCallback = (highlights: Highlight[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
const LAST_SYNCED_KEY = 'nostrverse_highlights_last_synced'
|
||||||
|
|
||||||
|
class NostrverseHighlightsController {
|
||||||
|
private highlightsListeners: HighlightsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentHighlights: Highlight[] = []
|
||||||
|
private loaded = false
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
onHighlights(cb: HighlightsCallback): () => void {
|
||||||
|
this.highlightsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitHighlights(highlights: Highlight[]): void {
|
||||||
|
this.highlightsListeners.forEach(cb => cb(highlights))
|
||||||
|
}
|
||||||
|
|
||||||
|
getHighlights(): Highlight[] {
|
||||||
|
return [...this.currentHighlights]
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded(): boolean {
|
||||||
|
return this.loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLastSyncedAt(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return typeof parsed?.ts === 'number' ? parsed.ts : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastSyncedAt(timestamp: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts: timestamp }))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, force = false } = options
|
||||||
|
|
||||||
|
if (!force && this.loaded) {
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generation++
|
||||||
|
const currentGeneration = this.generation
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const highlightsMap = new Map<string, Highlight>()
|
||||||
|
|
||||||
|
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||||
|
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
|
||||||
|
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
filter,
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
eventStore.add(evt)
|
||||||
|
const highlight = eventToHighlight(evt)
|
||||||
|
highlightsMap.set(highlight.id, highlight)
|
||||||
|
|
||||||
|
const sorted = sortHighlights(Array.from(highlightsMap.values()))
|
||||||
|
this.currentHighlights = sorted
|
||||||
|
this.emitHighlights(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
|
||||||
|
const highlights = events.map(eventToHighlight)
|
||||||
|
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
|
||||||
|
const sorted = sortHighlights(unique)
|
||||||
|
|
||||||
|
this.currentHighlights = sorted
|
||||||
|
this.loaded = true
|
||||||
|
this.emitHighlights(sorted)
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const newest = Math.max(...sorted.map(h => h.created_at))
|
||||||
|
this.setLastSyncedAt(newest)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.currentHighlights = []
|
||||||
|
this.emitHighlights(this.currentHighlights)
|
||||||
|
} finally {
|
||||||
|
if (currentGeneration === this.generation) this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nostrverseHighlightsController = new NostrverseHighlightsController()
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
relayUrls: string[],
|
relayUrls: string[],
|
||||||
limit = 50,
|
limit = 50,
|
||||||
eventStore?: IEventStore
|
eventStore?: IEventStore,
|
||||||
|
onPost?: (post: BlogPostPreview) => void
|
||||||
): Promise<BlogPostPreview[]> => {
|
): Promise<BlogPostPreview[]> => {
|
||||||
try {
|
try {
|
||||||
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
|
||||||
@@ -44,6 +45,19 @@ export const fetchNostrverseBlogPosts = async (
|
|||||||
const existing = uniqueEvents.get(key)
|
const existing = uniqueEvents.get(key)
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
if (!existing || event.created_at > existing.created_at) {
|
||||||
uniqueEvents.set(key, event)
|
uniqueEvents.set(key, event)
|
||||||
|
|
||||||
|
// Stream post immediately if callback provided
|
||||||
|
if (onPost) {
|
||||||
|
const post: BlogPostPreview = {
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}
|
||||||
|
onPost(post)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,8 @@ export const fetchNostrverseHighlights = async (
|
|||||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||||
|
|
||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
|
// Collect but do not block callers awaiting network completion
|
||||||
|
const collected: NostrEvent[] = []
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [9802], limit },
|
{ kinds: [9802], limit },
|
||||||
@@ -104,6 +120,7 @@ export const fetchNostrverseHighlights = async (
|
|||||||
if (eventStore) {
|
if (eventStore) {
|
||||||
eventStore.add(event)
|
eventStore.add(event)
|
||||||
}
|
}
|
||||||
|
collected.push(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -113,7 +130,7 @@ export const fetchNostrverseHighlights = async (
|
|||||||
rawEvents.forEach(evt => eventStore.add(evt))
|
rawEvents.forEach(evt => eventStore.add(evt))
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
|
||||||
const highlights = uniqueEvents.map(eventToHighlight)
|
const highlights = uniqueEvents.map(eventToHighlight)
|
||||||
|
|
||||||
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
|
||||||
|
|||||||
169
src/services/nostrverseWritingsController.ts
Normal file
169
src/services/nostrverseWritingsController.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore, Helpers } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||||
|
|
||||||
|
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
const LAST_SYNCED_KEY = 'nostrverse_writings_last_synced'
|
||||||
|
|
||||||
|
function toPreview(event: NostrEvent): BlogPostPreview {
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||||
|
return posts.slice().sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class NostrverseWritingsController {
|
||||||
|
private writingsListeners: WritingsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentPosts: BlogPostPreview[] = []
|
||||||
|
private loaded = false
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
onWritings(cb: WritingsCallback): () => void {
|
||||||
|
this.writingsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitWritings(posts: BlogPostPreview[]): void {
|
||||||
|
this.writingsListeners.forEach(cb => cb(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
getWritings(): BlogPostPreview[] {
|
||||||
|
return [...this.currentPosts]
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoaded(): boolean {
|
||||||
|
return this.loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLastSyncedAt(): number | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return typeof parsed?.ts === 'number' ? parsed.ts : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastSyncedAt(ts: number): void {
|
||||||
|
try { localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts })) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, force = false } = options
|
||||||
|
|
||||||
|
if (!force && this.loaded) {
|
||||||
|
this.emitWritings(this.currentPosts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generation++
|
||||||
|
const currentGeneration = this.generation
|
||||||
|
this.setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||||
|
|
||||||
|
const lastSyncedAt = force ? null : this.getLastSyncedAt()
|
||||||
|
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.BlogPost] }
|
||||||
|
if (lastSyncedAt) filter.since = lastSyncedAt
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
filter,
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
eventStore.add(evt)
|
||||||
|
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${evt.pubkey}:${dTag}`
|
||||||
|
const preview = toPreview(evt)
|
||||||
|
const existing = uniqueByReplaceable.get(key)
|
||||||
|
if (!existing || evt.created_at > existing.event.created_at) {
|
||||||
|
uniqueByReplaceable.set(key, preview)
|
||||||
|
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||||
|
this.currentPosts = sorted
|
||||||
|
this.emitWritings(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
|
||||||
|
events.forEach(evt => {
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${evt.pubkey}:${dTag}`
|
||||||
|
const existing = uniqueByReplaceable.get(key)
|
||||||
|
if (!existing || evt.created_at > existing.event.created_at) {
|
||||||
|
uniqueByReplaceable.set(key, toPreview(evt))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||||
|
this.currentPosts = sorted
|
||||||
|
this.loaded = true
|
||||||
|
this.emitWritings(sorted)
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const newest = Math.max(...sorted.map(p => p.event.created_at))
|
||||||
|
this.setLastSyncedAt(newest)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.currentPosts = []
|
||||||
|
this.emitWritings(this.currentPosts)
|
||||||
|
} finally {
|
||||||
|
if (currentGeneration === this.generation) this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nostrverseWritingsController = new NostrverseWritingsController()
|
||||||
|
|
||||||
|
|
||||||
250
src/services/writingsController.ts
Normal file
250
src/services/writingsController.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore, Helpers } from 'applesauce-core'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
|
||||||
|
|
||||||
|
type WritingsCallback = (posts: BlogPostPreview[]) => void
|
||||||
|
type LoadingCallback = (loading: boolean) => void
|
||||||
|
|
||||||
|
const LAST_SYNCED_KEY = 'writings_last_synced'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared writings controller
|
||||||
|
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
|
||||||
|
* similar to highlightsController
|
||||||
|
*/
|
||||||
|
class WritingsController {
|
||||||
|
private writingsListeners: WritingsCallback[] = []
|
||||||
|
private loadingListeners: LoadingCallback[] = []
|
||||||
|
|
||||||
|
private currentPosts: BlogPostPreview[] = []
|
||||||
|
private lastLoadedPubkey: string | null = null
|
||||||
|
private generation = 0
|
||||||
|
|
||||||
|
onWritings(cb: WritingsCallback): () => void {
|
||||||
|
this.writingsListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoading(cb: LoadingCallback): () => void {
|
||||||
|
this.loadingListeners.push(cb)
|
||||||
|
return () => {
|
||||||
|
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLoading(loading: boolean): void {
|
||||||
|
this.loadingListeners.forEach(cb => cb(loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitWritings(posts: BlogPostPreview[]): void {
|
||||||
|
this.writingsListeners.forEach(cb => cb(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current writings without triggering a reload
|
||||||
|
*/
|
||||||
|
getWritings(): BlogPostPreview[] {
|
||||||
|
return [...this.currentPosts]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if writings are loaded for a specific pubkey
|
||||||
|
*/
|
||||||
|
isLoadedFor(pubkey: string): boolean {
|
||||||
|
return this.lastLoadedPubkey === pubkey && this.currentPosts.length >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state (for logout or manual refresh)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.generation++
|
||||||
|
this.currentPosts = []
|
||||||
|
this.lastLoadedPubkey = null
|
||||||
|
this.emitWritings(this.currentPosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last synced timestamp for incremental loading
|
||||||
|
*/
|
||||||
|
private getLastSyncedAt(pubkey: string): number | null {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
if (!data) return null
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
return parsed[pubkey] || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last synced timestamp
|
||||||
|
*/
|
||||||
|
private setLastSyncedAt(pubkey: string, timestamp: number): void {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(LAST_SYNCED_KEY)
|
||||||
|
const parsed = data ? JSON.parse(data) : {}
|
||||||
|
parsed[pubkey] = timestamp
|
||||||
|
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[writings] Failed to save last synced timestamp:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
|
||||||
|
*/
|
||||||
|
private toPreview(event: NostrEvent): BlogPostPreview {
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
title: getArticleTitle(event) || 'Untitled',
|
||||||
|
summary: getArticleSummary(event),
|
||||||
|
image: getArticleImage(event),
|
||||||
|
published: getArticlePublished(event),
|
||||||
|
author: event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort posts by published/created date (most recent first)
|
||||||
|
*/
|
||||||
|
private sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
|
||||||
|
return posts.slice().sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load writings for a user (kind:30023)
|
||||||
|
* Streams results and stores in event store
|
||||||
|
*/
|
||||||
|
async start(options: {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
pubkey: string
|
||||||
|
force?: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
const { relayPool, eventStore, pubkey, force = false } = options
|
||||||
|
|
||||||
|
// Skip if already loaded for this pubkey (unless forced)
|
||||||
|
if (!force && this.isLoadedFor(pubkey)) {
|
||||||
|
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||||
|
this.emitWritings(this.currentPosts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment generation to cancel any in-flight work
|
||||||
|
this.generation++
|
||||||
|
const currentGeneration = this.generation
|
||||||
|
|
||||||
|
this.setLoading(true)
|
||||||
|
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||||
|
|
||||||
|
// Get last synced timestamp for incremental loading
|
||||||
|
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
|
||||||
|
const filter: { kinds: number[]; authors: string[]; since?: number } = {
|
||||||
|
kinds: [KINDS.BlogPost],
|
||||||
|
authors: [pubkey]
|
||||||
|
}
|
||||||
|
if (lastSyncedAt) {
|
||||||
|
filter.since = lastSyncedAt
|
||||||
|
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
filter,
|
||||||
|
{
|
||||||
|
onEvent: (evt) => {
|
||||||
|
// Check if this generation is still active
|
||||||
|
if (currentGeneration !== this.generation) return
|
||||||
|
|
||||||
|
if (seenIds.has(evt.id)) return
|
||||||
|
seenIds.add(evt.id)
|
||||||
|
|
||||||
|
// Store in event store immediately
|
||||||
|
eventStore.add(evt)
|
||||||
|
|
||||||
|
// Dedupe by replaceable key (author + d-tag)
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${evt.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const preview = this.toPreview(evt)
|
||||||
|
const existing = uniqueByReplaceable.get(key)
|
||||||
|
|
||||||
|
// Keep the newest version for replaceable events
|
||||||
|
if (!existing || evt.created_at > existing.event.created_at) {
|
||||||
|
uniqueByReplaceable.set(key, preview)
|
||||||
|
|
||||||
|
// Stream to listeners
|
||||||
|
const sortedPosts = this.sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||||
|
this.currentPosts = sortedPosts
|
||||||
|
this.emitWritings(sortedPosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if still active after async operation
|
||||||
|
if (currentGeneration !== this.generation) {
|
||||||
|
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store all events in event store
|
||||||
|
events.forEach(evt => eventStore.add(evt))
|
||||||
|
|
||||||
|
// Final processing - ensure we have the latest version of each replaceable
|
||||||
|
events.forEach(evt => {
|
||||||
|
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const key = `${evt.pubkey}:${dTag}`
|
||||||
|
const existing = uniqueByReplaceable.get(key)
|
||||||
|
|
||||||
|
if (!existing || evt.created_at > existing.event.created_at) {
|
||||||
|
uniqueByReplaceable.set(key, this.toPreview(evt))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sorted = this.sortPosts(Array.from(uniqueByReplaceable.values()))
|
||||||
|
|
||||||
|
this.currentPosts = sorted
|
||||||
|
this.lastLoadedPubkey = pubkey
|
||||||
|
this.emitWritings(sorted)
|
||||||
|
|
||||||
|
// Update last synced timestamp
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
|
||||||
|
this.setLastSyncedAt(pubkey, newestTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[writings] ❌ Failed to load writings:', error)
|
||||||
|
this.currentPosts = []
|
||||||
|
this.emitWritings(this.currentPosts)
|
||||||
|
} finally {
|
||||||
|
// Only clear loading if this generation is still active
|
||||||
|
if (currentGeneration === this.generation) {
|
||||||
|
this.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const writingsController = new WritingsController()
|
||||||
|
|
||||||
Reference in New Issue
Block a user