Compare commits

...

41 Commits

Author SHA1 Message Date
Gigi
d40c49edb0 chore: bump version to 0.7.2 2025-10-18 23:31:25 +02:00
Gigi
ce5d97fb1f Merge pull request #19 from dergigi/loading-improvements-etc
Implement cached-first loading with EventStore
2025-10-18 23:30:46 +02:00
Gigi
ffb8031a05 feat: implement cached-first loading with EventStore across app
- Add useStoreTimeline hook for reactive EventStore queries
- Add dedupe helpers for highlights and writings
- Explore: seed highlights and writings from store instantly
- Article sidebar: seed article-specific highlights from store
- External URLs: seed URL-specific highlights from store
- Profile pages: seed other-profile highlights and writings from store
- Remove debug logging
- All data loads from cache first, then updates with fresh data
- Follows DRY principles with single reusable hook
2025-10-18 23:03:48 +02:00
Gigi
d54e1072b8 feat: load highlights from event store for instant display
- Use eventStore.timeline() to query cached highlights
- Seed Explore page with cached highlights immediately
- Provides instant display of nostrverse highlights from store
- Fresh data still fetched in background and merged
- Follows applesauce pattern with useObservableMemo
2025-10-18 22:31:59 +02:00
Gigi
55defb645c debug: prefix all nostrverse logs with [NOSTRVERSE]
- Makes it easy to filter console logs
- Updated logs in nostrverseService.ts and Explore.tsx
- All relevant logs now have consistent prefix
2025-10-18 22:25:02 +02:00
Gigi
1ba9595542 debug: add console logging for nostrverse highlights
- Log highlight counts by source (mine, friends, nostrverse)
- Log classified highlights by level
- Log visibility filter state and results
- Helps diagnose why nostrverse content isn't appearing
2025-10-18 22:22:34 +02:00
Gigi
340913f15f fix: force React to remount tab content when switching tabs
- Add key prop based on activeTab to wrapper div
- Forces complete unmount/remount of content when switching tabs
- Prevents DOM element reuse that was causing blog posts to bleed into highlights tab
2025-10-18 22:20:27 +02:00
Gigi
1d6595f754 fix: deduplicate blog posts by author:d-tag instead of event ID
- Use consistent deduplication key (author:d-tag) for replaceable events
- Prevents duplicate blog posts when same article has multiple event IDs
- Streaming updates now properly replace older versions with newer ones
- Fixes issue where same blog post card appeared multiple times
2025-10-18 22:19:17 +02:00
Gigi
6099e3c6a4 feat: store nostrverse content in centralized event store
- Add eventStore parameter to fetchNostrverseBlogPosts
- Add eventStore parameter to fetchNostrverseHighlights
- Pass eventStore from Explore component to nostrverse fetchers
- Store all nostrverse blog posts and highlights in event store
- Enables offline access to nostrverse content
2025-10-18 22:08:22 +02:00
Gigi
ed75bc6059 feat: store article-specific highlights in centralized event store
- Pass eventStore to fetchHighlightsForArticle in useBookmarksData
- Pass eventStore to fetchHighlightsForUrl in useExternalUrlLoader
- All fetched highlights now persist in the centralized event store
- Enables offline access and consistent state management
2025-10-18 22:05:22 +02:00
Gigi
dcfc08287e refactor: use centralized controllers in highlights sidebar
- Subscribe to highlightsController for user's own highlights
- Subscribe to contactsController for followed pubkeys
- Merge controller highlights with article-specific highlights
- Remove duplicate fetching logic for contacts and own highlights
- Maintain article-specific highlight fetching for context-aware display
2025-10-18 22:01:44 +02:00
Gigi
35b2168f9a fix: get initial highlights state immediately from controller
The subscription pattern only fires on *changes*, not initial state.
When Me component mounts, we need to immediately get the current
highlights from the controller, not wait for a change event.

Before:
- Subscribe to controller
- Wait for controller to emit (only happens on changes)
- Meanwhile, myHighlights stays []

After:
- Get initial state immediately: highlightsController.getHighlights()
- Then subscribe to future updates
- myHighlights is populated right away

This ensures highlights are always available when navigating to
/me/highlights, even if the controller hasn't emitted any new events.
2025-10-18 21:56:27 +02:00
Gigi
f8a9079e5f fix: don't manually set highlights in loadHighlightsTab for own profile
The real issue: loadHighlightsTab was calling setHighlights(myHighlights)
before the controller subscription had populated myHighlights, resulting
in setting highlights to an empty array.

Solution: For own profile, let the sync effect handle setting highlights.
The controller subscription + sync effect is the single source of truth.
Only fetch highlights manually when viewing other users' profiles.

Flow for own profile:
1. Controller subscription populates myHighlights
2. Sync effect (useEffect) updates local highlights state
3. No manual setting needed in loadHighlightsTab

This ensures highlights are always synced from the controller, never
from a stale/empty initial value.
2025-10-18 21:54:27 +02:00
Gigi
780996c7c5 fix: prevent "No highlights yet" flash on /me/highlights
Fix issue where "No highlights yet" message would show briefly when
navigating to /me/highlights even when user has many highlights.

Root cause:
- Sync effect only ran when myHighlights.length > 0
- Local highlights state could be empty during navigation
- "No highlights yet" condition didn't check myHighlightsLoading

Changes:
- Remove length check from sync effect (always sync myHighlights)
- Add myHighlightsLoading check to "No highlights yet" condition
- Now shows skeleton or content, never false empty state

The controller always has the highlights loaded, so we should always
sync them to local state regardless of length.
2025-10-18 21:52:25 +02:00
Gigi
809437faa6 style: make Explore a section title like Zap Splits
Add section-title class to Explore heading to match other section headings.
2025-10-18 21:49:17 +02:00
Gigi
36f14811ae refactor: add dedicated Explore section in settings
Create new ExploreSettings component and organize explore-related settings.

Changes:
- Create src/components/Settings/ExploreSettings.tsx
- Move "Default Explore Scope" from ReadingDisplaySettings to ExploreSettings
- Add ExploreSettings to Settings.tsx above Zap Splits section
- Better organization: explore settings now in dedicated section

Settings order:
1. Theme
2. Reading Display
3. Explore (new)
4. Zap Splits
5. Layout & Behavior
6. PWA
7. Relays
2025-10-18 21:47:34 +02:00
Gigi
8b95af9c49 feat: add default explore scope setting
Add user setting to control default visibility scope in /explore page.

Changes:
- Add defaultExploreScopeNostrverse/Friends/Mine to UserSettings type
- Add "Default Explore Scope" setting in ReadingDisplaySettings UI
- Update Explore component to use defaultExploreScope settings
- Set default to friends-only (nostrverse: false, friends: true, mine: false)

Users can now configure which content types (nostrverse/friends/mine)
are visible by default when visiting the explore page, separate from
the highlight visibility settings.
2025-10-18 21:45:04 +02:00
Gigi
236ade3d2f style: remove background color from explore scope filter buttons
Remove background color from .highlight-level-toggles bar in /explore page.
The visibility filter buttons (nostrverse, friends, mine) now have no
background, making the UI cleaner.
2025-10-18 21:42:53 +02:00
Gigi
c2e882ec31 refactor: simplify highlights state management - remove prop drilling
Remove unnecessary prop drilling of myHighlights/myHighlightsLoading.
Components now subscribe directly to highlightsController (DRY principle).

