Compare commits

..

31 Commits

Author SHA1 Message Date
Gigi
73e2e060e3 chore: bump version to 0.7.4 2025-10-19 01:19:10 +02:00
Gigi
3007ae83c2 fix(profile): display cached highlights and writings instantly, fetch fresh in background 2025-10-19 01:17:35 +02:00
Gigi
a862eb880e feat(profile): preload all highlights and writings into event store 2025-10-19 01:15:01 +02:00
Gigi
016e369fb1 feat(highlights): only show nostrverse filter when logged out 2025-10-19 01:09:39 +02:00
Gigi
4f21982c48 feat(me): show bookmarks in cards view on /me/bookmarks tab 2025-10-19 01:08:42 +02:00
Gigi
f6d3fe9aba docs: update CHANGELOG.md for v0.7.3 release 2025-10-19 01:06:19 +02:00
Gigi
fc60e6b80a chore: bump version to 0.7.3 2025-10-19 01:04:48 +02:00
Gigi
d9cdbb7279 Merge pull request #20 from dergigi/writings-controller
Make Explore non-blocking; centralize nostrverse highlights & writings controllers
2025-10-19 01:03:25 +02:00
Gigi
401d333e0f fix(explore): logged-out mode relies solely on centralized nostrverse controllers; start controllers even when logged out 2025-10-19 00:58:07 +02:00
Gigi
d32a47e3c3 perf(explore): make loading fully non-blocking; seed caches then stream and merge results progressively 2025-10-19 00:55:24 +02:00
Gigi
35efdb6d3f feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init 2025-10-19 00:52:32 +02:00
Gigi
c7f7792d73 feat(highlights): add centralized nostrverseHighlightsController; start at app init; Explore subscribes to controller stream 2025-10-19 00:50:12 +02:00
Gigi
8aa26caae0 feat(explore): show skeletons instead of spinner; keep nostrverse non-blocking and stream into view 2025-10-19 00:48:24 +02:00
Gigi
6c00904bd5 fix(explore,nostrverse): never block explore highlights on nostrverse; show empty state instead of spinner and stream results into store immediately 2025-10-19 00:46:16 +02:00
Gigi
23526954ea fix(explore): reflect settings default scope immediately and avoid blank lists; preload/merge nostrverse from event store and keep fetches non-blocking 2025-10-19 00:42:39 +02:00
Gigi
9a437dd97b fix(explore): ensure nostrverse highlights are loaded and merged; preload nostrverse highlights at app start for instant Explore toggle 2025-10-19 00:38:05 +02:00
Gigi
0baf75462c refactor(explore): use writingsController for 'mine' posts; keep fetches non-blocking and centralized 2025-10-19 00:34:21 +02:00
Gigi
30b8f1af92 feat(writings): auto-load user writings at login so Explore 'mine' tab has local data 2025-10-19 00:30:07 +02:00
Gigi
07aea9d35f fix(explore): prevent disabling all explore scopes; ensure at least one filter remains active 2025-10-19 00:28:55 +02:00
Gigi
41a4abff37 fix(highlights): scope highlights to current article on /a and /r by deriving coordinate from naddr for early filtering, and ensure sidebar/content only show scoped highlights 2025-10-19 00:24:37 +02:00
Gigi
c9998984c3 feat(explore): include and stream my writings when enabled\n\n- Load my own writings in parallel with friends/nostrverse\n- Lazy-load on 'mine' toggle when logged in\n- Keep dedupe/sort consistent 2025-10-19 00:16:01 +02:00
Gigi
a799709e62 fix(explore): ensure writings are deduped by replaceable before visibility filtering and render 2025-10-19 00:14:20 +02:00
Gigi
18c6c3e68a fix(content): show only article-specific highlights in ContentPanel for nostr articles 2025-10-19 00:12:49 +02:00
Gigi
5e7395652f feat(explore): stream nostrverse writings when toggled on while logged in\n\n- Lazy-load nostrverse via onPost callback when filter is enabled\n- Avoid reloading twice using hasLoadedNostrverse guard\n- Keep DRY dedupe/sort behavior 2025-10-19 00:08:06 +02:00
Gigi
83076e7b01 feat(explore): stream nostrverse writings to paint instantly\n\n- Add onPost streaming callback to fetchNostrverseBlogPosts\n- Stream posts in Explore when logged out and logged in\n- Keep final deduped/sorted list after stream completes 2025-10-19 00:04:53 +02:00
Gigi
c79f4122da feat(debug): add Writings Loading section to debug page
- Add handlers for loading my writings, friends writings, and nostrverse writings
- Display writings with title, summary, author, and d-tag
- Show timing metrics (total load time and first event time)
- Use writingsController for own writings to test controller functionality
2025-10-18 23:57:46 +02:00
Gigi
179fe0bbc2 fix(explore): prevent infinite loop when loading nostrverse content
- Remove cachedHighlights, cachedWritings, myHighlights from useEffect deps
- These are derived from eventStore and caused infinite refetch loop
- Content is still seeded from cache but doesn't trigger re-fetches
2025-10-18 23:54:02 +02:00
Gigi
20b4f2b1b2 fix(explore): fetch nostrverse content when logged out
- Allow exploring nostrverse writings and highlights without account
- Default to nostrverse visibility when logged out
- Update visibility settings when login state changes
2025-10-18 23:50:12 +02:00
Gigi
936f9093cf fix(me): use myWritingsLoading state in writings tab rendering 2025-10-18 23:45:16 +02:00
Gigi
3149e5b824 feat(services): add centralized writingsController for kind 30023 2025-10-18 23:43:16 +02:00
Gigi
8619cecaf3 docs: update CHANGELOG.md for v0.7.2 2025-10-18 23:32:10 +02:00
13 changed files with 1441 additions and 160 deletions

