Compare commits

...

6 Commits

4 changed files with 183 additions and 86 deletions

View File

@@ -7,6 +7,74 @@ 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
@@ -1910,7 +1978,8 @@ 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.2...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

View File

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

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'
@@ -21,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'
@@ -109,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'>(() => {
@@ -188,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)
}
}
@@ -219,10 +228,9 @@ 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({
@@ -232,26 +240,37 @@ const Me: React.FC<MeProps> = ({
force: refreshTrigger > 0
})
setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
return
}
// For other profiles, seed with cached writings first
if (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 for other profiles
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)
}
}
@@ -413,6 +432,34 @@ const Me: React.FC<MeProps> = ({
}
}, [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: () => {
@@ -567,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>
@@ -595,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}
/>
))}
@@ -623,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>
)