Changes:
- Explore: Subscribe to controller directly, no props needed
- Me: Subscribe to controller directly, no props needed
- Bookmarks: Remove myHighlights props (no longer passes through)
- App: Remove highlights state, controller manages it internally

Benefits:
-  Simpler code (no prop drilling through 3 layers)
-  More DRY (single source of truth in controller)
-  Consistent with applesauce patterns (like useActiveAccount)
-  Less boilerplate (removed ~30 lines of prop passing)
-  Controller encapsulates all state management

Pattern: Components import and subscribe to controller directly,
just like they use Hooks.useActiveAccount() or other applesauce hooks.
2025-10-18 21:39:33 +02:00
Gigi
0a382e77b9 feat: show skeleton placeholders while highlights are loading
- Pass myHighlightsLoading state from controller through App → Bookmarks → Explore/Me
- Update Explore showSkeletons logic to include myHighlightsLoading
- Update Me showSkeletons logic to include myHighlightsLoading for own profile
- Sync myHighlights to Me component via useEffect for real-time updates
- Remove highlightsController import from Me (now uses props)

Benefits:
- Better UX with skeleton placeholders instead of empty/spinner states
- Consistent loading experience across Explore and Me pages
- Clear visual feedback when highlights are loading from controller
- Smooth transition from skeleton to actual content
2025-10-18 21:34:44 +02:00
Gigi
a1fd4bfc94 feat: use highlights controller in Explore page
- Pass myHighlights from controller through App.tsx → Bookmarks → Explore
- Merge controller highlights with friends/nostrverse highlights
- Seed Explore with myHighlights immediately (no re-fetch needed)
- Eliminate redundant fetching of user's own highlights
- Improve performance and consistency across the app

Benefits:
- User's highlights appear instantly in /explore (already loaded)
- No duplicate fetching of same data
- DRY principle - single source of truth for user highlights
- Better offline support (highlights from controller are in event store)
2025-10-18 21:28:42 +02:00
Gigi
530cc20cba feat: implement centralized highlights controller
- Create highlightsController with subscription API and event store integration
- Auto-load user highlights on app start (alongside bookmarks and contacts)
- Store highlight events in applesauce event store for offline support
- Update Me.tsx to use controller for own profile highlights
- Add optional eventStore parameter to all highlight fetch functions
- Pass eventStore through Debug component for persistent storage
- Implement incremental sync with localStorage-based lastSyncedAt tracking
- Add generation-based cancellation for in-flight requests
- Reset highlights on logout