View File

@@ -7,6 +7,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### 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
- 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.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

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.7.2",
"version": "0.7.4",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -23,6 +23,10 @@ import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
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 ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -109,9 +113,29 @@ function AppRoutes({
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
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])
// 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)
const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) {

View File

@@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
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.'
@@ -94,6 +96,12 @@ const Debug: React.FC<DebugProps> = ({
const [tLoadHighlights, setTLoadHighlights] = 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
const [liveTiming, setLiveTiming] = useState<{
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 () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -1070,6 +1260,99 @@ const Debug: React.FC<DebugProps> = ({
)}
</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>&quot;{title}&quot;</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 */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
@@ -27,6 +28,8 @@ import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
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 [loading, setLoading] = useState(true)
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
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)
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, [])
// Visibility filters (defaults from settings)
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
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
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
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
useEffect(() => {
if (propActiveTab) {
@@ -93,21 +198,22 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setLoading(false)
return
}
try {
// show spinner but keep existing data
// begin load, but do not block rendering
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
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
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)
const contacts = await fetchContacts(
relayPool,
activeAccount.pubkey,
activeAccount?.pubkey || '',
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
@@ -184,7 +293,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
@@ -213,7 +322,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
@@ -229,14 +338,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const next = [...prev, highlight]
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) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
@@ -250,52 +359,143 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Store final followed pubkeys
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 contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
])
// Use centralized writingsController for my posts (non-blocking)
// pull from writingsController; no need to store promise
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
setHasLoadedMine(true)
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
const allPosts = [...friendsPosts, ...nostrversePosts]
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Fire non-blocking fetches and merge as they resolve
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
.then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
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)
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
fetchHighlightsFromAuthors(relayPool, contactsArray)
.then((friendsHighlights) => {
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
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
nostrversePostsPromise.then((nostrversePosts) => {
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
}).catch(() => {})
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
// loading is already turned off after seeding
}
}
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
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -333,10 +533,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}, [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
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
return uniqueSortedPosts
.filter(post => {
// Filter out future dates
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'
return { ...post, level }
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
@@ -403,7 +613,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
return classifiedHighlights.length === 0 ? (
<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 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 showSkeletons = (loading || myHighlightsLoading) && !hasData
const showSkeletons = loading && !hasData
return (
<div className="explore-container">
@@ -451,7 +661,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
onClick={() => toggleScope('nostrverse')}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
@@ -462,7 +672,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
onClick={() => toggleScope('friends')}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
@@ -474,7 +684,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
onClick={() => toggleScope('mine')}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"

View File

@@ -46,36 +46,38 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
opacity: highlightVisibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
ariaLabel="Toggle friends highlights"
variant="ghost"
disabled={!currentUserPubkey}
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
ariaLabel="Toggle my highlights"
variant="ghost"
disabled={!currentUserPubkey}
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
{currentUserPubkey && (
<>
<IconButton
icon={faUserGroup}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
friends: !highlightVisibility.friends
})}
title="Toggle friends highlights"
ariaLabel="Toggle friends highlights"
variant="ghost"
style={{
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: highlightVisibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onHighlightVisibilityChange({
...highlightVisibility,
mine: !highlightVisibility.mine
})}
title="Toggle my highlights"
ariaLabel="Toggle my highlights"
variant="ghost"
style={{
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: highlightVisibility.mine ? 1 : 0.4
}}
/>
</>
)}
</div>
)}
{onRefresh && (

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'
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 { IEventStore, Helpers } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
@@ -11,6 +11,7 @@ import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
@@ -20,7 +21,6 @@ import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
@@ -81,6 +81,10 @@ const Me: React.FC<MeProps> = ({
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
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)
const cachedHighlights = useStoreTimeline(
eventStore,
@@ -104,7 +108,6 @@ const Me: React.FC<MeProps> = ({
toBlogPostPreview,
[viewingPubkey, isOwnProfile]
)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
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
useEffect(() => {
if (propActiveTab) {
@@ -169,30 +186,41 @@ const Me: React.FC<MeProps> = ({
const loadHighlightsTab = async () => {
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 hasCachedData = cachedHighlights.length > 0
try {
if (!hasBeenLoaded) setLoading(true)
// 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) {
// Seed with cached highlights first
if (cachedHighlights.length > 0) {
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh highlights
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
if (isOwnProfile) {
setLoadedTabs(prev => new Set(prev).add('highlights'))
setLoading(false)
return
}
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) {
console.error('Failed to load highlights:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
setLoading(false)
}
}
@@ -200,27 +228,49 @@ const Me: React.FC<MeProps> = ({
if (!viewingPubkey) return
const hasBeenLoaded = loadedTabs.has('writings')
const hasCachedData = cachedWritings.length > 0
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
if (!isOwnProfile && cachedWritings.length > 0) {
// For other profiles, seed with cached writings immediately (non-blocking)
if (hasCachedData) {
setWritings(cachedWritings.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
}))
setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
} else if (!hasBeenLoaded) {
setLoading(true)
}
// Fetch fresh writings
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings'))
// Fetch fresh writings in background and merge
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
.then(userWritings => {
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
})
.catch(err => {
console.error('Failed to load writings:', err)
setLoading(false)
})
} catch (err) {
console.error('Failed to load writings:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
setLoading(false)
}
}
@@ -375,6 +425,41 @@ const Me: React.FC<MeProps> = ({
}
}, [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
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
@@ -529,9 +614,9 @@ const Me: React.FC<MeProps> = ({
if (showSkeletons) {
return (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
<div className="bookmarks-grid bookmarks-cards">
{Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
<BookmarkSkeleton key={i} viewMode="cards" />
))}
</div>
</div>
@@ -557,13 +642,13 @@ const Me: React.FC<MeProps> = ({
sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<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) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
viewMode="cards"
onSelectUrl={handleSelectUrl}
/>
))}
@@ -585,27 +670,6 @@ const Me: React.FC<MeProps> = ({
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
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>
)
@@ -714,7 +778,7 @@ const Me: React.FC<MeProps> = ({
</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)' }}>
No articles written yet.
</div>

View File

@@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
summary={props.readerContent?.summary}
published={props.readerContent?.published}
selectedUrl={props.selectedUrl}
highlights={props.classifiedHighlights}
highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:')
? props.highlights // article-specific highlights only
: props.classifiedHighlights}
showHighlights={props.showHighlights}
highlightStyle={props.settings.highlightStyle || 'marker'}
highlightColor={props.settings.highlightColor || '#ffff00'}

View File

@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
const [isRefreshing, setIsRefreshing] = useState(false)
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
const articleFilter = useMemo(() => {
if (!currentArticleCoordinate) return null
if (!effectiveArticleCoordinate) return null
return {
kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate],
'#a': [effectiveArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
}
}, [currentArticleCoordinate, currentArticleEventId])
}, [effectiveArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[currentArticleCoordinate, currentArticleEventId]
[effectiveArticleCoordinate, currentArticleEventId]
)
// Subscribe to centralized controllers
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
if (effectiveArticleCoordinate) {
// Seed with cached highlights first
if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
effectiveArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Deduplicate highlights by ID as they arrive
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
} finally {
setHighlightsLoading(false)
}
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -143,19 +161,20 @@ export const useBookmarksData = ({
if (!relayPool || !activeAccount) return
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (currentArticleCoordinate && !externalUrl) {
if (effectiveArticleCoordinate && !externalUrl) {
handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
// Merge highlights from controller with article-specific highlights
const highlights = [...myHighlights, ...articleHighlights]
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
.sort((a, b) => b.created_at - a.created_at)
// When viewing an article, show only article-specific highlights
// Otherwise, show user's highlights from controller
const highlights = effectiveArticleCoordinate || externalUrl
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
: myHighlights
return {
highlights,

View 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()

View File

@@ -20,7 +20,8 @@ export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50,
eventStore?: IEventStore
eventStore?: IEventStore,
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
@@ -44,6 +45,19 @@ export const fetchNostrverseBlogPosts = async (
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
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)
const seenIds = new Set<string>()
// Collect but do not block callers awaiting network completion
const collected: NostrEvent[] = []
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], limit },
@@ -104,6 +120,7 @@ export const fetchNostrverseHighlights = async (
if (eventStore) {
eventStore.add(event)
}
collected.push(event)
}
}
)
@@ -113,7 +130,7 @@ export const fetchNostrverseHighlights = async (
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights(rawEvents)
const uniqueEvents = dedupeHighlights([...collected, ...rawEvents])
const highlights = uniqueEvents.map(eventToHighlight)
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')

View 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()

View 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()