Closes #highlights-controller
2025-10-18 21:19:57 +02:00
Gigi
a275c0a8e3 refactor: consolidate bookmarks and contacts auto-loading
- Combine both auto-load effects into single useEffect
- Load bookmarks and contacts together when account is ready
- Keep code DRY - same pattern, same timing, same place
- Both use their respective controllers
- Both check loading state before triggering
2025-10-18 21:03:01 +02:00
Gigi
cb43b748e4 temp: disable auto-load of contacts for testing
- Comment out contacts state and subscriptions
- Comment out auto-load effect
- Allows manual testing of contact loading in Debug page
- Remember to re-enable after testing
2025-10-18 20:59:14 +02:00
Gigi
ff9ce46448 refactor: simplify friends highlights loading to use cached contacts
- Remove redundant contact loading check
- Directly use contacts from centralized controller
- App.tsx already auto-loads contacts on login
- Clearer message indicating cached contacts are being used
- Faster execution since no contact loading needed
2025-10-18 20:58:14 +02:00
Gigi
1e6718fe1e fix: improve Load Friends button behavior in Debug
- Add local loading state for button (friendsButtonLoading)
- Clear friends list before loading to show streaming
- Set final result after controller completes
- Add error handling and logging
- Remove unused global friendsLoading subscription
- Button now properly shows loading state and results
2025-10-18 20:56:53 +02:00
Gigi
d6a913f2a6 feat: add centralized contacts controller
- Create contactsController similar to bookmarkController
- Manage friends/contacts list in one place across the app
- Auto-load contacts on login, cache results per pubkey
- Stream partial contacts as they arrive
- Update App.tsx to subscribe to contacts controller
- Update Debug.tsx to use centralized contacts instead of fetching directly
- Reset contacts on logout
- Contacts won't reload unnecessarily (cached by pubkey)
- Debug 'Load Friends' button forces reload to show streaming behavior
2025-10-18 20:51:03 +02:00
Gigi
8030e2fa00 feat: make friends highlights loading non-blocking
- Start fetching highlights immediately when partial contacts arrive
- Track seen authors to avoid duplicate queries
- Fire-and-forget pattern for partial fetches (like bookmark loading)
- Only await final batch for remaining authors
- Highlights stream in progressively as contacts are discovered
- Matches the non-blocking pattern used in Explore.tsx and bookmark loading
2025-10-18 20:47:35 +02:00
Gigi
1ff2f28566 chore: increase nostrverse highlights limit from 100 to 500
- Better for testing and debugging with more realistic data volumes
2025-10-18 20:43:37 +02:00
Gigi
78457335c6 refactor: simplify nostrverse highlights loading to direct query
- Use direct queryEvents with kind:9802 filter instead of service wrapper
- Add streaming with onEvent callback for immediate UI updates
- Track first event timing for performance analysis
- Remove unused fetchNostrverseHighlights import
2025-10-18 20:43:02 +02:00
Gigi
553feb10df feat: add debug buttons for highlight loading and Web of Trust
- Add three quick-load buttons: Load My Highlights, Load Friends Highlights, Load Nostrverse Highlights
- Add Web of Trust section with Load Friends button to display followed npubs
- Stream highlights with dedupe and timing metrics
- Display friends count and scrollable list of npubs
- All buttons respect loading states and account requirements
2025-10-18 20:39:57 +02:00
Gigi
ba5d7df3bd fix: show highlight button for all reading content
- Show highlight button when readerContent exists (both nostr articles and external URLs)
- Hide highlight button when browsing app pages like explore, settings, etc.
- Ensures highlighting is available for all readable content but not for navigation pages
2025-10-18 20:26:11 +02:00
Gigi
cf3ca2d527 feat: show highlight button only when viewing articles
- Only display the floating highlight button when currentArticle exists or selectedUrl is a nostr article
- Prevents highlight button from showing on external URLs, videos, or other content types
- Improves UX by showing highlight functionality only where it's relevant
2025-10-18 20:23:45 +02:00
Gigi
06763d5307 fix: resolve unused variable linting error in Debug.tsx 2025-10-18 20:23:18 +02:00
Gigi
a08e4fdc24 chore: bump version to 0.7.1 2025-10-18 20:22:03 +02:00
Gigi
bc7b4ae42d feat(debug): add time-to-first-event tracking for bookmarks
- Track and display time to first bookmark event arrival
- Mirror highlight loading metrics for consistency
- Shows how quickly local/fast relays respond
- Renamed 'load' stat to 'total' for clarity
- Clear first event timing on reset
2025-10-18 20:20:59 +02:00
Gigi
4dc1894ef3 feat(debug): default highlight loading to logged-in user
- Author mode now defaults to current user's pubkey if not specified
- Changed default mode from 'article' to 'author' for better UX
- Updated placeholder to show logged-in user's pubkey
- Updated description to clarify default behavior
- Makes 'Load Highlights' button immediately useful without input
2025-10-18 20:19:53 +02:00
Gigi
f00f26dfe0 feat(debug): add Highlight Loading section with streaming metrics
- Add query mode selector (Article/#a, URL/#r, Author)
- Stream highlight events as they arrive with onEvent callback
- Track timing metrics: total load time and time-to-first-event
- Display highlight summaries with content, tags, and metadata
- Support EOSE-based completion via queryEvents helper
- Mirror bookmark loading section UX for consistency
2025-10-18 10:05:56 +02:00
Gigi
2e59bc9375 feat(highlights): add optional session cache with TTL
- Add in-memory cache with 60s TTL for article/url/author queries
- Check cache before network fetch to reduce redundant queries
- Support force flag to bypass cache when needed
- Stream cached results through onHighlight callback for consistency
2025-10-18 10:04:13 +02:00
Gigi
0d50d05245 feat(highlights): refactor fetchers to use EOSE-based queryEvents
- Replace ad-hoc Rx timeout-based queries with centralized queryEvents helper
- Remove artificial timeouts (1200ms/6000ms) in favor of EOSE signals
- Use KINDS.Highlights consistently instead of hardcoded 9802
- Maintain streaming callbacks for instant UI updates
- Parallel queries for article #a and #e tags
- Local-first relay prioritization via queryEvents
2025-10-18 10:03:13 +02:00
Gigi
90c74a8e9d docs: update CHANGELOG.md for v0.7.0 2025-10-18 09:50:23 +02:00
24 changed files with 1745 additions and 263 deletions

View File

@@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.7.0] - 2025-10-18
### Added
- Login with Bunker (NIP-46) authentication support
- Support for remote signing via Nostr Connect protocol
- Bunker URI input with validation and error handling
- Automatic reconnection on app restore with proper permissions
- Signer suggestions in error messages (Amber, nsec.app, Nostrum)
- Debug page (`/debug`) for diagnostics and testing
- Interactive NIP-04 and NIP-44 encryption/decryption testing
- Live performance timing with stopwatch display
- Bookmark loading and decryption diagnostics
- Real-time bunker logs with filtering and clearing
- Version and git commit footer
- Progressive bookmark loading with streaming updates
- Non-blocking, progressive bookmark updates via callback pattern
- Batched background hydration using EventLoader and AddressLoader
- Auto-decrypt bookmarks as they arrive from relays
- Individual decrypt buttons for encrypted bookmark events
- Bookmark grouping toggle (grouped by source vs flat chronological)
- Toggle between grouped view and flat chronological list
- Amethyst-style bookmark detection and grouping
- Display bookmarks even when they only have IDs (content loads in background)
### Changed
- Improved login UI with better copy and modern design
- Personable title and nostr-native language
- Highlighted 'your own highlights' in login copy
- Simplified button text to single words (Extension, Signer)
- Hide login button and user icon when logged out
- Hide Extension button when Bunker input is shown
- Auto-load bookmarks on login and page mount
- Enhanced bunker error messages
- Formatted error messages with signer suggestions
- Links to nos2x, Amber, nsec.app, and Nostrum signers
- Better error handling for missing signer extensions
- Centered and constrained bunker input field
- Centralized bookmark loading architecture
- Single shared bookmark controller for consistent loading
- Unified bookmark loading with streaming and auto-decrypt
- Consolidated bookmark loading into single centralized function
- Bookmarks passed as props throughout component tree
- Renamed UI elements for clarity
- "Bunker" button renamed to "Signer"
- Hide bookmark controls when logged out
- Settings version footer improvements
- Separate links for version (to GitHub release) and commit (to commit page)
- Proper spacing around middot separator
### Fixed
- NIP-46 bunker signing and decryption
- NostrConnectSigner properly reconnects with permissions on app restore
- Bunker relays added to relay pool for signing requests
- Proper setup of pool and relays before bunker reconnection
- Expose nip04/nip44 on NostrConnectAccount for bookmark decryption
- Cache wrapped nip04/nip44 objects instead of using getters
- Wait for bunker relay connections before marking signer ready
- Validate bunker URI (remote must differ from user pubkey)
- Accept remote===pubkey for Amber compatibility
- Bookmark loading and decryption
- Bookmarks load and complete properly with streaming
- Auto-decrypt private bookmarks with NIP-04 detection
- Include decrypted private bookmarks in sidebar
- Skip background event fetching when there are too many IDs
- Only build bookmarks from ready events (unencrypted or decrypted)
- Restore Debug page decrypt display via onDecryptComplete callback
- Make controller onEvent non-blocking for queryEvents completion
- Proper timeout handling for bookmark decryption (no hanging)
- Smart encryption detection with consistent padlock display
- Sequential decryption instead of concurrent to avoid queue issues
- Add extraRelays to EventLoader and AddressLoader
- PWA cache limit increased to 3 MiB for larger bundles
- Extension login error messages with nos2x link
- TypeScript and linting errors throughout
- Replace empty catch blocks with warnings
- Fix explicit any types
- Add missing useEffect dependencies
- Resolve all linting issues in App.tsx, Debug.tsx, and async utilities
### Performance
- Non-blocking NIP-46 operations
- Fire-and-forget NIP-46 publish for better UI responsiveness
- Non-blocking bookmark decryption with sequential processing
- Make controller onEvent non-blocking for queryEvents completion
- Optimized bookmark loading
- Batched background hydration using EventLoader and AddressLoader
- Progressive, non-blocking bookmark loading with streaming
- Shorter timeouts for debug page bookmark loading
- Remove artificial delays from bookmark decryption
### Refactored
- Centralized bookmark controller architecture
- Extract bookmark streaming helpers and centralize loading
- Consolidated bookmark loading into single function
- Remove deprecated bookmark service files
- Share bookmark controller between components
- Debug page organization
- Extract VersionFooter component to eliminate duplication
- Structured sections with proper layout and styling
- Apply settings page styling structure
- Simplified bunker implementation following applesauce patterns
- Clean up bunker implementation for better maintainability
- Import RELAYS from central config (DRY principle)
- Update RELAYS list with relay.nsec.app
### Documentation
- Comprehensive Amber.md documentation
- Amethyst-style bookmarks section
- Bunker decrypt investigation summary
- Critical queue disabling requirement
- NIP-46 setup and troubleshooting
## [0.6.24] - 2025-01-16
### Fixed
@@ -1760,7 +1878,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.6.24...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
[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
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21

View File

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

View File

@@ -21,6 +21,8 @@ import { SkeletonThemeProvider } from './components/Skeletons'
import { DebugBus } from './utils/debugBus'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
import { highlightsController } from './services/highlightsController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -28,9 +30,11 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
// AppRoutes component that has access to hooks
function AppRoutes({
relayPool,
eventStore,
showToast
}: {
relayPool: RelayPool
eventStore: EventStore | null
showToast: (message: string) => void
}) {
const accountManager = Hooks.useAccountManager()
@@ -40,6 +44,10 @@ function AppRoutes({
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(false)
// Centralized contacts state (fed by controller)
const [contacts, setContacts] = useState<Set<string>>(new Set())
const [contactsLoading, setContactsLoading] = useState(false)
// Subscribe to bookmark controller
useEffect(() => {
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
@@ -59,13 +67,50 @@ function AppRoutes({
}
}, [])
// Auto-load bookmarks when account is ready (on login or page mount)
// Subscribe to contacts controller
useEffect(() => {
if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager })
console.log('[contacts] 🎧 Subscribing to contacts controller')
const unsubContacts = contactsController.onContacts((contacts) => {
console.log('[contacts] 📥 Received contacts:', contacts.size)
setContacts(contacts)
})
const unsubLoading = contactsController.onLoading((loading) => {
console.log('[contacts] 📥 Loading state:', loading)
setContactsLoading(loading)
})
return () => {
console.log('[contacts] 🔇 Unsubscribing from contacts controller')
unsubContacts()
unsubLoading()
}
}, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, accountManager])
}, [])
// Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount)
useEffect(() => {
if (activeAccount && relayPool) {
const pubkey = (activeAccount as { pubkey?: string }).pubkey
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager })
}
// Load contacts
if (pubkey && contacts.size === 0 && !contactsLoading) {
console.log('[contacts] 🚀 Auto-loading contacts on mount/login')
contactsController.start({ relayPool, pubkey })
}
// Load highlights (controller manages its own state)
if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) {
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey })
}
}
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
// Manual refresh (for sidebar button)
const handleRefreshBookmarks = useCallback(async () => {
@@ -81,6 +126,8 @@ function AppRoutes({
const handleLogout = () => {
accountManager.clearActive()
bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
showToast('Logged out successfully')
}
@@ -263,6 +310,7 @@ function AppRoutes({
element={
<Debug
relayPool={relayPool}
eventStore={eventStore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
@@ -639,7 +687,7 @@ function App() {
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
<RouteDebug />
</div>
</BrowserRouter>

View File

@@ -35,7 +35,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
onLogout,
bookmarks,
bookmarksLoading,
onRefreshBookmarks
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const location = useLocation()
@@ -179,6 +179,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore,
onRefreshBookmarks
})
@@ -242,6 +243,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
useExternalUrlLoader({
url: externalUrl,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -325,10 +327,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

@@ -3,11 +3,11 @@ import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { Accounts } from 'applesauce-accounts'
import { NostrConnectSigner } from 'applesauce-signers'
import { RelayPool } from 'applesauce-relay'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
import ThreePaneLayout from './ThreePaneLayout'
@@ -16,11 +16,14 @@ import type { NostrEvent } from '../services/bookmarkHelpers'
import { Bookmark } from '../types/bookmarks'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
interface DebugProps {
relayPool: RelayPool | null
eventStore: IEventStore | null
bookmarks: Bookmark[]
bookmarksLoading: boolean
onRefreshBookmarks: () => Promise<void>
@@ -29,6 +32,7 @@ interface DebugProps {
const Debug: React.FC<DebugProps> = ({
relayPool,
eventStore,
bookmarks,
bookmarksLoading,
onRefreshBookmarks,
@@ -37,11 +41,10 @@ const Debug: React.FC<DebugProps> = ({
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
const { settings, saveSettings } = useSettings({
relayPool,
eventStore,
eventStore: eventStore!,
pubkey: activeAccount?.pubkey,
accountManager
})
@@ -76,17 +79,33 @@ const Debug: React.FC<DebugProps> = ({
const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null)
const [tLoadBookmarks, setTLoadBookmarks] = useState<number | null>(null)
const [tDecryptBookmarks, setTDecryptBookmarks] = useState<number | null>(null)
const [tFirstBookmark, setTFirstBookmark] = useState<number | null>(null)
// Individual event decryption results
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
// Highlight loading state
const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author')
const [highlightArticleCoord, setHighlightArticleCoord] = useState<string>('')
const [highlightUrl, setHighlightUrl] = useState<string>('')
const [highlightAuthor, setHighlightAuthor] = useState<string>('')
const [isLoadingHighlights, setIsLoadingHighlights] = useState(false)
const [highlightEvents, setHighlightEvents] = useState<NostrEvent[]>([])
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
// Live timing state
const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
loadBookmarks?: { startTime: number }
decryptBookmarks?: { startTime: number }
loadHighlights?: { startTime: number }
}>({})
// Web of Trust state
const [friendsPubkeys, setFriendsPubkeys] = useState<Set<string>>(new Set())
const [friendsButtonLoading, setFriendsButtonLoading] = useState(false)
useEffect(() => {
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
@@ -243,10 +262,12 @@ const Debug: React.FC<DebugProps> = ({
setBookmarkStats(null)
setBookmarkEvents([]) // Clear existing events
setDecryptedEvents(new Map())
setTFirstBookmark(null)
DebugBus.info('debug', 'Loading bookmark events...')
// Start timing
const start = performance.now()
let firstEventTime: number | null = null
setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } }))
// Import controller at runtime to avoid circular dependencies
@@ -254,6 +275,12 @@ const Debug: React.FC<DebugProps> = ({
// Subscribe to raw events for Debug UI display
const unsubscribeRaw = bookmarkController.onRawEvent((evt) => {
// Track time to first event
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstBookmark(Math.round(firstEventTime))
}
// Add event immediately with live deduplication
setBookmarkEvents(prev => {
const key = getEventKey(evt)
@@ -311,10 +338,243 @@ const Debug: React.FC<DebugProps> = ({
setBookmarkStats(null)
setTLoadBookmarks(null)
setTDecryptBookmarks(null)
setTFirstBookmark(null)
setDecryptedEvents(new Map())
DebugBus.info('debug', 'Cleared bookmark data')
}
const handleLoadHighlights = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Cannot load highlights: missing relayPool')
return
}
// Default to logged-in user's highlights if no specific query provided
const getValue = () => {
if (highlightMode === 'article') return highlightArticleCoord.trim()
if (highlightMode === 'url') return highlightUrl.trim()
const authorValue = highlightAuthor.trim()
return authorValue || pubkey || ''
}
const value = getValue()
if (!value) {
DebugBus.warn('debug', 'Please provide a value to query or log in')
return
}
try {
setIsLoadingHighlights(true)
setHighlightEvents([])
setTFirstHighlight(null)
DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`)
const start = performance.now()
setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } }))
let firstEventTime: number | null = null
const seenIds = new Set<string>()
// Import highlight services
const { queryEvents } = await import('../services/dataFetch')
const { KINDS } = await import('../config/kinds')
// Build filter based on mode
let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] }
if (highlightMode === 'article') {
filter = { kinds: [KINDS.Highlights], '#a': [value] }
} else if (highlightMode === 'url') {
filter = { kinds: [KINDS.Highlights], '#r': [value] }
} else {
filter = { kinds: [KINDS.Highlights], authors: [value] }
}
const events = await queryEvents(relayPool, filter, {
onEvent: (evt) => {
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => [...prev, evt])
}
})
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
setLiveTiming(prev => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const { loadHighlights, ...rest } = prev
return rest
})
DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`)
} catch (err) {
console.error('Failed to load highlights:', err)
DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setIsLoadingHighlights(false)
}
}
const handleClearHighlights = () => {
setHighlightEvents([])
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Cleared highlight data')
}
const handleLoadMyHighlights = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load your highlights')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Loading my highlights...')
try {
let firstEventTime: number | null = null
await fetchHighlights(relayPool, activeAccount.pubkey, (h) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => {
if (prev.some(x => x.id === h.id)) return prev
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, settings, false, eventStore || undefined)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded my highlights in ${elapsed}ms`)
}
}
const handleLoadFriendsHighlights = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends highlights')
return
}
// Get contacts from centralized controller (should already be loaded by App.tsx)
const contacts = contactsController.getContacts()
if (contacts.size === 0) {
DebugBus.warn('debug', 'No friends found. Make sure you have contacts loaded.')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', `Loading highlights from ${contacts.size} friends (using cached contacts)...`)
let firstEventTime: number | null = null
try {
await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => {
if (prev.some(x => x.id === h.id)) return prev
const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent]
return next.sort((a, b) => b.created_at - a.created_at)
})
}, eventStore || undefined)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded friends highlights in ${elapsed}ms`)
}
}
const handleLoadNostrverseHighlights = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Relay pool not available')
return
}
const start = performance.now()
setHighlightEvents([])
setIsLoadingHighlights(true)
setTLoadHighlights(null)
setTFirstHighlight(null)
DebugBus.info('debug', 'Loading nostrverse highlights (kind:9802)...')
try {
let firstEventTime: number | null = null
const seenIds = new Set<string>()
const { queryEvents } = await import('../services/dataFetch')
const events = await queryEvents(relayPool, { kinds: [9802], limit: 500 }, {
onEvent: (evt) => {
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstHighlight(Math.round(firstEventTime))
}
setHighlightEvents(prev => [...prev, evt])
}
})
DebugBus.info('debug', `Loaded ${events.length} nostrverse highlights`)
} finally {
setIsLoadingHighlights(false)
const elapsed = Math.round(performance.now() - start)
setTLoadHighlights(elapsed)
DebugBus.info('debug', `Loaded nostrverse highlights in ${elapsed}ms`)
}
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
return
}
setFriendsButtonLoading(true)
DebugBus.info('debug', 'Loading friends list via controller...')
// Clear current list
setFriendsPubkeys(new Set())
// Subscribe to controller updates to see streaming
const unsubscribe = contactsController.onContacts((contacts) => {
console.log('[debug] Received contacts update:', contacts.size)
setFriendsPubkeys(new Set(contacts))
})
try {
// Force reload to see streaming behavior
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true })
const final = contactsController.getContacts()
setFriendsPubkeys(new Set(final))
DebugBus.info('debug', `Loaded ${final.size} friends from controller`)
} catch (err) {
console.error('[debug] Failed to load friends:', err)
DebugBus.error('debug', `Failed to load friends: ${err instanceof Error ? err.message : String(err)}`)
} finally {
unsubscribe()
setFriendsButtonLoading(false)
}
}
const friendsNpubs = useMemo(() => {
return Array.from(friendsPubkeys).map(pk => nip19.npubEncode(pk))
}, [friendsPubkeys])
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setBunkerError('Please enter a bunker URI')
@@ -376,7 +636,7 @@ const Debug: React.FC<DebugProps> = ({
return null
}
const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks') => {
const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => {
const timing = liveTiming[operation]
if (timing) {
const elapsed = Math.round(performance.now() - timing.startTime)
@@ -390,7 +650,7 @@ const Debug: React.FC<DebugProps> = ({
value?: string | number | null;
mode?: 'nip44' | 'nip04';
type?: 'encrypt' | 'decrypt';
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks';
bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights';
}) => {
const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null)
const isLive = !!liveValue
@@ -596,7 +856,8 @@ const Debug: React.FC<DebugProps> = ({
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="load" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
<Stat label="total" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
<Stat label="first event" value={tFirstBookmark} />
<Stat label="decrypt" value={tDecryptBookmarks} bookmarkOp="decryptBookmarks" />
</div>
@@ -647,6 +908,204 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Highlight Loading Section */}
<div className="settings-section">
<h3 className="section-title">Highlight Loading</h3>
<div className="text-sm opacity-70 mb-3">Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.</div>
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Query Mode:</div>
<div className="flex gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'article'}
onChange={() => setHighlightMode('article')}
/>
<span>Article (#a)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'url'}
onChange={() => setHighlightMode('url')}
/>
<span>URL (#r)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
checked={highlightMode === 'author'}
onChange={() => setHighlightMode('author')}
/>
<span>Author</span>
</label>
</div>
</div>
<div className="mb-3">
{highlightMode === 'article' && (
<input
type="text"
className="input w-full"
placeholder="30023:pubkey:identifier"
value={highlightArticleCoord}
onChange={(e) => setHighlightArticleCoord(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
{highlightMode === 'url' && (
<input
type="text"
className="input w-full"
placeholder="https://example.com/article"
value={highlightUrl}
onChange={(e) => setHighlightUrl(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
{highlightMode === 'author' && (
<input
type="text"
className="input w-full"
placeholder={pubkey ? `${pubkey.slice(0, 16)}... (logged-in user)` : 'pubkey (hex)'}
value={highlightAuthor}
onChange={(e) => setHighlightAuthor(e.target.value)}
disabled={isLoadingHighlights}
/>
)}
</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"
onClick={handleLoadHighlights}
disabled={isLoadingHighlights || !relayPool}
>
{isLoadingHighlights ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Highlights'
)}
</button>
<button
className="btn btn-secondary ml-auto"
onClick={handleClearHighlights}
disabled={highlightEvents.length === 0}
>
Clear
</button>
</div>
<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={handleLoadMyHighlights}
disabled={isLoadingHighlights || !relayPool || !activeAccount}
>
Load My Highlights
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadFriendsHighlights}
disabled={isLoadingHighlights || !relayPool || !activeAccount}
>
Load Friends Highlights
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadNostrverseHighlights}
disabled={isLoadingHighlights || !relayPool}
>
Load Nostrverse Highlights
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadHighlights} bookmarkOp="loadHighlights" />
<Stat label="first event" value={tFirstHighlight} />
</div>
{highlightEvents.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Highlights ({highlightEvents.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{highlightEvents.map((evt, idx) => {
const content = evt.content || ''
const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content
const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1]
const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1]
const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1]
const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[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">Highlight #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
</div>
<div className="mt-1">
<div className="font-semibold text-[11px]">Content:</div>
<div className="italic">&quot;{shortContent}&quot;</div>
</div>
{contextTag && (
<div className="mt-1 text-[11px] opacity-70">
<div>Context: {contextTag.substring(0, 60)}...</div>
</div>
)}
{aTag && <div className="mt-1 text-[11px] opacity-70">#a: {aTag}</div>}
{rTag && <div className="mt-1 text-[11px] opacity-70">#r: {rTag}</div>}
{eTag && <div className="mt-1 text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>
<div className="text-sm opacity-70 mb-3">Load your followed contacts (friends) for highlight fetching:</div>
<div className="mb-3">
<button
className="btn btn-primary"
onClick={handleLoadFriendsList}
disabled={friendsButtonLoading || !relayPool || !activeAccount}
>
{friendsButtonLoading ? (
<>
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
Loading...
</>
) : (
'Load Friends'
)}
</button>
</div>
{friendsPubkeys.size > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Friends Count: {friendsNpubs.length}</div>
<div className="font-mono text-xs max-h-48 overflow-y-auto bg-gray-100 dark:bg-gray-800 p-3 rounded space-y-1">
{friendsNpubs.map(npub => (
<div key={npub} title={npub} className="truncate hover:text-clip hover:whitespace-normal cursor-pointer">
{npub}
</div>
))}
</div>
</div>
)}
</div>
{/* Debug Logs Section */}
<div className="settings-section">
<h3 className="section-title">Debug Logs</h3>

View File

@@ -1,18 +1,19 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { nip19 } from 'nostr-tools'
import { IEventStore, Helpers } from 'applesauce-core'
import { nip19, NostrEvent } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
@@ -22,6 +23,12 @@ import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface ExploreProps {
relayPool: RelayPool
@@ -42,13 +49,41 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings, or friends only)
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Load cached content from event store (instant display)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// Visibility filters (defaults from settings)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
friends: settings?.defaultHighlightVisibilityFriends ?? true,
mine: settings?.defaultHighlightVisibilityMine ?? false
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
// Subscribe to highlights controller
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -68,14 +103,34 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
setLoading(true)
// Seed from in-memory cache if available to avoid empty flash
// Use functional update to check current state without creating dependency
const cachedPosts = getCachedPosts(activeAccount.pubkey)
if (cachedPosts && cachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (cachedHighlights && cachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
}
// Seed with cached content from event store (instant display)
if (cachedHighlights.length > 0 || myHighlights.length > 0) {
const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights])
setHighlights(prev => {
const all = dedupeHighlightsById([...prev, ...merged])
return all.sort((a, b) => b.created_at - a.created_at)
})
}
// Seed with cached writings from event store
if (cachedWritings.length > 0) {
setBlogPosts(prev => {
const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings])
return all.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
// Fetch the user's contacts (friends)
@@ -97,8 +152,31 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
relayUrls,
(post) => {
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
// Deduplicate by author:d-tag (replaceable event key)
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 exists, only replace if this one is newer
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) {
return prev // Keep existing (newer or same)
}
// Replace with newer version
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
// New post, add it
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
@@ -110,9 +188,27 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
).then((all) => {
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
// Deduplicate by author:d-tag (replaceable event key)
const byKey = new Map<string, BlogPostPreview>()
// Add existing posts
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)
}
// Merge in new posts (keeping newer versions)
for (const post of all) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
const merged = Array.from(byKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
@@ -160,33 +256,21 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
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
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// 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)
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
@@ -211,7 +295,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -340,7 +424,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = loading && !hasData
const showSkeletons = (loading || myHighlightsLoading) && !hasData
return (
<div className="explore-container">
@@ -422,7 +506,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
{renderTabContent()}
<div key={activeTab}>
{renderTabContent()}
</div>
</div>
)
}

View File

@@ -1,14 +1,16 @@
import React, { useState, useEffect } from 'react'
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 { Hooks } from 'applesauce-react'
import { IEventStore, Helpers } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { nip19, NostrEvent } from 'nostr-tools'
import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
import { highlightsController } from '../services/highlightsController'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
@@ -31,9 +33,15 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
interface MeProps {
relayPool: RelayPool
eventStore: IEventStore
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
bookmarks: Bookmark[] // From centralized App.tsx state
@@ -47,6 +55,7 @@ const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started'
const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
pubkey: propPubkey,
bookmarks
@@ -67,6 +76,34 @@ const Me: React.FC<MeProps> = ({
const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Load cached data from event store for OTHER profiles (not own)
const cachedHighlights = useStoreTimeline(
eventStore,
!isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 },
eventToHighlight,
[viewingPubkey, isOwnProfile]
)
const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}), [])
const cachedWritings = useStoreTimeline(
eventStore,
!isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 },
toBlogPostPreview,
[viewingPubkey, isOwnProfile]
)
const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
@@ -87,6 +124,20 @@ const Me: React.FC<MeProps> = ({
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// Subscribe to highlights controller
useEffect(() => {
// Get initial state immediately
setMyHighlights(highlightsController.getHighlights())
// Subscribe to updates
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -123,8 +174,20 @@ const Me: React.FC<MeProps> = ({
try {
if (!hasBeenLoaded) setLoading(true)
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
// 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)
}
setLoadedTabs(prev => new Set(prev).add('highlights'))
} catch (err) {
console.error('Failed to load highlights:', err)
@@ -140,6 +203,17 @@ const Me: React.FC<MeProps> = ({
try {
if (!hasBeenLoaded) setLoading(true)
// Seed with cached writings first
if (!isOwnProfile && cachedWritings.length > 0) {
setWritings(cachedWritings.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
}))
}
// Fetch fresh writings
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings'))
@@ -294,6 +368,12 @@ const Me: React.FC<MeProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
// Sync myHighlights from controller when viewing own profile
useEffect(() => {
if (isOwnProfile) {
setHighlights(myHighlights)
}
}, [isOwnProfile, myHighlights])
// Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -414,7 +494,7 @@ const Me: React.FC<MeProps> = ({
// Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData
const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData
const renderTabContent = () => {
switch (activeTab) {
@@ -428,7 +508,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
}
return highlights.length === 0 && !loading ? (
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>

View File

@@ -6,6 +6,7 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import ExploreSettings from './Settings/ExploreSettings'
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import ZapSettings from './Settings/ZapSettings'
import RelaySettings from './Settings/RelaySettings'
@@ -29,6 +30,9 @@ const DEFAULT_SETTINGS: UserSettings = {
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,
defaultExploreScopeNostrverse: false,
defaultExploreScopeFriends: true,
defaultExploreScopeMine: false,
zapSplitHighlighterWeight: 50,
zapSplitBorisWeight: 2.1,
zapSplitAuthorWeight: 50,
@@ -163,6 +167,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />

View File

@@ -0,0 +1,59 @@
import React from 'react'
import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton'
interface ExploreSettingsProps {
settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void
}
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
return (
<div className="settings-section">
<h3 className="section-title">Explore</h3>
<div className="setting-group setting-inline">
<label>Default Explore Scope</label>
<div className="highlight-level-toggles">
<IconButton
icon={faNetworkWired}
onClick={() => onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })}
title="Nostrverse content"
ariaLabel="Toggle nostrverse content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })}
title="Friends content"
ariaLabel="Toggle friends content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })}
title="My content"
ariaLabel="Toggle my content by default in explore"
variant="ghost"
style={{
color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4
}}
/>
</div>
</div>
</div>
)
}
export default ExploreSettings

View File

@@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
/>
</div>
</div>
{props.hasActiveAccount && (
{props.hasActiveAccount && props.readerContent && (
<HighlightButton
ref={props.highlightButtonRef}
onHighlight={props.onCreateHighlight}

View File

@@ -1,11 +1,16 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { IEventStore } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import { fetchContacts } from '../services/contactService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { UserSettings } from '../services/settingsService'
import { highlightsController } from '../services/highlightsController'
import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -15,6 +20,7 @@ interface UseBookmarksDataParams {
currentArticleCoordinate?: string
currentArticleEventId?: string
settings?: UserSettings
eventStore?: IEventStore | null
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
onRefreshBookmarks: () => Promise<void>
@@ -28,19 +34,50 @@ export const useBookmarksData = ({
currentArticleCoordinate,
currentArticleEventId,
settings,
eventStore,
onRefreshBookmarks
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
const [highlights, setHighlights] = useState<Highlight[]>([])
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
const handleFetchContacts = useCallback(async () => {
if (!relayPool || !activeAccount) return
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
setFollowedPubkeys(contacts)
}, [relayPool, activeAccount])
// Load cached article-specific highlights from event store
const articleFilter = useMemo(() => {
if (!currentArticleCoordinate) return null
return {
kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
}
}, [currentArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[currentArticleCoordinate, currentArticleEventId]
)
// Subscribe to centralized controllers
useEffect(() => {
// Get initial state immediately
setMyHighlights(highlightsController.getHighlights())
setFollowedPubkeys(new Set(contactsController.getContacts()))
// Subscribe to updates
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubContacts = contactsController.onContacts((contacts) => {
setFollowedPubkeys(new Set(contacts))
})
return () => {
unsubHighlights()
unsubContacts()
}
}, [])
const handleFetchHighlights = useCallback(async () => {
if (!relayPool) return
@@ -48,7 +85,16 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
// Seed with cached highlights first
if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh article-specific highlights (from all users)
const highlightsMap = new Map<string, Highlight>()
// Seed map with cached highlights
cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h))
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
@@ -58,22 +104,23 @@ export const useBookmarksData = ({
if (!highlightsMap.has(highlight.id)) {
highlightsMap.set(highlight.id, highlight)
const highlightsList = Array.from(highlightsMap.values())
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
}
},
settings
settings,
false, // force
eventStore || undefined
)
console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`)
} else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings)
setHighlights(fetchedHighlights)
} else {
// No article selected - clear article highlights
setArticleHighlights([])
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings])
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -82,29 +129,37 @@ export const useBookmarksData = ({
try {
await onRefreshBookmarks()
await handleFetchHighlights()
await handleFetchContacts()
// Contacts and own highlights are managed by controllers
setLastFetchTime(Date.now())
} catch (err) {
console.error('Failed to refresh data:', err)
} finally {
setIsRefreshing(false)
}
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights])
// Fetch highlights/contacts independently
// Fetch article-specific highlights when viewing an article
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only fetch general highlights when not viewing an article (naddr) or external URL
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (!naddr && !externalUrl) {
if (currentArticleCoordinate && !externalUrl) {
handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, currentArticleCoordinate, 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)
return {
highlights,
setHighlights,
setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader)
highlightsLoading,
setHighlightsLoading,
followedPubkeys,

View File

@@ -1,8 +1,12 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { fetchHighlightsForUrl } from '../services/highlightService'
import { Highlight } from '../types/highlights'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
// Helper to extract filename from URL
function getFilenameFromUrl(url: string): string {
@@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string {
interface UseExternalUrlLoaderProps {
url: string | undefined
relayPool: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
@@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps {
export function useExternalUrlLoader({
url,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
@@ -42,6 +48,19 @@ export function useExternalUrlLoader({
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseExternalUrlLoaderProps) {
// Load cached URL-specific highlights from event store
const urlFilter = useMemo(() => {
if (!url) return null
return { kinds: [KINDS.Highlights], '#r': [url] }
}, [url])
const cachedUrlHighlights = useStoreTimeline(
eventStore || null,
urlFilter || { kinds: [KINDS.Highlights], limit: 0 },
eventToHighlight,
[url]
)
useEffect(() => {
if (!relayPool || !url) return
@@ -66,11 +85,20 @@ export function useExternalUrlLoader({
// Fetch highlights for this URL asynchronously
try {
setHighlightsLoading(true)
setHighlights([])
// Seed with cached highlights first
if (cachedUrlHighlights.length > 0) {
setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at))
} else {
setHighlights([])
}
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>()
// Seed with cached IDs
cachedUrlHighlights.forEach(h => seen.add(h.id))
await fetchHighlightsForUrl(
relayPool,
url,
@@ -82,13 +110,11 @@ export function useExternalUrlLoader({
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
}
},
undefined, // settings
false, // force
eventStore || undefined
)
// Highlights are already set via the streaming callback
// No need to set them again as that could cause a flash/disappearance
console.log(`📌 Finished fetching highlights for URL`)
} else {
console.log('📌 Highlight fetching for URLs not yet implemented')
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
@@ -109,6 +135,6 @@ export function useExternalUrlLoader({
}
loadExternalUrl()
}, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights])
}

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useObservableMemo } from 'applesauce-react/hooks'
import { startWith } from 'rxjs'
import type { IEventStore } from 'applesauce-core'
import type { Filter, NostrEvent } from 'nostr-tools'
/**
* Subscribe to EventStore timeline and map events to app types
* Provides instant cached results, then updates reactively
*
* @param eventStore - The applesauce event store
* @param filter - Nostr filter to query
* @param mapEvent - Function to transform NostrEvent to app type
* @param deps - Dependencies for memoization
* @returns Array of mapped results
*/
export function useStoreTimeline<T>(
eventStore: IEventStore | null,
filter: Filter,
mapEvent: (event: NostrEvent) => T,
deps: unknown[] = []
): T[] {
const events = useObservableMemo(
() => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined,
[eventStore, ...deps]
)
return useMemo(
() => events?.map(mapEvent) ?? [],
[events, mapEvent]
)
}

View File

@@ -0,0 +1,114 @@
import { RelayPool } from 'applesauce-relay'
import { fetchContacts } from './contactService'
type ContactsCallback = (contacts: Set<string>) => void
type LoadingCallback = (loading: boolean) => void
/**
* Shared contacts/friends controller
* Manages the user's follow list centrally, similar to bookmarkController
*/
class ContactsController {
private contactsListeners: ContactsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentContacts: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
onContacts(cb: ContactsCallback): () => void {
this.contactsListeners.push(cb)
return () => {
this.contactsListeners = this.contactsListeners.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 emitContacts(contacts: Set<string>): void {
this.contactsListeners.forEach(cb => cb(contacts))
}
/**
* Get current contacts without triggering a reload
*/
getContacts(): Set<string> {
return new Set(this.currentContacts)
}
/**
* Check if contacts are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.currentContacts.clear()
this.lastLoadedPubkey = null
this.emitContacts(this.currentContacts)
}
/**
* Load contacts for a user
* Streams partial results and caches the final list
*/
async start(options: {
relayPool: RelayPool
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitContacts(this.currentContacts)
return
}
this.setLoading(true)
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
try {
const contacts = await fetchContacts(
relayPool,
pubkey,
(partial) => {
// Stream partial updates
this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts)
console.log('[contacts] 📥 Partial contacts:', partial.size)
}
)
// Store final result
this.currentContacts = new Set(contacts)
this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts)
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
} catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear()
this.emitContacts(this.currentContacts)
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const contactsController = new ContactsController()

View File

@@ -0,0 +1,96 @@
import { Highlight } from '../../types/highlights'
interface CacheEntry {
highlights: Highlight[]
timestamp: number
}
/**
* Simple in-memory session cache for highlight queries with TTL
*/
class HighlightCache {
private cache = new Map<string, CacheEntry>()
private ttlMs = 60000 // 60 seconds
/**
* Generate cache key for article coordinate
*/
articleKey(coordinate: string): string {
return `article:${coordinate}`
}
/**
* Generate cache key for URL
*/
urlKey(url: string): string {
// Normalize URL for consistent caching
try {
const normalized = new URL(url)
normalized.hash = '' // Remove hash
return `url:${normalized.toString()}`
} catch {
return `url:${url}`
}
}
/**
* Generate cache key for author pubkey
*/
authorKey(pubkey: string): string {
return `author:${pubkey}`
}
/**
* Get cached highlights if not expired
*/
get(key: string): Highlight[] | null {
const entry = this.cache.get(key)
if (!entry) return null
const now = Date.now()
if (now - entry.timestamp > this.ttlMs) {
this.cache.delete(key)
return null
}
return entry.highlights
}
/**
* Store highlights in cache
*/
set(key: string, highlights: Highlight[]): void {
this.cache.set(key, {
highlights,
timestamp: Date.now()
})
}
/**
* Clear specific cache entry
*/
clear(key: string): void {
this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clearAll(): void {
this.cache.clear()
}
/**
* Get cache stats
*/
stats(): { size: number; keys: string[] } {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
// Singleton instance
export const highlightCache = new HighlightCache()

View File

@@ -1,61 +1,77 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { KINDS } from '../../config/kinds'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.authorKey(pubkey)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for author (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const ordered = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const rawEvents: NostrEvent[] = await queryEvents(
relayPool,
{ kinds: [KINDS.Highlights], authors: [pubkey] },
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
)
console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8))
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.authorKey(pubkey)
highlightCache.set(cacheKey, sorted)
return sorted
} catch {
return []
}

View File

@@ -1,95 +1,81 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { KINDS } from '../../config/kinds'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.articleKey(articleCoordinate)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for article (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
const onEvent = (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
return eventToHighlight(event)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
const aLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
let eTagEvents: NostrEvent[] = []
if (eventId) {
const eLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const eRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
}
// Query for both #a and #e tags in parallel
const [aTagEvents, eTagEvents] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }),
eventId
? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent })
: Promise.resolve([] as NostrEvent[])
])
const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings)
console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.articleKey(articleCoordinate)
highlightCache.set(cacheKey, sorted)
return sorted
} catch {
return []
}

View File

@@ -1,68 +1,80 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { KINDS } from '../../config/kinds'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
import { queryEvents } from '../dataFetch'
import { highlightCache } from './cache'
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
settings?: UserSettings,
force = false,
eventStore?: IEventStore
): Promise<Highlight[]> => {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
// Check cache first unless force refresh
if (!force) {
const cacheKey = highlightCache.urlKey(url)
const cached = highlightCache.get(cacheKey)
if (cached) {
console.log(`📌 Using cached highlights for URL (${cached.length} items)`)
// Stream cached highlights if callback provided
if (onHighlight) {
cached.forEach(h => onHighlight(h))
}
return cached
}
}
try {
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelaysUrl.length > 0
? relayPool
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const seenIds = new Set<string>()
const rawEvents: NostrEvent[] = await queryEvents(
relayPool,
{ kinds: [KINDS.Highlights], '#r': [url] },
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
)
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
// Rebroadcast events - but don't let errors here break the highlight display
try {
await rebroadcastEvents(rawEvents, relayPool, settings)
} catch (err) {
console.warn('Failed to rebroadcast highlight events:', err)
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
const sorted = sortHighlights(highlights)
// Cache the results
const cacheKey = highlightCache.urlKey(url)
highlightCache.set(cacheKey, sorted)
return sorted
} catch (err) {
console.error('Error fetching highlights for URL:', err)
// Return highlights that were already streamed via callback
// Don't return empty array as that would clear already-displayed highlights
return []
}
}

View File

@@ -1,5 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch'
@@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch'
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @param eventStore - Optional event store to persist events
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
@@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async (
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
}
)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)

View File

@@ -0,0 +1,208 @@
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 = 'highlights_last_synced'
/**
* Shared highlights controller
* Manages the user's highlights centrally, similar to bookmarkController
*/
class HighlightsController {
private highlightsListeners: HighlightsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentHighlights: Highlight[] = []
private lastLoadedPubkey: string | null = null
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))
}
/**
* Get current highlights without triggering a reload
*/
getHighlights(): Highlight[] {
return [...this.currentHighlights]
}
/**
* Check if highlights are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
this.currentHighlights = []
this.lastLoadedPubkey = null
this.emitHighlights(this.currentHighlights)
}
/**
* 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('[highlights] Failed to save last synced timestamp:', err)
}
}
/**
* Load highlights for a user
* 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('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitHighlights(this.currentHighlights)
return
}
// Increment generation to cancel any in-flight work
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
kinds: [KINDS.Highlights],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
console.log('[highlights] 📅 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)
// Convert to highlight and add to map
const highlight = eventToHighlight(evt)
highlightsMap.set(highlight.id, highlight)
// Stream to listeners
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sortedHighlights
this.emitHighlights(sortedHighlights)
}
}
)
// Check if still active after async operation
if (currentGeneration !== this.generation) {
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
return
}
// Store all events in event store
events.forEach(evt => eventStore.add(evt))
// Final processing
const highlights = events.map(eventToHighlight)
const uniqueHighlights = Array.from(
new Map(highlights.map(h => [h.id, h])).values()
)
const sorted = sortHighlights(uniqueHighlights)
this.currentHighlights = sorted
this.lastLoadedPubkey = pubkey
this.emitHighlights(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []
this.emitHighlights(this.currentHighlights)
} finally {
// Only clear loading if this generation is still active
if (currentGeneration === this.generation) {
this.setLoading(false)
}
}
}
}
// Singleton instance
export const highlightsController = new HighlightsController()

View File

@@ -1,6 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Helpers, IEventStore } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
@@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
limit = 50,
eventStore?: IEventStore
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
@@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async (
{
relayUrls,
onEvent: (event: NostrEvent) => {
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
@@ -42,7 +49,7 @@ export const fetchNostrverseBlogPosts = async (
}
)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
@@ -60,7 +67,7 @@ export const fetchNostrverseBlogPosts = async (
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts')
return blogPosts
} catch (error) {
@@ -73,25 +80,43 @@ export const fetchNostrverseBlogPosts = async (
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @param eventStore - Optional event store to persist fetched events
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
limit = 100,
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
const seenIds = new Set<string>()
const rawEvents = await queryEvents(
relayPool,
{ kinds: [9802], limit },
{}
{
onEvent: (event: NostrEvent) => {
if (seenIds.has(event.id)) return
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
}
}
)
// Store all events in event store if provided (in case some were missed in streaming)
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights')
return sortHighlights(highlights)
} catch (error) {

View File

@@ -36,6 +36,10 @@ export interface UserSettings {
defaultHighlightVisibilityNostrverse?: boolean
defaultHighlightVisibilityFriends?: boolean
defaultHighlightVisibilityMine?: boolean
// Default explore scope
defaultExploreScopeNostrverse?: boolean
defaultExploreScopeFriends?: boolean
defaultExploreScopeMine?: boolean
// Zap split weights (treated as relative weights, not strict percentages)
zapSplitHighlighterWeight?: number // default 50
zapSplitBorisWeight?: number // default 2.1

View File

@@ -73,7 +73,7 @@
.highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ }
/* Three-level highlight toggles */
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; border-radius: 4px; }
.highlights-loading,
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; }

35
src/utils/dedupe.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Highlight } from '../types/highlights'
import { BlogPostPreview } from '../services/exploreService'
/**
* Deduplicate highlights by ID
*/
export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] {
const byId = new Map<string, Highlight>()
for (const highlight of highlights) {
byId.set(highlight.id, highlight)
}
return Array.from(byId.values())
}
/**
* Deduplicate blog posts by replaceable event key (author:d-tag)
* Keeps the newest version when duplicates exist
*/
export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] {
const byKey = new Map<string, BlogPostPreview>()
for (const post of posts) {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existing = byKey.get(key)
// Keep the newer version
if (!existing || post.event.created_at > existing.event.created_at) {
byKey.set(key, post)
}
}
return Array.from(byKey.values())
}