Compare commits

...

84 Commits

Author SHA1 Message Date
Gigi
1c21615103 chore: bump version to 0.8.1 2025-10-19 22:31:06 +02:00
Gigi
732070e89b fix: re-derive reads/links when bookmarks change
- Add bookmarks to useEffect dependencies that load tab data
- Reads tab now updates when bookmarks are loaded/updated
- Fixes 'No articles ready yet' disappearing when switching tabs
- Ensures reads are always derived from current bookmark state
- Re-renders Reads tab whenever bookmarks change
2025-10-19 22:29:02 +02:00
Gigi
d9a00dd157 fix: merge reading progress from controller into reads/links before filtering
- Enrich reads and links arrays with reading progress from readingProgressMap
- Use item.id to lookup progress for articles
- Use item.url to lookup progress for links
- Now 'started' and 'reading' filters show correct articles
- Filters respond in real-time as reading progress updates from controller
2025-10-19 22:25:55 +02:00
Gigi
103be75f6e feat: auto-load reading progress on login and app start
- Add readingProgressController.start() to App.tsx
- Follows same pattern as highlightsController and writingsController
- Checks isLoadedFor(pubkey) to prevent duplicate loading
- Automatically fetches reading progress when user logs in
- Loads progress from cache first, then streams from relays
- Reading progress now available immediately for filters and indicators
2025-10-19 22:23:03 +02:00
Gigi
8dd4e358b4 fix: normalize highlight article references to naddr format for proper matching
- Convert coordinate-format eventReferences (30023:pubkey:identifier) to naddr
- ReadItems use naddr format for IDs, but highlights store coordinates
- Properly match highlights to articles by normalizing both formats
- Fixes 'highlighted' filter showing no results
- Handles conversion errors gracefully by falling back to original format
2025-10-19 22:21:43 +02:00
Gigi
2e8dfaee09 refactor: reorder reading progress filters - highlighted before completed
- Move highlighted filter before completed in button order
- Reading filters now appear in logical order:
  All → Unopened → Started → Reading → Highlighted → Completed
2025-10-19 22:20:31 +02:00
Gigi
db3084b373 fix: use ES6 import instead of require in helpers.ts
- Replace require() call with ES6 import for READING_PROGRESS constant
- Fixes linter error: 'require' is not defined (no-undef)
- All linter checks now pass with no warnings or errors
2025-10-19 22:19:06 +02:00
Gigi
83e4a2ad4c refactor: rename Amethyst bookmark sections to simpler names
- Rename 'Amethyst Lists' to 'My Lists'
- Rename 'Amethyst Private' to 'Private Lists'
- Clearer and more intuitive names without referencing the Amethyst client
- Applied in both Me.tsx and BookmarkList.tsx

These sections contain kind:30001 bookmarks (replaceable list events).
2025-10-19 22:18:16 +02:00
Gigi
c1d23fac7b feat: show reading progress in compact and card view bookmarks
- Add readingProgress prop to BookmarkItem component
- Display reading progress in CompactView with 2px indicator
- Display reading progress in CardView with 3px indicator
- Progress color matches main app: blue (reading), green (completed), neutral (started)
- Add getBookmarkReadingProgress helper in Me.tsx
- Show progress only for kind:30023 articles with progress > 0
- Reading progress now visible across all bookmark view modes
2025-10-19 22:17:35 +02:00
Gigi
de32310801 feat: add highlights filter button to reading progress filters
- Add 'highlighted' filter type to ReadingProgressFilterType
- New filter button with yellow highlighter icon
- Filter shows only articles that have highlights
- Highlights filter checks both eventReference and urlReference tags
- Color-coded: green for completed, yellow for highlighted, blue for others
- Applies to reads and links tabs in /me page
2025-10-19 22:15:13 +02:00
Gigi
5c82dff8df feat: only track reading progress for articles above minimum length
- Add MIN_CONTENT_LENGTH constant (1000 chars ≈ 150 words) to config/kinds
- Create shouldTrackReadingProgress helper to validate content length
- Strip HTML tags when calculating character count
- Only save reading progress for articles meeting the threshold
- Log when content is too short to track

This prevents noisy tracking of very short articles or excerpts.
2025-10-19 22:13:37 +02:00
Gigi
abe2d6528a feat: add setting to hide bookmarks missing creation date
- Add hideBookmarksWithoutCreationDate to UserSettings
- New checkbox in Layout & Behavior settings
- Bookmarks without valid creation dates shown as 'Now'
- Setting disabled by default to maintain current behavior
2025-10-19 22:11:47 +02:00
Gigi
8b56fe3d6e ux: update Flight Mode notification text to say 'Local relays only' 2025-10-19 22:10:32 +02:00
Gigi
bdce7c9358 docs: update CHANGELOG.md for v0.8.0 release 2025-10-19 22:09:56 +02:00
Gigi
81a4ae392f bump: release version 0.8.0 2025-10-19 22:09:13 +02:00
Gigi
6e438b8ee2 Merge pull request #21 from dergigi/reading-progress-nip
feat: implement NIP-85 reading progress tracking
2025-10-19 22:08:04 +02:00
Gigi
31974e7271 feat(reading): 2s completion hold at 100% + reliable auto mark-as-read
- Add completionHoldMs (default 2000ms) to useReadingPosition
- Start hold timer when position hits 100%; cancel if user scrolls up
- Fallback to threshold completion when configured
- Clears timers on unmount/disable
2025-10-19 16:17:17 +02:00
Gigi
676be1a932 feat: make reading position sync default-on in runtime paths
- Treat undefined as enabled in ContentPanel (only false disables)
- Keeps DEFAULT_SETTINGS at true; ensures consistent behavior even for users without the new setting persisted yet
2025-10-19 16:15:43 +02:00
Gigi
9883f2eb1a chore(settings): tweak label for auto mark-as-read (remove animation note) 2025-10-19 16:13:56 +02:00
Gigi
87e46be86f feat(settings): restore 'auto mark as read at 100%' option
- Added autoMarkAsReadOnCompletion to default settings (disabled by default)
- Added toggle in Layout & Behavior section
- Existing ContentPanel logic already hooks into this to trigger animation & mark-as-read
2025-10-19 16:07:59 +02:00
Gigi
b745a92a7e feat: allow saving 0% reading position and initial save
- Remove low-position guard; allow 0% saves
- One-time initial save even without significant change
- Always allow immediate save regardless of position
- Fix linter empty-catch warnings in readingProgressController
2025-10-19 16:03:34 +02:00
Gigi
5a79da4024 feat: persist reading progress in localStorage per pubkey
- Seed controller state from cache on start for instant display after refresh
- Persist updated progress map after processing events
- Keeps progress visible even without immediate relay responses
2025-10-19 15:59:01 +02:00
Gigi
a7d05a29f5 feat: process local reading progress via eventStore.timeline()
- Subscribe to timeline for immediate local events and reactive updates
- Clean up timeline subscription on reset/start to avoid leaks
- Keep relay sync for background augmentation
- Should populate progress map even without relay roundtrip
2025-10-19 12:29:44 +02:00
Gigi
0740d53d37 fix: resolve all linter warnings
- Add proper types (Filter, NostrEvent) to readingProgressController
- Add eslint-disable comment for position dependency in useReadingPosition
  (position is derived from scroll and including it would cause infinite re-renders)
- All lint warnings resolved
- TypeScript type checks pass
2025-10-19 12:27:19 +02:00
Gigi
914738abb4 fix: force full sync when map is empty
- If currentProgressMap is empty, do a full sync (no 'since' filter)
- This ensures first load gets all events, not just recent ones
- Incremental sync only happens when we already have data
- This was the bug: lastSynced was preventing initial load of events
2025-10-19 12:18:46 +02:00
Gigi
4fac5f42c9 fix: remove broken timeline subscription, rely on queryEvents
- Timeline subscription is async and emits empty array first
- queryEvents already checks local store then relays
- Simpler and actually works correctly
- This is how all other controllers work (highlights, bookmarks, etc.)
2025-10-19 12:17:38 +02:00
Gigi
16b3668e73 debug: add logs to trace why events aren't processed
- Log sample event to see format
- Log map size after processEvents to see if it worked
- This will show if processEvents is failing silently
2025-10-19 12:13:45 +02:00
Gigi
f3a83256a8 debug: improve timeline subscription and add more logs
- Capture events from timeline before unsubscribing
- Add log to show when timeline emits
- Add log after unsubscribe to show what we got
- This will help debug why processEvents isn't being called
2025-10-19 12:10:53 +02:00
Gigi
0e98ddeef4 fix: use eventStore.timeline() to query local events
- Subscribe to timeline to get initial cached events
- Unsubscribe immediately after reading initial value
- This works with IEventStore interface correctly
2025-10-19 12:04:49 +02:00
Gigi
1ba375e93e fix: load reading progress from event store first (non-blocking)
- Query local event store immediately for instant display
- Then augment with relay data in background
- This matches how bookmarks work: local-first, then sync
- Events saved locally now appear immediately without waiting for relay propagation
2025-10-19 12:03:36 +02:00
Gigi
5d14d25d0e debug: add detailed logging to Profile component
- Show initial map size and updates in Profile
- Log lookups with map contents in Profile
- Helps debug reading progress on profile pages
2025-10-19 12:02:22 +02:00
Gigi
616038a23a debug: reduce log spam and show map size in lookups
- Only log when progress found or map is empty
- Show map size to quickly diagnose empty map issue
- Show first 3 map keys as sample instead of all
2025-10-19 11:59:37 +02:00
Gigi
14fce2c3dc debug: add detailed naddr comparison logs
- Show all map keys when looking up reading progress
- Show d-tag generation from naddr in save flow
- This will help identify if naddr encoding/decoding is causing mismatch
2025-10-19 11:56:27 +02:00
Gigi
7c511de474 feat: enable reading position sync by default
- Changed syncReadingPosition default from false to true in Settings.tsx
- Users can still disable it in settings if they prefer
- This ensures reading progress tracking works out of the box
2025-10-19 11:52:05 +02:00
Gigi
3a10ac8691 debug: add logs to show why reading position saves are skipped
- Log when scheduleSave returns early (syncEnabled false, no onSave callback)
- Log when position is too low (<5%)
- Log when change is not significant enough (<1%)
- Log ContentPanel sync status (enabled, settings, requirements)
- This will help diagnose why no events are being created
2025-10-19 11:41:38 +02:00
Gigi
205879f948 debug: add comprehensive logging for reading position calculation and event publishing
- Add logs in useReadingPosition: scroll position calculation (throttled to 5% changes)
- Add logs for scheduling and triggering auto-save
- Add detailed logs in ContentPanel handleSavePosition
- Add logs in saveReadingPosition: event creation, signing, publishing
- Add logs in publishEvent: event store addition, relay status, publishing
- All logs prefixed with [progress] for easy filtering
- Shows complete flow from scroll → calculate → save → create event → publish to relays
2025-10-19 11:39:25 +02:00
Gigi
bff43f4a28 debug: add comprehensive [progress] logging throughout reading progress flow
- Add logs in readingProgressController: processing events, emitting to listeners
- Add logs in Explore component: receiving updates, looking up progress
- Add logs in BlogPostCard: rendering with progress
- Add detailed logs in processReadingProgress: event parsing, naddr conversion
- All logs prefixed with [progress] for easy filtering
2025-10-19 11:30:57 +02:00
Gigi
2a7fffd594 fix: remove invalid eventStore.list() call in reading progress controller
- EventStore doesn't have a list() method
- Follow same pattern as highlightsController and just fetch from relays
- Fixes TypeError: eventStore.list is not a function
2025-10-19 11:18:21 +02:00
Gigi
50a4161e16 feat: reset reading progress controller on logout
- Add readingProgressController.reset() to handleLogout in App.tsx
- Ensures reading progress data is cleared when user logs out
- Consistent with other controllers (bookmarks, contacts, highlights)
2025-10-19 11:08:33 +02:00
Gigi
5fd8976097 refactor: create centralized reading progress controller
- Add readingProgressController following the same pattern as highlightsController and writingsController
- Controller manages reading progress (kind:39802) centrally with subscriptions
- Remove duplicated reading progress loading logic from Explore, Profile, and Me components
- Components now subscribe to controller updates instead of loading data individually
- Supports incremental sync and force reload
- Improves efficiency and maintainability
2025-10-19 11:06:57 +02:00
Gigi
80b26abff2 feat: add reading progress indicators to blog post cards
- Add reading progress loading and display in Explore component
- Add reading progress loading and display in Profile component
- Add reading progress loading and display in Me writings tab
- Reading progress now shows as colored progress bar in all blog post cards
- Progress colors: gray (started 0-10%), blue (reading 10-95%), green (completed 95%+)
2025-10-19 11:02:20 +02:00
Gigi
c0638851c6 docs: simplify NIP-85 to match NIP-84 style and length
- Remove verbose rationale section
- Remove excessive querying examples
- Remove privacy considerations (obvious)
- Remove implementation notes fluff
- Remove references section
- Keep only essential: format, tags, content, examples
- Match NIP-84's concise, to-the-point style

From 190 lines down to ~75 lines - much more readable
2025-10-19 10:54:31 +02:00
Gigi
9b6b14cfe8 refactor: remove client tag from reading progress events
- Remove 'client' tag from NIP-85 specification
- Remove 'client' tag from code implementation
- Align with Nostr principles of client-agnostic data
- Follow NIP-84 pattern which doesn't include client tags

Events should be client-agnostic and not include branding/tracking.
2025-10-19 10:46:44 +02:00
Gigi
b6ad62a3ab refactor: rename to NIP-85 (kind 39802 for reading progress)
- Rename NIP-39802.md to NIP-85.md
- Update all references from NIP-39802 to NIP-85 in code comments
- Add Table of Contents to NIP document
- Update kinds.ts to reference NIP-85 and NIP-84 (highlights)
- Maintain kind number 39802 for the event type

NIP-85 is the specification number, 39802 is the event kind number.
2025-10-19 10:41:02 +02:00
Gigi
85d87bac29 docs: improve NIP-39802 with URL cleaning guidance from NIP-84
- Add recommendation to clean URLs from tracking parameters
- Add URL Handling subsection with best practices
- Ensure same article from different sources maps to same progress
- Inspired by NIP-84 (Highlights) URL handling guidelines
2025-10-19 10:38:28 +02:00
Gigi
3b31eceeab feat: improve reading progress with validation and auto-mark
- Add autoMarkAsReadOnCompletion setting (opt-in, default: false)
- Implement auto-mark as read when reaching 95%+ completion
- Add validation for progress bounds (0-1) per NIP-39802 spec
- Align completion threshold to 95% to match filter behavior
- Skip invalid progress events with warning log

Improvements ensure consistency between completion detection and
filtering, while adding safety validation per the NIP spec.
2025-10-19 10:34:53 +02:00
Gigi
442c138d6a refactor: simplify NIP-39802 implementation - remove migration complexity
- Remove dual-write logic: only write kind 39802
- Remove legacy kind 30078 read fallback
- Remove migration settings flags (useReadingProgressKind, writeLegacyReadingPosition)
- Simplify readingPositionService: single write/read path
- Remove processReadingPositions() legacy processor
- Update readsService and linksService to only query kind 39802
- Simplify NIP-39802 spec: remove migration section
- Delete READING_PROGRESS_MIGRATION.md (not needed for unreleased app)
- Clean up imports and comments

No backward compatibility needed since app hasn't been released yet.
2025-10-19 10:14:37 +02:00
Gigi
61e6027252 docs: add migration guide and test documentation for NIP-39802
- Create READING_PROGRESS_MIGRATION.md with detailed migration phases
- Document test scenarios inline in readingPositionService and readingDataProcessor
- Outline timeline for dual-write, prefer-new, and deprecation phases
- Add rollback plan and settings API documentation
- Include comparison table of legacy vs new event formats
2025-10-19 10:10:18 +02:00
Gigi
7d373015b4 feat: implement NIP-39802 reading progress with dual-write migration
- Add kind 39802 (ReadingProgress) as dedicated parameterized replaceable event
- Create NIP-39802 specification document in public/md/
- Implement dual-write: publish both kind 39802 and legacy kind 30078
- Implement dual-read: prefer kind 39802, fall back to kind 30078
- Add migration flags to settings (useReadingProgressKind, writeLegacyReadingPosition)
- Update readingPositionService with new d-tag generation and tag helpers
- Add processReadingProgress() for kind 39802 events in readingDataProcessor
- Update readsService and linksService to query and process both kinds
- Use event.created_at as authoritative timestamp per NIP-39802 spec
- ContentPanel respects migration flags from settings
- Maintain backward compatibility during migration phase
2025-10-19 10:09:09 +02:00
Gigi
32b1286079 chore: remove [bookmark] debug logs
- Remove all console.log statements with [bookmark] prefix from App.tsx
- Remove all console.log statements with [bookmark] prefix from bookmarkController.ts
- Replace verbose error logging with simple error messages
- Keep code clean and reduce console clutter
2025-10-19 01:43:25 +02:00
Gigi
17fdd92827 fix(profile): fetch all writings for profile pages by removing limit
- Make limit parameter configurable in fetchBlogPostsFromAuthors
- Default limit is 100 for Explore page (multiple authors)
- Pass null limit for Profile pages to fetch all writings
- Fixes issue where only 1 writing was shown instead of all
2025-10-19 01:35:00 +02:00
Gigi
aa6aeb2723 refactor: split Me into Me and Profile components for simpler /p/ pages
- Create Profile.tsx for viewing other users (highlights + writings only)
- Profile uses useStoreTimeline for instant cache-first display
- Background fetches populate event store non-blocking
- Extract toBlogPostPreview helper for reuse
- Simplify Me.tsx to only handle own profile (/me routes)
- Remove isOwnProfile branching and cached data logic from Me
- Update Bookmarks.tsx to render Profile for /p/ routes
- Keep code DRY and files under 210 lines
2025-10-19 01:28:22 +02:00
Gigi
4b0f275f57 docs: update CHANGELOG.md for v0.7.4 release 2025-10-19 01:21:38 +02:00
Gigi
73e2e060e3 chore: bump version to 0.7.4 2025-10-19 01:19:10 +02:00
Gigi
3007ae83c2 fix(profile): display cached highlights and writings instantly, fetch fresh in background 2025-10-19 01:17:35 +02:00
Gigi
a862eb880e feat(profile): preload all highlights and writings into event store 2025-10-19 01:15:01 +02:00
Gigi
016e369fb1 feat(highlights): only show nostrverse filter when logged out 2025-10-19 01:09:39 +02:00
Gigi
4f21982c48 feat(me): show bookmarks in cards view on /me/bookmarks tab 2025-10-19 01:08:42 +02:00
Gigi
f6d3fe9aba docs: update CHANGELOG.md for v0.7.3 release 2025-10-19 01:06:19 +02:00
Gigi
fc60e6b80a chore: bump version to 0.7.3 2025-10-19 01:04:48 +02:00
Gigi
d9cdbb7279 Merge pull request #20 from dergigi/writings-controller
Make Explore non-blocking; centralize nostrverse highlights & writings controllers
2025-10-19 01:03:25 +02:00
Gigi
401d333e0f fix(explore): logged-out mode relies solely on centralized nostrverse controllers; start controllers even when logged out 2025-10-19 00:58:07 +02:00
Gigi
d32a47e3c3 perf(explore): make loading fully non-blocking; seed caches then stream and merge results progressively 2025-10-19 00:55:24 +02:00
Gigi
35efdb6d3f feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init 2025-10-19 00:52:32 +02:00
Gigi
c7f7792d73 feat(highlights): add centralized nostrverseHighlightsController; start at app init; Explore subscribes to controller stream 2025-10-19 00:50:12 +02:00
Gigi
8aa26caae0 feat(explore): show skeletons instead of spinner; keep nostrverse non-blocking and stream into view 2025-10-19 00:48:24 +02:00
Gigi
6c00904bd5 fix(explore,nostrverse): never block explore highlights on nostrverse; show empty state instead of spinner and stream results into store immediately 2025-10-19 00:46:16 +02:00
Gigi
23526954ea fix(explore): reflect settings default scope immediately and avoid blank lists; preload/merge nostrverse from event store and keep fetches non-blocking 2025-10-19 00:42:39 +02:00
Gigi
9a437dd97b fix(explore): ensure nostrverse highlights are loaded and merged; preload nostrverse highlights at app start for instant Explore toggle 2025-10-19 00:38:05 +02:00
Gigi
0baf75462c refactor(explore): use writingsController for 'mine' posts; keep fetches non-blocking and centralized 2025-10-19 00:34:21 +02:00
Gigi
30b8f1af92 feat(writings): auto-load user writings at login so Explore 'mine' tab has local data 2025-10-19 00:30:07 +02:00
Gigi
07aea9d35f fix(explore): prevent disabling all explore scopes; ensure at least one filter remains active 2025-10-19 00:28:55 +02:00
Gigi
41a4abff37 fix(highlights): scope highlights to current article on /a and /r by deriving coordinate from naddr for early filtering, and ensure sidebar/content only show scoped highlights 2025-10-19 00:24:37 +02:00
Gigi
c9998984c3 feat(explore): include and stream my writings when enabled\n\n- Load my own writings in parallel with friends/nostrverse\n- Lazy-load on 'mine' toggle when logged in\n- Keep dedupe/sort consistent 2025-10-19 00:16:01 +02:00
Gigi
a799709e62 fix(explore): ensure writings are deduped by replaceable before visibility filtering and render 2025-10-19 00:14:20 +02:00
Gigi
18c6c3e68a fix(content): show only article-specific highlights in ContentPanel for nostr articles 2025-10-19 00:12:49 +02:00
Gigi
5e7395652f feat(explore): stream nostrverse writings when toggled on while logged in\n\n- Lazy-load nostrverse via onPost callback when filter is enabled\n- Avoid reloading twice using hasLoadedNostrverse guard\n- Keep DRY dedupe/sort behavior 2025-10-19 00:08:06 +02:00
Gigi
83076e7b01 feat(explore): stream nostrverse writings to paint instantly\n\n- Add onPost streaming callback to fetchNostrverseBlogPosts\n- Stream posts in Explore when logged out and logged in\n- Keep final deduped/sorted list after stream completes 2025-10-19 00:04:53 +02:00
Gigi
c79f4122da feat(debug): add Writings Loading section to debug page
- Add handlers for loading my writings, friends writings, and nostrverse writings
- Display writings with title, summary, author, and d-tag
- Show timing metrics (total load time and first event time)
- Use writingsController for own writings to test controller functionality
2025-10-18 23:57:46 +02:00
Gigi
179fe0bbc2 fix(explore): prevent infinite loop when loading nostrverse content
- Remove cachedHighlights, cachedWritings, myHighlights from useEffect deps
- These are derived from eventStore and caused infinite refetch loop
- Content is still seeded from cache but doesn't trigger re-fetches
2025-10-18 23:54:02 +02:00
Gigi
20b4f2b1b2 fix(explore): fetch nostrverse content when logged out
- Allow exploring nostrverse writings and highlights without account
- Default to nostrverse visibility when logged out
- Update visibility settings when login state changes
2025-10-18 23:50:12 +02:00
Gigi
936f9093cf fix(me): use myWritingsLoading state in writings tab rendering 2025-10-18 23:45:16 +02:00
Gigi
3149e5b824 feat(services): add centralized writingsController for kind 30023 2025-10-18 23:43:16 +02:00
Gigi
8619cecaf3 docs: update CHANGELOG.md for v0.7.2 2025-10-18 23:32:10 +02:00
41 changed files with 2932 additions and 475 deletions

View File

@@ -7,6 +7,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.0] - 2025-10-19
### Added
- Centralized reading progress controller for non-blocking reading position sync
- Progressive loading with caching from event store
- Streaming updates from relays with proper merging
- 2-second completion hold at 100% reading position to prevent UI jitter
- Configurable auto-mark-as-read at 100% reading progress
- Reading progress indicators on blog post cards
- Visual progress bars on article cards in Explore and bookmarks sidebar
- Persistent reading position synced across devices via NIP-85
### Changed
- Reading position sync now enabled by default in runtime paths
- Improved auto-mark-as-read behavior with reliable completion detection
- Reading progress events use proper NIP-85 specification (kind 39802)
### Fixed
- Reading position saves with proper validation and event store integration
- Profile page writings loading now fetches all writings without limits
- Consistent reading progress calculation and event publishing
### Performance
- Non-blocking reading progress controller with streaming updates
- Cache-first loading strategy with local event store before relay queries
- Efficient progress merging and deduplication
## [0.7.4] - 2025-10-18
### Added
- Profile page data preloading for instant tab switching
- Automatically preloads all highlights and writings when viewing a profile (`/p/` pages)
- Non-blocking background fetch stores all events in event store
- Tab switching becomes instant after initial preload
### Changed
- `/me/bookmarks` tab now displays in cards view only
- Removed view mode toggle buttons (compact, large) from bookmarks tab
- Cards view provides optimal bookmark browsing experience
- Grouping toggle (grouped/flat) still available
- Highlights sidebar filters simplified when logged out
- Only nostrverse filter button shown when not logged in
- Friends and personal highlight filters hidden when logged out
- Cleaner UX showing only available options
### Fixed
- Profile page tabs now display cached content instantly
- Highlights and writings show immediately from event store cache
- Network fetches happen in background without blocking UI
- Matches Explore and Debug page non-blocking loading pattern
- Eliminated loading delays when switching between tabs
### Performance
- Cache-first profile loading strategy
- Instant display of cached highlights and writings from event store
- Background refresh updates data without blocking
- Tab switches show content immediately without loading states
## [0.7.3] - 2025-10-18
### Added
- Centralized nostrverse writings controller for kind 30023 content
- Automatically starts at app initialization
- Streams nostrverse blog posts progressively to Explore page
- Provides non-blocking, cache-first loading strategy
- Centralized nostrverse highlights controller
- Pre-loads nostrverse highlights at app start for instant toggling
- Streams highlights progressively to Explore page
- Integrated with EventStore for caching
- Writings loading debug section on `/debug` page
- Diagnostics for writings controller and loading states
### Changed
- Explore page now uses centralized `writingsController` for user's own writings
- Auto-loads user writings at login for instant availability
- Non-blocking fetch with progressive streaming
- Explore page loading strategy optimized
- Shows skeleton placeholders instead of blocking spinners
- Seeds from cache, then streams and merges results progressively
- Keeps nostrverse fetches non-blocking
- User's own writings now included in Explore when enabled
- Lazy-loads on 'mine' toggle when logged in
- Streams in parallel with friends/nostrverse content
### Fixed
- Explore page works correctly in logged-out mode
- Relies solely on centralized nostrverse controllers
- Controllers start even when logged out
- Fetches nostrverse content properly without authentication
- Explore page no longer allows disabling all scope filters
- Ensures at least one filter (mine/friends/nostrverse) remains active
- Prevents blank content state
- Explore page reflects default scope setting immediately
- No more blank lists on initial load
- Pre-loads and merges nostrverse from event store
- Explore page highlights properly scoped
- Nostrverse highlights never block the page
- Shows empty state instead of spinner
- Streams results into store immediately
- Highlights are merged and loaded correctly
- Article-specific highlights properly filtered
- Highlights scoped to current article on `/a/` and `/r/` routes
- Derives coordinate from naddr for early filtering
- Sidebar and content only show relevant highlights
- ContentPanel shows only article-specific highlights for nostr articles
- Explore writings properly deduplicated
- Deduplication by replaceable event (author:d-tag) happens before visibility filtering
- Consistent dedupe/sort behavior across all loading scenarios
- Debug page writings loading section added
- No infinite loop when loading nostrverse content
### Performance
- Non-blocking explore page loading
- Fully non-blocking loading strategy
- Seeds caches then streams and merges results progressively
- Lazy-loading for content filters
- Nostrverse writings lazy-load when toggled on while logged in
- Avoids redundant loading with guard flags
- Streaming callbacks for progressive updates
- Writings stream to UI via onPost callback
- Posts appear instantly as they arrive from cache or network
## [0.7.2] - 2025-01-27
### Added
- Cached-first loading with EventStore across the app
- Instant display of cached highlights and writings from local event store
- Progressive loading with streaming updates from relays
- Centralized event storage for improved performance and offline support
- Default explore scope setting for controlling content visibility
- Configurable default scope for explore page content
- Dedicated Explore section in settings for better organization
### Changed
- Highlights and writings now load from cache first, then stream from relays
- Explore page shows cached content instantly before network updates
- Article-specific highlights stored in centralized event store for faster access
- Nostrverse content cached locally for improved performance
### Fixed
- Prevent "No highlights yet" flash on `/me/highlights` page
- Force React to remount tab content when switching tabs for proper state management
- Deduplicate blog posts by author:d-tag instead of event ID for better accuracy
- Show skeleton placeholders while highlights are loading for better UX
### Performance
- Local-first loading strategy reduces perceived loading times
- Cached content displays immediately while background sync occurs
- Centralized event storage eliminates redundant network requests
## [0.7.0] - 2025-10-18
### Added
@@ -1878,7 +2044,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.0...HEAD
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4
[0.7.3]: https://github.com/dergigi/boris/compare/v0.7.2...v0.7.3
[0.7.2]: https://github.com/dergigi/boris/compare/v0.7.0...v0.7.2
[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0
[0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23

View File

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

75
public/md/NIP-85.md Normal file
View File

@@ -0,0 +1,75 @@
# NIP-85
## Reading Progress
`draft` `optional`
This NIP defines kind `39802`, a parameterized replaceable event for tracking reading progress across articles and web content.
## Table of Contents
* [Format](#format)
* [Tags](#tags)
* [Content](#content)
* [Examples](#examples)
## Format
Reading progress events use NIP-33 parameterized replaceable semantics. The `d` tag serves as the unique identifier per author and target content.
### Tags
Events SHOULD tag the source of the reading progress, whether nostr-native or not. `a` tags should be used for nostr events and `r` tags for URLs.
When tagging a URL, clients generating these events SHOULD do a best effort of cleaning the URL from trackers or obvious non-useful information from the query string.
- `d` (required): Unique identifier for the target content
- For Nostr articles: `30023:<pubkey>:<identifier>` (matching the article's coordinate)
- For external URLs: `url:<base64url-encoded-url>`
- `a` (optional but recommended for Nostr articles): Article coordinate `30023:<pubkey>:<identifier>`
- `r` (optional but recommended for URLs): Raw URL of the external content
### Content
The content is a JSON object with the following fields:
- `progress` (required): Number between 0 and 1 representing reading progress (0 = not started, 1 = completed)
- `loc` (optional): Number representing a location marker (e.g., pixel scroll position, page number, etc.)
- `ts` (optional): Unix timestamp (seconds) when the progress was recorded
- `ver` (optional): Schema version string
The latest event by `created_at` per (`pubkey`, `d`) pair is authoritative (NIP-33 semantics).
Clients SHOULD implement rate limiting to avoid excessive relay traffic (debounce writes, only save significant changes).
## Examples
### Nostr Article
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635012,
"content": "{\"progress\":0.66,\"loc\":1432,\"ts\":1734635012,\"ver\":\"1\"}",
"tags": [
["d", "30023:<author-pubkey>:<article-identifier>"],
["a", "30023:<author-pubkey>:<article-identifier>"]
]
}
```
### External URL
```json
{
"kind": 39802,
"pubkey": "<user-pubkey>",
"created_at": 1734635999,
"content": "{\"progress\":1,\"ts\":1734635999,\"ver\":\"1\"}",
"tags": [
["d", "url:aHR0cHM6Ly9leGFtcGxlLmNvbS9wb3N0"],
["r", "https://example.com/post"]
]
}
```

View File

@@ -23,6 +23,11 @@ import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
import { highlightsController } from './services/highlightsController'
import { writingsController } from './services/writingsController'
import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -50,18 +55,14 @@ function AppRoutes({
// Subscribe to bookmark controller
useEffect(() => {
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
const unsubBookmarks = bookmarkController.onBookmarks((bookmarks) => {
console.log('[bookmark] 📥 Received bookmarks:', bookmarks.length)
setBookmarks(bookmarks)
})
const unsubLoading = bookmarkController.onLoading((loading) => {
console.log('[bookmark] 📥 Loading state:', loading)
setBookmarksLoading(loading)
})
return () => {
console.log('[bookmark] 🔇 Unsubscribing from bookmark controller')
unsubBookmarks()
unsubLoading()
}
@@ -94,7 +95,6 @@ function AppRoutes({
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login')
bookmarkController.start({ relayPool, activeAccount, accountManager })
}
@@ -109,16 +109,40 @@ function AppRoutes({
console.log('[highlights] 🚀 Auto-loading highlights on mount/login')
highlightsController.start({ relayPool, eventStore, pubkey })
}
// Load writings (controller manages its own state)
if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) {
console.log('[writings] 🚀 Auto-loading writings on mount/login')
writingsController.start({ relayPool, eventStore, pubkey })
}
// Load reading progress (controller manages its own state)
if (pubkey && eventStore && !readingProgressController.isLoadedFor(pubkey)) {
console.log('[progress] 🚀 Auto-loading reading progress on mount/login')
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
}
}, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager])
// Ensure nostrverse controllers run even when logged out
useEffect(() => {
if (relayPool && eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
nostrverseWritingsController.start({ relayPool, eventStore })
}
}, [relayPool, eventStore])
// Manual refresh (for sidebar button)
const handleRefreshBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) {
console.warn('[bookmark] Cannot refresh: missing relayPool or activeAccount')
return
}
console.log('[bookmark] 🔄 Manual refresh triggered')
bookmarkController.reset()
await bookmarkController.start({ relayPool, activeAccount, accountManager })
}, [relayPool, activeAccount, accountManager])
@@ -128,6 +152,7 @@ function AppRoutes({
bookmarkController.reset() // Clear bookmarks via controller
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
showToast('Logged out successfully')
}

View File

@@ -33,6 +33,11 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Debug log
if (readingProgress !== undefined) {
console.log('[progress] 🎴 Card render:', post.title.slice(0, 30), '=> progress:', progressPercent + '%', 'color:', progressColor)
}
return (
<Link

View File

@@ -19,9 +19,10 @@ interface BookmarkItemProps {
index: number
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
readingProgress?: number
}
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', readingProgress }) => {
const [ogImage, setOgImage] = useState<string | null>(null)
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
@@ -139,7 +140,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon: getContentTypeIcon()
contentTypeIcon: getContentTypeIcon(),
readingProgress
}
if (viewMode === 'compact') {

View File

@@ -13,7 +13,7 @@ import { ViewMode } from './Bookmarks'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet, hasCreationDate } from '../utils/bookmarkUtils'
import { UserSettings } from '../services/settingsService'
import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService'
@@ -100,6 +100,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
// Merge and flatten all individual bookmarks from all lists
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent)
.filter(b => !settings?.hideBookmarksWithoutCreationDate || hasCreationDate(b))
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
@@ -116,8 +117,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]

View File

@@ -24,6 +24,7 @@ interface CardViewProps {
articleImage?: string
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
export const CardView: React.FC<CardViewProps> = ({
@@ -38,7 +39,8 @@ export const CardView: React.FC<CardViewProps> = ({
handleReadNow,
articleImage,
articleSummary,
contentTypeIcon
contentTypeIcon,
readingProgress
}) => {
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
@@ -52,6 +54,14 @@ export const CardView: React.FC<CardViewProps> = ({
const shouldTruncate = !expanded && contentLength > 210
const isArticle = bookmark.kind === 30023
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
// Determine which image to use (article image, instant preview, or OG image)
const previewImage = articleImage || instantPreview || ogImage
const cachedImage = useImageCache(previewImage || undefined)
@@ -163,6 +173,28 @@ export const CardView: React.FC<CardViewProps> = ({
</button>
)}
{/* Reading progress indicator for articles */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '3px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
marginTop: '0.75rem'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="bookmark-footer">
<div className="bookmark-meta-minimal">
<Link

View File

@@ -13,6 +13,7 @@ interface CompactViewProps {
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
articleSummary?: string
contentTypeIcon: IconDefinition
readingProgress?: number
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -22,12 +23,21 @@ export const CompactView: React.FC<CompactViewProps> = ({
extractedUrls,
onSelectUrl,
articleSummary,
contentTypeIcon
contentTypeIcon,
readingProgress
}) => {
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
// Calculate progress color (matching BlogPostCard logic)
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
progressColor = 'var(--color-text)' // Neutral text color (started)
}
const handleCompactClick = () => {
if (!onSelectUrl) return
@@ -62,6 +72,28 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{/* CTA removed */}
</div>
{/* Reading progress indicator for articles */}
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
<div
style={{
height: '2px',
width: '100%',
background: 'var(--color-border)',
overflow: 'hidden',
margin: '0'
}}
>
<div
style={{
height: '100%',
width: `${Math.round(readingProgress * 100)}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
</div>
)
}

View File

@@ -17,6 +17,7 @@ import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import Profile from './Profile'
import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification'
@@ -330,7 +331,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
) : undefined}
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
relayPool ? <Profile relayPool={relayPool} eventStore={eventStore} pubkey={profilePubkey} activeTab={profileTab} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null

View File

@@ -32,7 +32,7 @@ import {
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
import { classifyUrl } from '../utils/helpers'
import { classifyUrl, shouldTrackReadingProgress } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
@@ -151,7 +151,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Callback to save reading position
const handleSavePosition = useCallback(async (position: number) => {
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
console.log('[progress] ⏭️ ContentPanel: Skipping save - missing requirements:', {
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
@@ -160,11 +160,24 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled in settings')
console.log('[progress] ⏭️ ContentPanel: Sync disabled in settings')
return
}
// Check if content is long enough to track reading progress
if (!shouldTrackReadingProgress(html, markdown)) {
console.log('[progress] ⏭️ ContentPanel: Content too short to track reading progress')
return
}
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
console.log('[progress] 💾 ContentPanel: Saving position:', {
position,
percentage: Math.round(position * 100) + '%',
scrollTop,
articleIdentifier: articleIdentifier.slice(0, 50) + '...',
url: selectedUrl?.slice(0, 50)
})
try {
const factory = new EventFactory({ signer: activeAccount })
@@ -176,25 +189,40 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{
position,
timestamp: Math.floor(Date.now() / 1000),
scrollTop: window.pageYOffset || document.documentElement.scrollTop
scrollTop
}
)
console.log('[progress] ✅ ContentPanel: Save completed successfully')
} catch (error) {
console.error('❌ [ContentPanel] Failed to save reading position:', error)
console.error('[progress] ❌ ContentPanel: Failed to save reading position:', error)
}
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl, html, markdown])
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
syncEnabled: settings?.syncReadingPosition !== false,
onSave: handleSavePosition,
onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) {
// Could trigger auto-mark as read here if desired
// Auto-mark as read when reading is complete (if enabled in settings)
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
console.log('[progress] 📖 Auto-marking as read on completion')
handleMarkAsRead()
}
}
})
// Log sync status when it changes
useEffect(() => {
console.log('[progress] 📊 ContentPanel reading position sync status:', {
enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition !== false,
hasAccount: !!activeAccount,
hasRelayPool: !!relayPool,
hasEventStore: !!eventStore,
hasArticleIdentifier: !!articleIdentifier,
currentProgress: progressPercentage + '%'
})
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
// Load saved reading position when article loads
useEffect(() => {
@@ -208,8 +236,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
})
return
}
if (!settings?.syncReadingPosition) {
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
if (settings?.syncReadingPosition === false) {
console.log('⏭️ [ContentPanel] Sync disabled in settings - not restoring position')
return
}

View File

@@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useSettings } from '../hooks/useSettings'
import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService'
import { contactsController } from '../services/contactsController'
import { writingsController } from '../services/writingsController'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
@@ -94,6 +96,12 @@ const Debug: React.FC<DebugProps> = ({
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(null)
// Writings loading state
const [isLoadingWritings, setIsLoadingWritings] = useState(false)
const [writingPosts, setWritingPosts] = useState<BlogPostPreview[]>([])
const [tLoadWritings, setTLoadWritings] = useState<number | null>(null)
const [tFirstWriting, setTFirstWriting] = useState<number | null>(null)
// Live timing state
const [liveTiming, setLiveTiming] = useState<{
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
@@ -538,6 +546,188 @@ const Debug: React.FC<DebugProps> = ({
}
}
const handleLoadMyWritings = async () => {
if (!relayPool || !activeAccount?.pubkey || !eventStore) {
DebugBus.warn('debug', 'Please log in to load your writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading my writings via writingsController...')
try {
let firstEventTime: number | null = null
const unsub = writingsController.onWritings((posts) => {
if (firstEventTime === null && posts.length > 0) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(posts)
})
await writingsController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: true
})
unsub()
const currentWritings = writingsController.getWritings()
setWritingPosts(currentWritings)
DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`)
}
}
const handleLoadFriendsWritings = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends writings')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading friends writings...')
try {
// Get contacts first
await contactsController.start({ relayPool, pubkey: activeAccount.pubkey })
const friends = contactsController.getContacts()
const friendsArray = Array.from(friends)
DebugBus.info('debug', `Found ${friendsArray.length} friends`)
if (friendsArray.length === 0) {
DebugBus.warn('debug', 'No friends found to load writings from')
return
}
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const posts = await fetchBlogPostsFromAuthors(
relayPool,
friendsArray,
relayUrls,
(post) => {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
setWritingPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const exists = prev.find(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (exists) return prev
return [...prev, post].sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
)
setWritingPosts(posts)
DebugBus.info('debug', `Loaded ${posts.length} friend writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`)
}
}
const handleLoadNostrverseWritings = async () => {
if (!relayPool) {
DebugBus.warn('debug', 'Relay pool not available')
return
}
const start = performance.now()
setWritingPosts([])
setIsLoadingWritings(true)
setTLoadWritings(null)
setTFirstWriting(null)
DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...')
try {
let firstEventTime: number | null = null
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const { queryEvents } = await import('../services/dataFetch')
const { Helpers } = await import('applesauce-core')
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
const uniqueEvents = new Map<string, NostrEvent>()
await queryEvents(relayPool, { kinds: [30023], limit: 50 }, {
relayUrls,
onEvent: (evt) => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || evt.created_at > existing.created_at) {
uniqueEvents.set(key, evt)
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstWriting(Math.round(firstEventTime))
}
const posts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(posts)
}
}
})
const finalPosts = Array.from(uniqueEvents.values()).map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
} as BlogPostPreview)).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setWritingPosts(finalPosts)
DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`)
} finally {
setIsLoadingWritings(false)
const elapsed = Math.round(performance.now() - start)
setTLoadWritings(elapsed)
DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`)
}
}
const handleClearWritings = () => {
setWritingPosts([])
setTLoadWritings(null)
setTFirstWriting(null)
}
const handleLoadFriendsList = async () => {
if (!relayPool || !activeAccount?.pubkey) {
DebugBus.warn('debug', 'Please log in to load friends list')
@@ -1070,6 +1260,99 @@ const Debug: React.FC<DebugProps> = ({
)}
</div>
{/* Writings Loading Section */}
<div className="settings-section">
<h3 className="section-title">Writings Loading</h3>
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
<div className="flex gap-2 mb-3 flex-wrap">
<button
className="btn btn-secondary text-sm"
onClick={handleLoadMyWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount || !eventStore}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load My Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadFriendsWritings}
disabled={isLoadingWritings || !relayPool || !activeAccount}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Friends Writings'
)}
</button>
<button
className="btn btn-secondary text-sm"
onClick={handleLoadNostrverseWritings}
disabled={isLoadingWritings || !relayPool}
>
{isLoadingWritings ? (
<FontAwesomeIcon icon={faSpinner} className="animate-spin" />
) : (
'Load Nostrverse Writings'
)}
</button>
<button
className="btn btn-secondary text-sm ml-auto"
onClick={handleClearWritings}
disabled={writingPosts.length === 0}
>
Clear
</button>
</div>
<div className="mb-3 flex gap-2 flex-wrap">
<Stat label="total" value={tLoadWritings} />
<Stat label="first event" value={tFirstWriting} />
</div>
{writingPosts.length > 0 && (
<div className="mb-3">
<div className="text-sm opacity-70 mb-2">Loaded Writings ({writingPosts.length}):</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{writingPosts.map((post, idx) => {
const title = post.title
const summary = post.summary
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
return (
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
<div className="font-semibold mb-1">Writing #{idx + 1}</div>
<div className="opacity-70 mb-1">
<div>Author: {post.author.slice(0, 16)}...</div>
<div>Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}</div>
<div>d-tag: {dTag || '(empty)'}</div>
</div>
<div className="mt-1">
<div className="font-semibold text-[11px]">Title:</div>
<div>&quot;{title}&quot;</div>
</div>
{summary && (
<div className="mt-1 text-[11px] opacity-70">
<div>Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}</div>
</div>
)}
{post.image && (
<div className="mt-1 text-[11px] opacity-70">
<div>Image: {post.image.substring(0, 40)}...</div>
</div>
)}
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {post.event.id}</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Web of Trust Section */}
<div className="settings-section">
<h3 className="section-title">Web of Trust</h3>

View File

@@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController'
import { highlightsController } from '../services/highlightsController'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
@@ -27,6 +28,9 @@ import { KINDS } from '../config/kinds'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe'
import { writingsController } from '../services/writingsController'
import { nostrverseWritingsController } from '../services/nostrverseWritingsController'
import { readingProgressController } from '../services/readingProgressController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -48,10 +52,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false)
const [hasLoadedMine, setHasLoadedMine] = useState(false)
const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false)
// Get myHighlights directly from controller
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
const [myHighlightsLoading, setMyHighlightsLoading] = useState(false)
// Remove unused loading state to avoid warnings
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached content from event store (instant display)
const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, [])
@@ -66,24 +76,155 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}), [])
const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, [])
// Visibility filters (defaults from settings)
// Visibility filters (defaults from settings or nostrverse when logged out)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
// Ensure at least one scope remains active
const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => {
setVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }
if (!next.nostrverse && !next.friends && !next.mine) {
return prev // ignore toggle that would disable all scopes
}
return next
})
}, [])
// Subscribe to highlights controller
useEffect(() => {
const unsubHighlights = highlightsController.onHighlights(setMyHighlights)
const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading)
return () => {
unsubHighlights()
unsubLoading()
}
}, [])
// Subscribe to nostrverse highlights controller for global stream
useEffect(() => {
const apply = (incoming: Highlight[]) => {
setHighlights(prev => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const h of incoming) byId.set(h.id, h)
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
})
}
// seed immediately
apply(nostrverseHighlightsController.getHighlights())
const unsub = nostrverseHighlightsController.onHighlights(apply)
return () => unsub()
}, [])
// Subscribe to nostrverse writings controller for global stream
useEffect(() => {
const apply = (incoming: BlogPostPreview[]) => {
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of prev) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
byKey.set(key, p)
}
for (const p of incoming) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
apply(nostrverseWritingsController.getWritings())
const unsub = nostrverseWritingsController.onWritings(apply)
return () => unsub()
}, [])
// Subscribe to writings controller for "mine" posts and seed immediately
useEffect(() => {
// Seed from controller's current state
const seed = writingsController.getWritings()
if (seed.length > 0) {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...seed])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
}
// Stream updates
const unsub = writingsController.onWritings((posts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...posts])
return merged.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
})
return () => unsub()
}, [])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
console.log('[progress] 🎯 Explore: Initial progress map size:', initialMap.size)
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
console.log('[progress] 🎯 Explore: Received progress update, size:', newMap.size)
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Update visibility when settings/login state changes
useEffect(() => {
if (!activeAccount) {
// When logged out, show nostrverse by default
setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false }))
setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately
setHasLoadedNostrverseHighlights(true)
} else {
// When logged in, use settings defaults immediately
setVisibility({
nostrverse: settings?.defaultExploreScopeNostrverse ?? false,
friends: settings?.defaultExploreScopeFriends ?? true,
mine: settings?.defaultExploreScopeMine ?? false
})
setHasLoadedNostrverse(false)
setHasLoadedNostrverseHighlights(false)
}
}, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -93,21 +234,22 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
useEffect(() => {
const loadData = async () => {
if (!activeAccount) {
setLoading(false)
return
}
try {
// show spinner but keep existing data
// begin load, but do not block rendering
setLoading(true)
// If not logged in, only fetch nostrverse content with streaming posts
if (!activeAccount) {
// Logged out: rely entirely on centralized controllers; do not fetch here
setLoading(false)
}
// Seed from in-memory cache if available to avoid empty flash
const memoryCachedPosts = getCachedPosts(activeAccount.pubkey)
const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : []
if (memoryCachedPosts && memoryCachedPosts.length > 0) {
setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev)
}
const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey)
const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : []
if (memoryCachedHighlights && memoryCachedHighlights.length > 0) {
setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev)
}
@@ -133,10 +275,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}
// At this point, we have seeded any available data; lift the loading state
setLoading(false)
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(
relayPool,
activeAccount.pubkey,
activeAccount?.pubkey || '',
(partial) => {
// Store followed pubkeys for highlight classification
setFollowedPubkeys(partial)
@@ -184,7 +329,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
setBlogPosts((prev) => {
@@ -213,7 +358,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
if (activeAccount) setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
@@ -229,14 +374,14 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
}
).then((all) => {
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of all) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
})
@@ -250,52 +395,143 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// Fetch both friends content and nostrverse content in parallel
// Fetch friends content and (optionally) nostrverse + mine content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined),
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
])
// Use centralized writingsController for my posts (non-blocking)
// pull from writingsController; no need to store promise
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
setHasLoadedMine(true)
const nostrversePostsPromise = visibility.nostrverse
? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => {
// Stream nostrverse posts too when logged in
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
})
: Promise.resolve([] as BlogPostPreview[])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Fire non-blocking fetches and merge as they resolve
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls)
.then((friendsPosts) => {
setBlogPosts(prev => {
const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts])
const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted)
// Pre-cache profiles in background
const authorPubkeys = Array.from(new Set(sorted.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {})
return sorted
})
}).catch(() => {})
// Merge and deduplicate all highlights (mine from controller + friends + nostrverse)
const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights]
const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at)
fetchHighlightsFromAuthors(relayPool, contactsArray)
.then((friendsHighlights) => {
setHighlights(prev => {
const merged = dedupeHighlightsById([...prev, ...friendsHighlights])
const sorted = merged.sort((a, b) => b.created_at - a.created_at)
if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted)
return sorted
})
}).catch(() => {})
// Fetch profiles for all blog post authors to cache them
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
nostrversePostsPromise.then((nostrversePosts) => {
setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)))
}).catch(() => {})
// No blocking errors - let empty states handle messaging
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((nostriverseHighlights) => {
setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at))
}).catch(() => {})
} catch (err) {
console.error('Failed to load data:', err)
// No blocking error - user can pull-to-refresh
} finally {
setLoading(false)
// loading is already turned off after seeding
}
}
loadData()
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
// Lazy-load nostrverse writings when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
setHasLoadedNostrverse(true)
fetchNostrverseBlogPosts(
relayPool,
relayUrls,
50,
eventStore || undefined,
(post) => {
setBlogPosts(prev => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${post.author}:${dTag}`
const existingIndex = prev.findIndex(p => {
const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
return `${p.author}:${pDTag}` === key
})
if (existingIndex >= 0) {
const existing = prev[existingIndex]
if (post.event.created_at <= existing.event.created_at) return prev
const next = [...prev]
next[existingIndex] = post
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
}
const next = [...prev, post]
return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}
).then((finalPosts) => {
// Ensure final deduped list
setBlogPosts(prev => {
const byKey = new Map<string, BlogPostPreview>()
for (const p of [...prev, ...finalPosts]) {
const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${p.author}:${dTag}`
const existing = byKey.get(key)
if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p)
}
return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))
})
}).catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse])
// Lazy-load nostrverse highlights when user toggles it on (logged in)
useEffect(() => {
if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return
setHasLoadedNostrverseHighlights(true)
fetchNostrverseHighlights(relayPool, 100, eventStore || undefined)
.then((hl) => {
if (hl && hl.length > 0) {
setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at))
}
})
.catch(() => {})
}, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights])
// Lazy-load my writings when user toggles "mine" on (logged in)
// No direct fetch here; writingsController streams my posts centrally
useEffect(() => {
if (!activeAccount || !visibility.mine || hasLoadedMine) return
setHasLoadedMine(true)
}, [visibility.mine, activeAccount, hasLoadedMine])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -333,10 +569,20 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Dedupe and sort posts once for rendering
const uniqueSortedPosts = useMemo(() => {
const unique = dedupeWritingsByReplaceable(blogPosts)
return unique.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}, [blogPosts])
// Filter blog posts by future dates and visibility, and add level classification
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts
return uniqueSortedPosts
.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
@@ -360,7 +606,40 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
return { ...post, level }
})
}, [blogPosts, activeAccount, followedPubkeys, visibility])
}, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility])
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) {
console.log('[progress] ⚠️ No d-tag for post:', post.title)
return undefined
}
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
// Only log first lookup to avoid spam, or when found
if (progress || readingProgressMap.size === 0) {
console.log('[progress] 🔍 Looking up:', {
title: post.title.slice(0, 30),
naddr: naddr.slice(0, 80),
mapSize: readingProgressMap.size,
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
})
}
return progress
} catch (err) {
console.error('[progress] ❌ Error encoding naddr:', err)
return undefined
}
}, [readingProgressMap])
const renderTabContent = () => {
switch (activeTab) {
@@ -386,6 +665,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
post={post}
href={getPostUrl(post)}
level={post.level}
readingProgress={getReadingProgress(post)}
/>
))}
</div>
@@ -403,7 +683,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
return classifiedHighlights.length === 0 ? (
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<span>No highlights to show for the selected scope.</span>
</div>
) : (
<div className="explore-grid">
@@ -422,9 +702,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}
// Show content progressively - no blocking error screens
// Show skeletons while first load in this session
const hasData = highlights.length > 0 || blogPosts.length > 0
const showSkeletons = (loading || myHighlightsLoading) && !hasData
const showSkeletons = loading && !hasData
return (
<div className="explore-container">
@@ -451,7 +731,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
onClick={() => toggleScope('nostrverse')}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
@@ -462,7 +742,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
onClick={() => toggleScope('friends')}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
@@ -474,7 +754,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
onClick={() => toggleScope('mine')}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"

View File

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

View File

@@ -1,26 +1,24 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { faHighlighter, faBookmark, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { IEventStore, Helpers } from 'applesauce-core'
import { IEventStore } from 'applesauce-core'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19, NostrEvent } from 'nostr-tools'
import { nip19 } 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 { writingsController } from '../services/writingsController'
import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton'
import { ViewMode } from './Bookmarks'
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh'
@@ -33,17 +31,12 @@ 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
import { readingProgressController } from '../services/readingProgressController'
interface MeProps {
relayPool: RelayPool
eventStore: IEventStore
activeTab?: TabType
pubkey?: string // Optional pubkey for viewing other users' profiles
bookmarks: Bookmark[] // From centralized App.tsx state
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
}
@@ -51,13 +44,12 @@ interface MeProps {
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted']
const Me: React.FC<MeProps> = ({
relayPool,
eventStore,
activeTab: propActiveTab,
pubkey: propPubkey,
activeTab: propActiveTab,
bookmarks
}) => {
const activeAccount = Hooks.useActiveAccount()
@@ -65,9 +57,8 @@ const Me: React.FC<MeProps> = ({
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account
const viewingPubkey = propPubkey || activeAccount?.pubkey
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
// Only for own profile
const viewingPubkey = activeAccount?.pubkey
const [highlights, setHighlights] = useState<Highlight[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
@@ -81,30 +72,10 @@ const Me: React.FC<MeProps> = ({
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]
)
// Get myWritings directly from controller
const [myWritings, setMyWritings] = useState<BlogPostPreview[]>([])
const [myWritingsLoading, setMyWritingsLoading] = useState(false)
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')
const [groupingMode, setGroupingMode] = useState<'grouped' | 'flat'>(() => {
@@ -123,6 +94,9 @@ const Me: React.FC<MeProps> = ({
? (urlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// Reading progress state for writings tab (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Subscribe to highlights controller
useEffect(() => {
@@ -138,6 +112,20 @@ const Me: React.FC<MeProps> = ({
}
}, [])
// Subscribe to writings controller
useEffect(() => {
// Get initial state immediately
setMyWritings(writingsController.getWritings())
// Subscribe to updates
const unsubWritings = writingsController.onWritings(setMyWritings)
const unsubLoading = writingsController.onLoading(setMyWritingsLoading)
return () => {
unsubWritings()
unsubLoading()
}
}, [])
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
@@ -164,68 +152,64 @@ const Me: React.FC<MeProps> = ({
}
}
}
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Load reading progress data for writings tab
useEffect(() => {
if (!viewingPubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: viewingPubkey,
force: refreshTrigger > 0
})
}, [viewingPubkey, relayPool, eventStore, refreshTrigger])
// Tab-specific loading functions
const loadHighlightsTab = async () => {
if (!viewingPubkey) return
// Only show loading skeleton if tab hasn't been loaded yet
const hasBeenLoaded = loadedTabs.has('highlights')
try {
if (!hasBeenLoaded) setLoading(true)
// For own profile, highlights come from controller subscription (sync effect handles it)
// For viewing other users, seed with cached data then fetch fresh
if (!isOwnProfile) {
// Seed with cached highlights first
if (cachedHighlights.length > 0) {
setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at))
}
// Fetch fresh highlights
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
setHighlights(userHighlights)
}
setLoadedTabs(prev => new Set(prev).add('highlights'))
} catch (err) {
console.error('Failed to load highlights:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
// Highlights come from controller subscription (sync effect handles it)
setLoadedTabs(prev => new Set(prev).add('highlights'))
setLoading(false)
}
const loadWritingsTab = async () => {
if (!viewingPubkey) return
const hasBeenLoaded = loadedTabs.has('writings')
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)
// Use centralized controller
await writingsController.start({
relayPool,
eventStore,
pubkey: viewingPubkey,
force: refreshTrigger > 0
})
setLoadedTabs(prev => new Set(prev).add('writings'))
setLoading(false)
} catch (err) {
console.error('Failed to load writings:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
setLoading(false)
}
}
const loadReadingListTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
@@ -241,7 +225,7 @@ const Me: React.FC<MeProps> = ({
}
const loadReadsTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reads')
@@ -291,7 +275,7 @@ const Me: React.FC<MeProps> = ({
}
const loadLinksTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
if (!viewingPubkey || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
@@ -337,14 +321,12 @@ const Me: React.FC<MeProps> = ({
}
// Load cached data immediately if available
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
// Bookmarks come from App.tsx centralized state, no local caching needed
setReads(cached.reads || [])
setLinks(cached.links || [])
}
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
// Bookmarks come from App.tsx centralized state, no local caching needed
setReads(cached.reads || [])
setLinks(cached.links || [])
}
// Load data for active tab (refresh in background if already loaded)
@@ -366,14 +348,17 @@ const Me: React.FC<MeProps> = ({
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
}, [activeTab, viewingPubkey, refreshTrigger, bookmarks])
// Sync myHighlights from controller when viewing own profile
// Sync myHighlights from controller
useEffect(() => {
if (isOwnProfile) {
setHighlights(myHighlights)
}
}, [isOwnProfile, myHighlights])
setHighlights(myHighlights)
}, [myHighlights])
// Sync myWritings from controller
useEffect(() => {
setWritings(myWritings)
}, [myWritings])
// Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({
@@ -389,8 +374,8 @@ const Me: React.FC<MeProps> = ({
const handleHighlightDelete = (highlightId: string) => {
setHighlights(prev => {
const updated = prev.filter(h => h.id !== highlightId)
// Update cache when highlight is deleted (own profile only)
if (isOwnProfile && viewingPubkey) {
// Update cache when highlight is deleted
if (viewingPubkey) {
updateCachedHighlights(viewingPubkey, updated)
}
return updated
@@ -468,6 +453,42 @@ const Me: React.FC<MeProps> = ({
navigate(`/r/${encodeURIComponent(url)}`)
}
}
// Helper to get reading progress for a post
const getWritingReadingProgress = (post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
// Helper to get reading progress for a bookmark
const getBookmarkReadingProgress = (bookmark: IndividualBookmark): number | undefined => {
if (bookmark.kind === 30023) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: bookmark.pubkey,
identifier: dTag
})
return readingProgressMap.get(naddr)
} catch (err) {
return undefined
}
}
return undefined
}
// Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
@@ -478,23 +499,44 @@ const Me: React.FC<MeProps> = ({
const groups = groupIndividualBookmarks(filteredBookmarks)
// Enrich reads and links with reading progress from controller
const readsWithProgress = reads.map(item => {
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
const linksWithProgress = links.map(item => {
if (item.url) {
const progress = readingProgressMap.get(item.url)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
// Apply reading progress filter
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
: [
{ key: 'nip51-private', title: 'Private Bookmarks', items: groups.nip51Private },
{ key: 'nip51-public', title: 'My Bookmarks', items: groups.nip51Public },
{ key: 'amethyst-private', title: 'Amethyst Private', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'Amethyst Lists', items: groups.amethystPublic },
{ key: 'amethyst-private', title: 'Private Lists', items: groups.amethystPrivate },
{ key: 'amethyst-public', title: 'My Lists', items: groups.amethystPublic },
{ key: 'web', title: 'Web Bookmarks', items: groups.standaloneWeb }
]
// 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 || (isOwnProfile && myHighlightsLoading)) && !hasData
const showSkeletons = (loading || myHighlightsLoading) && !hasData
const renderTabContent = () => {
switch (activeTab) {
@@ -508,7 +550,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
}
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
return highlights.length === 0 && !loading && !myHighlightsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>
@@ -529,9 +571,9 @@ const Me: React.FC<MeProps> = ({
if (showSkeletons) {
return (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
<div className="bookmarks-grid bookmarks-cards">
{Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
<BookmarkSkeleton key={i} viewMode="cards" />
))}
</div>
</div>
@@ -557,14 +599,15 @@ const Me: React.FC<MeProps> = ({
sections.filter(s => s.items.length > 0).map(section => (
<div key={section.key} className="bookmarks-section">
<h3 className="bookmarks-section-title">{section.title}</h3>
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
<div className="bookmarks-grid bookmarks-cards">
{section.items.map((individualBookmark, index) => (
<BookmarkItem
key={`${section.key}-${individualBookmark.id}-${index}`}
bookmark={individualBookmark}
index={index}
viewMode={viewMode}
viewMode="cards"
onSelectUrl={handleSelectUrl}
readingProgress={getBookmarkReadingProgress(individualBookmark)}
/>
))}
</div>
@@ -585,27 +628,6 @@ const Me: React.FC<MeProps> = ({
ariaLabel={groupingMode === 'grouped' ? 'Switch to flat view' : 'Switch to grouped view'}
variant="ghost"
/>
<IconButton
icon={faList}
onClick={() => setViewMode('compact')}
title="Compact list view"
ariaLabel="Compact list view"
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faThLarge}
onClick={() => setViewMode('cards')}
title="Cards view"
ariaLabel="Cards view"
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
/>
<IconButton
icon={faImage}
onClick={() => setViewMode('large')}
title="Large preview view"
ariaLabel="Large preview view"
variant={viewMode === 'large' ? 'primary' : 'ghost'}
/>
</div>
</div>
)
@@ -714,7 +736,7 @@ const Me: React.FC<MeProps> = ({
</div>
)
}
return writings.length === 0 && !loading ? (
return writings.length === 0 && !loading && !myWritingsLoading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
@@ -725,6 +747,7 @@ const Me: React.FC<MeProps> = ({
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={getWritingReadingProgress(post)}
/>
))}
</div>
@@ -748,43 +771,39 @@ const Me: React.FC<MeProps> = ({
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
onClick={() => navigate('/me/highlights')}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
{isOwnProfile && (
<>
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
</button>
</>
)}
<button
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
data-tab="reading-list"
onClick={() => navigate('/me/reading-list')}
>
<FontAwesomeIcon icon={faBookmark} />
<span className="tab-label">Bookmarks</span>
</button>
<button
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="reads"
onClick={() => navigate('/me/reads')}
>
<FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Reads</span>
</button>
<button
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
data-tab="links"
onClick={() => navigate('/me/links')}
>
<FontAwesomeIcon icon={faLink} />
<span className="tab-label">Links</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
onClick={() => navigate('/me/writings')}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>

280
src/components/Profile.tsx Normal file
View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHighlighter, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
import { HighlightItem } from './HighlightItem'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { fetchHighlights } from '../services/highlightService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import AuthorCard from './AuthorCard'
import BlogPostCard from './BlogPostCard'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { useStoreTimeline } from '../hooks/useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { toBlogPostPreview } from '../utils/toBlogPostPreview'
import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator'
import { Hooks } from 'applesauce-react'
import { readingProgressController } from '../services/readingProgressController'
interface ProfileProps {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
activeTab?: 'highlights' | 'writings'
}
const Profile: React.FC<ProfileProps> = ({
relayPool,
eventStore,
pubkey,
activeTab: propActiveTab
}) => {
const navigate = useNavigate()
const activeAccount = Hooks.useActiveAccount()
const [activeTab, setActiveTab] = useState<'highlights' | 'writings'>(propActiveTab || 'highlights')
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Reading progress state (naddr -> progress 0-1)
const [readingProgressMap, setReadingProgressMap] = useState<Map<string, number>>(new Map())
// Load cached data from event store instantly
const cachedHighlights = useStoreTimeline(
eventStore,
{ kinds: [KINDS.Highlights], authors: [pubkey] },
eventToHighlight,
[pubkey]
)
const cachedWritings = useStoreTimeline(
eventStore,
{ kinds: [30023], authors: [pubkey] },
toBlogPostPreview,
[pubkey]
)
// Update local state when prop changes
useEffect(() => {
if (propActiveTab) {
setActiveTab(propActiveTab)
}
}, [propActiveTab])
// Subscribe to reading progress controller
useEffect(() => {
// Get initial state immediately
const initialMap = readingProgressController.getProgressMap()
console.log('[progress] 🎯 Profile: Initial progress map size:', initialMap.size)
setReadingProgressMap(initialMap)
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((newMap) => {
console.log('[progress] 🎯 Profile: Received progress update, size:', newMap.size)
setReadingProgressMap(newMap)
})
return () => {
unsubProgress()
}
}, [])
// Load reading progress data when logged in
useEffect(() => {
if (!activeAccount?.pubkey) {
return
}
readingProgressController.start({
relayPool,
eventStore,
pubkey: activeAccount.pubkey,
force: refreshTrigger > 0
})
}, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger])
// Background fetch to populate event store (non-blocking)
useEffect(() => {
if (!pubkey || !relayPool || !eventStore) return
console.log('🔄 [Profile] Background fetching highlights and writings for', pubkey.slice(0, 8))
// Fetch highlights in background
fetchHighlights(relayPool, pubkey, undefined, undefined, false, eventStore)
.then(highlights => {
console.log('✅ [Profile] Fetched', highlights.length, 'highlights')
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch highlights:', err)
})
// Fetch writings in background (no limit for single user profile)
fetchBlogPostsFromAuthors(relayPool, [pubkey], RELAYS, undefined, null)
.then(writings => {
writings.forEach(w => eventStore.add(w.event))
console.log('✅ [Profile] Fetched', writings.length, 'writings')
})
.catch(err => {
console.warn('⚠️ [Profile] Failed to fetch writings:', err)
})
}, [pubkey, relayPool, eventStore, refreshTrigger])
// Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => {
setRefreshTrigger(prev => prev + 1)
},
maximumPullLength: 240,
refreshThreshold: 80,
isDisabled: !pubkey
})
const getPostUrl = (post: BlogPostPreview) => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
return `/a/${naddr}`
}
// Helper to get reading progress for a post
const getReadingProgress = useCallback((post: BlogPostPreview): number | undefined => {
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
try {
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const progress = readingProgressMap.get(naddr)
// Only log when found or map is empty
if (progress || readingProgressMap.size === 0) {
console.log('[progress] 🔍 Profile lookup:', {
title: post.title?.slice(0, 30),
naddr: naddr.slice(0, 80),
mapSize: readingProgressMap.size,
mapKeys: readingProgressMap.size > 0 ? Array.from(readingProgressMap.keys()).slice(0, 3).map(k => k.slice(0, 80)) : [],
progress: progress ? Math.round(progress * 100) + '%' : 'not found'
})
}
return progress
} catch (err) {
return undefined
}
}, [readingProgressMap])
const handleHighlightDelete = () => {
// Not allowed to delete other users' highlights
return
}
const npub = nip19.npubEncode(pubkey)
const showSkeletons = cachedHighlights.length === 0 && cachedWritings.length === 0
const renderTabContent = () => {
switch (activeTab) {
case 'highlights':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
)
}
return cachedHighlights.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No highlights yet.
</div>
) : (
<div className="highlights-list me-highlights-list">
{cachedHighlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
)
case 'writings':
if (showSkeletons) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
return cachedWritings.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles written yet.
</div>
) : (
<div className="explore-grid">
{cachedWritings.map((post) => (
<BlogPostCard
key={post.event.id}
post={post}
href={getPostUrl(post)}
readingProgress={getReadingProgress(post)}
/>
))}
</div>
)
default:
return null
}
}
return (
<div className="explore-container">
<RefreshIndicator
isRefreshing={isRefreshing}
pullPosition={pullPosition}
/>
<div className="explore-header">
<AuthorCard authorPubkey={pubkey} clickable={false} />
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
data-tab="highlights"
onClick={() => navigate(`/p/${npub}`)}
>
<FontAwesomeIcon icon={faHighlighter} />
<span className="tab-label">Highlights</span>
</button>
<button
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
data-tab="writings"
onClick={() => navigate(`/p/${npub}/writings`)}
>
<FontAwesomeIcon icon={faPenToSquare} />
<span className="tab-label">Writings</span>
</button>
</div>
</div>
<div className="me-tab-content">
{renderTabContent()}
</div>
</div>
)
}
export default Profile

View File

@@ -1,9 +1,9 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -16,6 +16,7 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'highlighted' as const, icon: faHighlighter, label: 'Highlighted' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
]
@@ -23,8 +24,15 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
<div className="bookmark-filters">
{filters.map(filter => {
const isActive = selectedFilter === filter.type
// Only "completed" gets green color, everything else uses default blue
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
// Only "completed" gets green color, "highlighted" gets yellow, everything else uses default blue
let activeStyle: Record<string, string> | undefined = undefined
if (isActive) {
if (filter.type === 'completed') {
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
}
}
return (
<button

View File

@@ -156,7 +156,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
fontWeight: 400
}}
>
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
Local relays only
</span>
</>
)}

View File

@@ -39,7 +39,9 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true,
rebroadcastToAllRelays: false,
paragraphAlignment: 'justify',
syncReadingPosition: false,
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: false,
}
interface SettingsProps {

View File

@@ -117,6 +117,32 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
<span>Sync reading position across devices</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="autoMarkAsReadOnCompletion" className="checkbox-label">
<input
id="autoMarkAsReadOnCompletion"
type="checkbox"
checked={settings.autoMarkAsReadOnCompletion ?? false}
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read at 100%</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="hideBookmarksWithoutCreationDate" className="checkbox-label">
<input
id="hideBookmarksWithoutCreationDate"
type="checkbox"
checked={settings.hideBookmarksWithoutCreationDate ?? false}
onChange={(e) => onUpdate({ hideBookmarksWithoutCreationDate: e.target.checked })}
className="setting-checkbox"
/>
<span>Hide bookmarks missing a creation date</span>
</label>
</div>
</div>
)
}

View File

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

View File

@@ -1,8 +1,9 @@
// Nostr event kinds used throughout the application
export const KINDS = {
Highlights: 9802, // NIP-?? user highlights
Highlights: 9802, // NIP-84 user highlights
BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data (reading positions)
AppData: 30078, // NIP-78 application data
ReadingProgress: 39802, // NIP-85 reading progress
List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list
@@ -13,3 +14,9 @@ export const KINDS = {
export type KindValue = typeof KINDS[keyof typeof KINDS]
// Reading progress tracking configuration
export const READING_PROGRESS = {
// Minimum character count to track reading progress (roughly 150 words)
MIN_CONTENT_LENGTH: 1000
} as const

View File

@@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController'
import { useStoreTimeline } from './useStoreTimeline'
import { eventToHighlight } from '../services/highlightEventProcessor'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
interface UseBookmarksDataParams {
relayPool: RelayPool | null
@@ -44,21 +45,38 @@ export const useBookmarksData = ({
const [isRefreshing, setIsRefreshing] = useState(false)
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
// Determine effective article coordinate as early as possible
// Prefer state-derived coordinate, but fall back to route naddr before content loads
const effectiveArticleCoordinate = useMemo(() => {
if (currentArticleCoordinate) return currentArticleCoordinate
if (!naddr) return undefined
try {
const decoded = nip19.decode(naddr)
if (decoded.type === 'naddr') {
const ptr = decoded.data as { kind: number; pubkey: string; identifier: string }
return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}`
}
} catch {
// ignore decode failure; treat as no coordinate yet
}
return undefined
}, [currentArticleCoordinate, naddr])
// Load cached article-specific highlights from event store
const articleFilter = useMemo(() => {
if (!currentArticleCoordinate) return null
if (!effectiveArticleCoordinate) return null
return {
kinds: [KINDS.Highlights],
'#a': [currentArticleCoordinate],
'#a': [effectiveArticleCoordinate],
...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {})
}
}, [currentArticleCoordinate, currentArticleEventId])
}, [effectiveArticleCoordinate, currentArticleEventId])
const cachedArticleHighlights = useStoreTimeline(
eventStore || null,
articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article
eventToHighlight,
[currentArticleCoordinate, currentArticleEventId]
[effectiveArticleCoordinate, currentArticleEventId]
)
// Subscribe to centralized controllers
@@ -84,7 +102,7 @@ export const useBookmarksData = ({
setHighlightsLoading(true)
try {
if (currentArticleCoordinate) {
if (effectiveArticleCoordinate) {
// Seed with cached highlights first
if (cachedArticleHighlights.length > 0) {
setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at))
@@ -97,7 +115,7 @@ export const useBookmarksData = ({
await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
effectiveArticleCoordinate,
currentArticleEventId,
(highlight) => {
// Deduplicate highlights by ID as they arrive
@@ -120,7 +138,7 @@ export const useBookmarksData = ({
} finally {
setHighlightsLoading(false)
}
}, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
}, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights])
const handleRefreshAll = useCallback(async () => {
if (!relayPool || !activeAccount || isRefreshing) return
@@ -143,19 +161,20 @@ export const useBookmarksData = ({
if (!relayPool || !activeAccount) return
// Fetch article-specific highlights when viewing an article
// External URLs have their highlights fetched by useExternalUrlLoader
if (currentArticleCoordinate && !externalUrl) {
if (effectiveArticleCoordinate && !externalUrl) {
handleFetchHighlights()
} else if (!naddr && !externalUrl) {
// Clear article highlights when not viewing an article
setArticleHighlights([])
setHighlightsLoading(false)
}
}, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
}, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights])
// Merge highlights from controller with article-specific highlights
const highlights = [...myHighlights, ...articleHighlights]
.filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate
.sort((a, b) => b.created_at - a.created_at)
// When viewing an article, show only article-specific highlights
// Otherwise, show user's highlights from controller
const highlights = effectiveArticleCoordinate || externalUrl
? articleHighlights.sort((a, b) => b.created_at - a.created_at)
: myHighlights
return {
highlights,

View File

@@ -4,40 +4,53 @@ interface UseReadingPositionOptions {
enabled?: boolean
onPositionChange?: (position: number) => void
onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%)
readingCompleteThreshold?: number // Default 0.95 (95%) - matches filter threshold
syncEnabled?: boolean // Whether to sync positions to Nostr
onSave?: (position: number) => void // Callback for saving position
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
completionHoldMs?: number // How long to hold at 100% before firing complete (default 2000)
}
export const useReadingPosition = ({
enabled = true,
onPositionChange,
onReadingComplete,
readingCompleteThreshold = 0.9,
readingCompleteThreshold = 0.95, // Match filter threshold for consistency
syncEnabled = false,
onSave,
autoSaveInterval = 5000
autoSaveInterval = 5000,
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasSavedOnce = useRef(false)
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Debounced save function
const scheduleSave = useCallback((currentPosition: number) => {
if (!syncEnabled || !onSave) return
// Don't save if position is too low (< 5%)
if (currentPosition < 0.05) return
if (!syncEnabled || !onSave) {
console.log('[progress] ⏭️ scheduleSave skipped:', { syncEnabled, hasOnSave: !!onSave, position: Math.round(currentPosition * 100) + '%' })
return
}
// Don't save if position hasn't changed significantly (less than 1%)
// But always save if we've reached 100% (completion)
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
const isInitialSave = !hasSavedOnce.current
if (!hasSignificantChange && !hasReachedCompletion) return
if (!hasSignificantChange && !hasReachedCompletion && !isInitialSave) {
console.log('[progress] ⏭️ No significant change:', {
current: Math.round(currentPosition * 100) + '%',
last: Math.round(lastSavedPosition.current * 100) + '%',
diff: Math.abs(currentPosition - lastSavedPosition.current),
isInitialSave
})
return
}
// Clear existing timer
if (saveTimerRef.current) {
@@ -45,8 +58,11 @@ export const useReadingPosition = ({
}
// Schedule new save
console.log('[progress] ⏰ Scheduling save in', autoSaveInterval + 'ms for position:', Math.round(currentPosition * 100) + '%')
saveTimerRef.current = setTimeout(() => {
console.log('[progress] 💾 Auto-saving position:', Math.round(currentPosition * 100) + '%')
lastSavedPosition.current = currentPosition
hasSavedOnce.current = true
onSave(currentPosition)
}, autoSaveInterval)
}, [syncEnabled, onSave, autoSaveInterval])
@@ -61,11 +77,11 @@ export const useReadingPosition = ({
saveTimerRef.current = null
}
// Save if position is meaningful (>= 5%)
if (position >= 0.05) {
lastSavedPosition.current = position
onSave(position)
}
// Always allow immediate save (including 0%)
console.log('[progress] 💾 Immediate save triggered for position:', Math.round(position * 100) + '%')
lastSavedPosition.current = position
hasSavedOnce.current = true
onSave(position)
}, [syncEnabled, onSave, position])
useEffect(() => {
@@ -89,17 +105,53 @@ export const useReadingPosition = ({
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
// Only log on significant changes (every 5%) to avoid flooding console
const prevPercent = Math.floor(position * 20) // Groups by 5%
const newPercent = Math.floor(clampedProgress * 20)
if (prevPercent !== newPercent) {
console.log('[progress] 📏 useReadingPosition:', Math.round(clampedProgress * 100) + '%', {
scrollTop,
documentHeight,
isAtBottom
})
}
setPosition(clampedProgress)
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()
// Completion detection with 2s hold at 100%
if (!hasTriggeredComplete.current) {
// If at exact 100%, start a hold timer; cancel if we scroll up
if (clampedProgress === 1) {
if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && position === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
console.log('[progress] ✅ Completion hold satisfied (100% for', completionHoldMs, 'ms)')
onReadingComplete?.()
}
completionTimerRef.current = null
}, completionHoldMs)
console.log('[progress] ⏳ Completion hold started (waiting', completionHoldMs, 'ms)')
}
} else {
// If we moved off 100%, cancel any pending completion hold
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
// still allow threshold-based completion for near-bottom if configured
if (clampedProgress >= readingCompleteThreshold) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
console.log('[progress] ✅ Completion via threshold:', readingCompleteThreshold)
onReadingComplete?.()
}
}
}
}
}
@@ -118,7 +170,12 @@ export const useReadingPosition = ({
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
}
}
// position is intentionally not in deps - it's computed from scroll and would cause infinite re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
// Reset reading complete state when enabled changes
@@ -126,6 +183,12 @@ export const useReadingPosition = ({
if (!enabled) {
setIsReadingComplete(false)
hasTriggeredComplete.current = false
hasSavedOnce.current = false
lastSavedPosition.current = 0
if (completionTimerRef.current) {
clearTimeout(completionTimerRef.current)
completionTimerRef.current = null
}
}
}, [enabled])

View File

@@ -126,18 +126,14 @@ class BookmarkController {
generation: number
): void {
if (!this.eventLoader) {
console.warn('[bookmark] ⚠️ EventLoader not initialized')
return
}
// Filter to unique IDs not already hydrated
const unique = Array.from(new Set(ids)).filter(id => !idToEvent.has(id))
if (unique.length === 0) {
console.log('[bookmark] 🔧 All IDs already hydrated, skipping')
return
}
console.log('[bookmark] 🔧 Hydrating', unique.length, 'IDs using EventLoader')
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
@@ -159,8 +155,8 @@ class BookmarkController {
onProgress()
},
error: (error) => {
console.error('[bookmark] ❌ EventLoader error:', error)
error: () => {
// Silent error - EventLoader handles retries
}
})
}
@@ -175,14 +171,11 @@ class BookmarkController {
generation: number
): void {
if (!this.addressLoader) {
console.warn('[bookmark] ⚠️ AddressLoader not initialized')
return
}
if (coords.length === 0) return
console.log('[bookmark] 🔧 Hydrating', coords.length, 'coordinates using AddressLoader')
// Convert coordinates to AddressPointers
const pointers = coords.map(c => ({
kind: c.kind,
@@ -203,8 +196,8 @@ class BookmarkController {
onProgress()
},
error: (error) => {
console.error('[bookmark] ❌ AddressLoader error:', error)
error: () => {
// Silent error - AddressLoader handles retries
}
})
}
@@ -223,10 +216,6 @@ class BookmarkController {
return this.decryptedResults.has(getEventKey(evt))
})
const unencryptedCount = allEvents.filter(evt => !hasEncryptedContent(evt)).length
const decryptedCount = readyEvents.length - unencryptedCount
console.log('[bookmark] 📋 Building bookmarks:', unencryptedCount, 'unencrypted,', decryptedCount, 'decrypted, of', allEvents.length, 'total')
if (readyEvents.length === 0) {
this.bookmarksListeners.forEach(cb => cb([]))
return
@@ -237,17 +226,14 @@ class BookmarkController {
const unencryptedEvents = readyEvents.filter(evt => !hasEncryptedContent(evt))
const decryptedEvents = readyEvents.filter(evt => hasEncryptedContent(evt))
console.log('[bookmark] 🔧 Processing', unencryptedEvents.length, 'unencrypted events')
// Process unencrypted events
const { publicItemsAll: publicUnencrypted, privateItemsAll: privateUnencrypted, newestCreatedAt, latestContent, allTags } =
await collectBookmarksFromEvents(unencryptedEvents, activeAccount, signerCandidate)
console.log('[bookmark] 🔧 Unencrypted returned:', publicUnencrypted.length, 'public,', privateUnencrypted.length, 'private')
// Merge in decrypted results
let publicItemsAll = [...publicUnencrypted]
let privateItemsAll = [...privateUnencrypted]
console.log('[bookmark] 🔧 Merging', decryptedEvents.length, 'decrypted events')
decryptedEvents.forEach(evt => {
const eventKey = getEventKey(evt)
const decrypted = this.decryptedResults.get(eventKey)
@@ -256,11 +242,8 @@ class BookmarkController {
privateItemsAll = [...privateItemsAll, ...decrypted.privateItems]
}
})
console.log('[bookmark] 🔧 Total after merge:', publicItemsAll.length, 'public,', privateItemsAll.length, 'private')
const allItems = [...publicItemsAll, ...privateItemsAll]
console.log('[bookmark] 🔧 Total items to process:', allItems.length)
// Separate hex IDs from coordinates
const noteIds: string[] = []
@@ -276,14 +259,11 @@ class BookmarkController {
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
console.log('[bookmark] 🔧 Building final bookmarks list...')
const allBookmarks = dedupeBookmarksById([
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
console.log('[bookmark] 🔧 After hydration and dedup:', allBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Enriching and sorting...')
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
@@ -293,9 +273,7 @@ class BookmarkController {
const sortedBookmarks = enriched
.map(b => ({ ...b, urlReferences: extractUrlsFromContent(b.content) }))
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
console.log('[bookmark] 🔧 Sorted:', sortedBookmarks.length, 'bookmarks')
console.log('[bookmark] 🔧 Creating final Bookmark object...')
const bookmark: Bookmark = {
id: `${activeAccount.pubkey}-bookmarks`,
title: `Bookmarks (${sortedBookmarks.length})`,
@@ -310,18 +288,14 @@ class BookmarkController {
encryptedContent: undefined
}
console.log('[bookmark] 📋 Built bookmark with', sortedBookmarks.length, 'items')
console.log('[bookmark] 📤 Emitting to', this.bookmarksListeners.length, 'listeners')
this.bookmarksListeners.forEach(cb => cb([bookmark]))
}
// Emit immediately with empty metadata (show placeholders)
const idToEvent: Map<string, NostrEvent> = new Map()
console.log('[bookmark] 🚀 Emitting initial bookmarks with placeholders (IDs only)...')
emitBookmarks(idToEvent)
// Now fetch events progressively in background using batched hydrators
console.log('[bookmark] 🔧 Background hydration:', noteIds.length, 'note IDs and', coordinates.length, 'coordinates')
const generation = this.hydrationGeneration
const onProgress = () => emitBookmarks(idToEvent)
@@ -341,9 +315,7 @@ class BookmarkController {
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
} catch (error) {
console.error('[bookmark] ❌ Failed to build bookmarks:', error)
console.error('[bookmark] ❌ Error details:', error instanceof Error ? error.message : String(error))
console.error('[bookmark] ❌ Stack:', error instanceof Error ? error.stack : 'no stack')
console.error('Failed to build bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
}
}
@@ -356,7 +328,6 @@ class BookmarkController {
const { relayPool, activeAccount, accountManager } = options
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
console.error('[bookmark] Invalid activeAccount')
return
}
@@ -366,7 +337,6 @@ class BookmarkController {
this.hydrationGeneration++
// Initialize loaders for this session
console.log('[bookmark] 🔧 Initializing EventLoader and AddressLoader with', RELAYS.length, 'relays')
this.eventLoader = createEventLoader(relayPool, {
eventStore: this.eventStore,
extraRelays: RELAYS
@@ -377,7 +347,6 @@ class BookmarkController {
})
this.setLoading(true)
console.log('[bookmark] 🔍 Starting bookmark load for', account.pubkey.slice(0, 8))
try {
// Get signer for auto-decryption
@@ -405,7 +374,6 @@ class BookmarkController {
// Add/update event
this.currentEvents.set(key, evt)
console.log('[bookmark] 📨 Event:', evt.kind, evt.id.slice(0, 8), 'encrypted:', hasEncryptedContent(evt))
// Emit raw event for Debug UI
this.emitRawEvent(evt)
@@ -415,12 +383,13 @@ class BookmarkController {
if (!isEncrypted) {
// For unencrypted events, build bookmarks immediately (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after event:', err))
.catch(() => {
// Silent error - will retry on next event
})
}
// Auto-decrypt if event has encrypted content (fire-and-forget, non-blocking)
if (isEncrypted) {
console.log('[bookmark] 🔓 Auto-decrypting event', evt.id.slice(0, 8))
// Don't await - let it run in background
collectBookmarksFromEvents([evt], account, signerCandidate)
.then(({ publicItemsAll, privateItemsAll, newestCreatedAt, latestContent, allTags }) => {
@@ -433,10 +402,6 @@ class BookmarkController {
latestContent,
allTags
})
console.log('[bookmark] ✅ Auto-decrypted:', evt.id.slice(0, 8), {
public: publicItemsAll.length,
private: privateItemsAll.length
})
// Emit decrypt complete for Debug UI
this.decryptCompleteListeners.forEach(cb =>
@@ -445,10 +410,12 @@ class BookmarkController {
// Rebuild bookmarks with newly decrypted content (progressive update)
this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
.catch(err => console.error('[bookmark] ❌ Failed to update after decrypt:', err))
.catch(() => {
// Silent error - will retry on next event
})
})
.catch((error) => {
console.error('[bookmark] ❌ Auto-decrypt failed:', evt.id.slice(0, 8), error)
.catch(() => {
// Silent error - decrypt failed
})
}
}
@@ -457,9 +424,8 @@ class BookmarkController {
// Final update after EOSE
await this.buildAndEmitBookmarks(maybeAccount, signerCandidate)
console.log('[bookmark] ✅ Bookmark load complete')
} catch (error) {
console.error('[bookmark] ❌ Failed to load bookmarks:', error)
console.error('Failed to load bookmarks:', error)
this.bookmarksListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)

View File

@@ -20,13 +20,16 @@ export interface BlogPostPreview {
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch posts from
* @param relayUrls - Array of relay URLs to query
* @param onPost - Optional callback for streaming posts
* @param limit - Limit for number of events to fetch (default: 100, pass null for no limit)
* @returns Array of blog post previews
*/
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void
onPost?: (post: BlogPostPreview) => void,
limit: number | null = 100
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -34,15 +37,19 @@ export const fetchBlogPostsFromAuthors = async (
return []
}
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors', limit ? `(limit: ${limit})` : '(no limit)')
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
const filter = limit !== null
? { kinds: [KINDS.BlogPost], authors: pubkeys, limit }
: { kinds: [KINDS.BlogPost], authors: pubkeys }
await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
filter,
{
relayUrls,
onEvent: (event: NostrEvent) => {

View File

@@ -4,12 +4,12 @@ import { queryEvents } from './dataFetch'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { ReadItem } from './readsService'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
/**
* Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:30078)
* - URLs with reading progress (kind:39802)
* - Manually marked as read URLs (kind:7, kind:17)
*/
export async function fetchLinks(
@@ -32,18 +32,18 @@ export async function fetchLinks(
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Links] Data fetched:', {
readingPositions: readingPositionEvents.length,
readingProgress: progressEvents.length,
markedAsRead: markedAsReadArticles.length
})
// Process reading positions and emit external items
processReadingPositions(readingPositionEvents, linksMap)
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {

View File

@@ -0,0 +1,139 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
type HighlightsCallback = (highlights: Highlight[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'nostrverse_highlights_last_synced'
class NostrverseHighlightsController {
private highlightsListeners: HighlightsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentHighlights: Highlight[] = []
private loaded = false
private generation = 0
onHighlights(cb: HighlightsCallback): () => void {
this.highlightsListeners.push(cb)
return () => {
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitHighlights(highlights: Highlight[]): void {
this.highlightsListeners.forEach(cb => cb(highlights))
}
getHighlights(): Highlight[] {
return [...this.currentHighlights]
}
isLoaded(): boolean {
return this.loaded
}
private getLastSyncedAt(): number | null {
try {
const raw = localStorage.getItem(LAST_SYNCED_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
return typeof parsed?.ts === 'number' ? parsed.ts : null
} catch {
return null
}
}
private setLastSyncedAt(timestamp: number): void {
try {
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts: timestamp }))
} catch { /* ignore */ }
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, force = false } = options
if (!force && this.loaded) {
this.emitHighlights(this.currentHighlights)
return
}
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] }
if (lastSyncedAt) filter.since = lastSyncedAt
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
eventStore.add(evt)
const highlight = eventToHighlight(evt)
highlightsMap.set(highlight.id, highlight)
const sorted = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sorted
this.emitHighlights(sorted)
}
}
)
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
const highlights = events.map(eventToHighlight)
const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values())
const sorted = sortHighlights(unique)
this.currentHighlights = sorted
this.loaded = true
this.emitHighlights(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(newest)
}
} catch (err) {
this.currentHighlights = []
this.emitHighlights(this.currentHighlights)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)
}
}
}
export const nostrverseHighlightsController = new NostrverseHighlightsController()

View File

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

View File

@@ -0,0 +1,169 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { KINDS } from '../config/kinds'
import { queryEvents } from './dataFetch'
import { BlogPostPreview } from './exploreService'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'nostrverse_writings_last_synced'
function toPreview(event: NostrEvent): BlogPostPreview {
return {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
}
function sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
return posts.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
class NostrverseWritingsController {
private writingsListeners: WritingsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentPosts: BlogPostPreview[] = []
private loaded = false
private generation = 0
onWritings(cb: WritingsCallback): () => void {
this.writingsListeners.push(cb)
return () => {
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitWritings(posts: BlogPostPreview[]): void {
this.writingsListeners.forEach(cb => cb(posts))
}
getWritings(): BlogPostPreview[] {
return [...this.currentPosts]
}
isLoaded(): boolean {
return this.loaded
}
private getLastSyncedAt(): number | null {
try {
const raw = localStorage.getItem(LAST_SYNCED_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
return typeof parsed?.ts === 'number' ? parsed.ts : null
} catch {
return null
}
}
private setLastSyncedAt(ts: number): void {
try { localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts })) } catch { /* ignore */ }
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, force = false } = options
if (!force && this.loaded) {
this.emitWritings(this.currentPosts)
return
}
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
try {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
const lastSyncedAt = force ? null : this.getLastSyncedAt()
const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.BlogPost] }
if (lastSyncedAt) filter.since = lastSyncedAt
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
eventStore.add(evt)
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const preview = toPreview(evt)
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, preview)
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.emitWritings(sorted)
}
}
}
)
if (currentGeneration !== this.generation) return
events.forEach(evt => eventStore.add(evt))
events.forEach(evt => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, toPreview(evt))
}
})
const sorted = sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.loaded = true
this.emitWritings(sorted)
if (sorted.length > 0) {
const newest = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(newest)
}
} catch {
this.currentPosts = []
this.emitWritings(this.currentPosts)
} finally {
if (currentGeneration === this.generation) this.setLoading(false)
}
}
}
export const nostrverseWritingsController = new NostrverseWritingsController()

View File

@@ -1,8 +1,9 @@
import { NostrEvent } from 'nostr-tools'
import { NostrEvent, nip19 } from 'nostr-tools'
import { ReadItem } from './readsService'
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
import { KINDS } from '../config/kinds'
const READING_POSITION_PREFIX = 'boris:reading-position:'
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85
interface ReadArticle {
id: string
@@ -13,44 +14,95 @@ interface ReadArticle {
}
/**
* Processes reading position events into ReadItems
* Processes reading progress events (kind 39802) into ReadItems
*
* Test scenarios:
* - Kind 39802 with d="30023:..." → article ReadItem with naddr id
* - Kind 39802 with d="url:..." → external ReadItem with decoded URL
* - Newer event.created_at overwrites older timestamp
* - Invalid d tag format → skip event
* - Malformed JSON content → skip event
*/
export function processReadingPositions(
export function processReadingProgress(
events: NostrEvent[],
readsMap: Map<string, ReadItem>
): void {
console.log('[progress] 🔧 processReadingProgress called with', events.length, 'events')
for (const event of events) {
if (event.kind !== READING_PROGRESS_KIND) {
console.log('[progress] ⏭️ Skipping event with wrong kind:', event.kind)
continue
}
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
if (!dTag) {
console.log('[progress] ⚠️ Event missing d-tag:', event.id.slice(0, 8))
continue
}
console.log('[progress] 📝 Processing event:', event.id.slice(0, 8), 'd-tag:', dTag.slice(0, 50))
try {
const positionData = JSON.parse(event.content)
const position = positionData.position
const timestamp = positionData.timestamp
const content = JSON.parse(event.content)
const position = content.progress || 0
console.log('[progress] 📊 Progress value:', position, '(' + Math.round(position * 100) + '%)')
// Validate progress is between 0 and 1 (NIP-85 requirement)
if (position < 0 || position > 1) {
console.warn('[progress] ❌ Invalid progress value (must be 0-1):', position, 'event:', event.id.slice(0, 8))
continue
}
// Use event.created_at as authoritative timestamp (NIP-85 spec)
const timestamp = event.created_at
let itemId: string
let itemUrl: string | undefined
let itemType: 'article' | 'external' = 'external'
// Check if it's a nostr article (naddr format)
if (identifier.startsWith('naddr1')) {
itemId = identifier
itemType = 'article'
} else {
// It's a base64url-encoded URL
try {
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl
itemType = 'external'
} catch (e) {
console.warn('Failed to decode URL identifier:', identifier)
// Check if d tag is a coordinate (30023:pubkey:identifier)
if (dTag.startsWith('30023:')) {
// It's a nostr article coordinate
const parts = dTag.split(':')
if (parts.length === 3) {
// Convert to naddr for consistency with the rest of the app
try {
const naddr = nip19.naddrEncode({
kind: parseInt(parts[0]),
pubkey: parts[1],
identifier: parts[2]
})
itemId = naddr
itemType = 'article'
console.log('[progress] ✅ Converted coordinate to naddr:', naddr.slice(0, 50))
} catch (e) {
console.warn('[progress] ❌ Failed to encode naddr from coordinate:', dTag)
continue
}
} else {
console.warn('[progress] ⚠️ Invalid coordinate format:', dTag)
continue
}
} else if (dTag.startsWith('url:')) {
// It's a URL with base64url encoding
const encoded = dTag.replace('url:', '')
try {
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
itemId = itemUrl
itemType = 'external'
console.log('[progress] ✅ Decoded URL:', itemUrl.slice(0, 50))
} catch (e) {
console.warn('[progress] ❌ Failed to decode URL from d tag:', dTag)
continue
}
} else {
console.warn('[progress] ⚠️ Unknown d-tag format:', dTag)
continue
}
// Add or update the item
// Add or update the item, preferring newer timestamps
const existing = readsMap.get(itemId)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
readsMap.set(itemId, {
@@ -62,11 +114,16 @@ export function processReadingPositions(
readingProgress: position,
readingTimestamp: timestamp
})
console.log('[progress] ✅ Added/updated item in readsMap:', itemId.slice(0, 50), '=', Math.round(position * 100) + '%')
} else {
console.log('[progress] ⏭️ Skipping older event for:', itemId.slice(0, 50))
}
} catch (error) {
console.warn('Failed to parse reading position:', error)
console.warn('[progress] ❌ Failed to parse reading progress event:', error)
}
}
console.log('[progress] 🏁 processReadingProgress finished, readsMap size:', readsMap.size)
}
/**

View File

@@ -1,13 +1,13 @@
import { IEventStore, mapEventsToStore } from 'applesauce-core'
import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { NostrEvent, nip19 } from 'nostr-tools'
import { firstValueFrom } from 'rxjs'
import { publishEvent } from './writeService'
import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
const APP_DATA_KIND = 30078 // NIP-78 Application Data
const READING_POSITION_PREFIX = 'boris:reading-position:'
const READING_PROGRESS_KIND = KINDS.ReadingProgress // 39802 - NIP-85 Reading Progress
export interface ReadingPosition {
position: number // 0-1 scroll progress
@@ -15,16 +15,83 @@ export interface ReadingPosition {
scrollTop?: number // Optional: pixel position
}
// Helper to extract and parse reading position from an event
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
export interface ReadingProgressContent {
progress: number // 0-1 scroll progress
ts?: number // Unix timestamp (optional, for display)
loc?: number // Optional: pixel position
ver?: string // Schema version
}
// Helper to extract and parse reading progress from event (kind 39802)
function getReadingProgressContent(event: NostrEvent): ReadingPosition | undefined {
if (!event.content || event.content.length === 0) return undefined
try {
return JSON.parse(event.content) as ReadingPosition
const content = JSON.parse(event.content) as ReadingProgressContent
return {
position: content.progress,
timestamp: content.ts || event.created_at,
scrollTop: content.loc
}
} catch {
return undefined
}
}
// Generate d tag for kind 39802 based on target
// Test cases:
// - naddr1... → "30023:<pubkey>:<identifier>"
// - https://example.com/post → "url:<base64url>"
// - Invalid naddr → "url:<base64url>" (fallback)
function generateDTag(naddrOrUrl: string): string {
// If it's a nostr article (naddr format), decode and build coordinate
if (naddrOrUrl.startsWith('naddr1')) {
try {
const decoded = nip19.decode(naddrOrUrl)
if (decoded.type === 'naddr') {
const dTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
console.log('[progress] 📋 Generated d-tag from naddr:', {
naddr: naddrOrUrl.slice(0, 50) + '...',
dTag: dTag.slice(0, 80) + '...'
})
return dTag
}
} catch (e) {
console.warn('Failed to decode naddr:', naddrOrUrl)
}
}
// For URLs, use url: prefix with base64url encoding
const base64url = btoa(naddrOrUrl)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `url:${base64url}`
}
// Generate tags for kind 39802 event
function generateProgressTags(naddrOrUrl: string): string[][] {
const dTag = generateDTag(naddrOrUrl)
const tags: string[][] = [['d', dTag]]
// Add 'a' tag for nostr articles
if (naddrOrUrl.startsWith('naddr1')) {
try {
const decoded = nip19.decode(naddrOrUrl)
if (decoded.type === 'naddr') {
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier || ''}`
tags.push(['a', coordinate])
}
} catch (e) {
// Ignore decode errors
}
} else {
// Add 'r' tag for URLs
tags.push(['r', naddrOrUrl])
}
return tags
}
/**
* Generate a unique identifier for an article
* For Nostr articles: use the naddr directly
@@ -43,7 +110,7 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
}
/**
* Save reading position to Nostr (Kind 30078)
* Save reading position to Nostr (kind 39802)
*/
export async function saveReadingPosition(
relayPool: RelayPool,
@@ -52,36 +119,57 @@ export async function saveReadingPosition(
articleIdentifier: string,
position: ReadingPosition
): Promise<void> {
console.log('💾 [ReadingPosition] Saving position:', {
identifier: articleIdentifier.slice(0, 32) + '...',
console.log('[progress] 💾 saveReadingPosition: Starting save:', {
identifier: articleIdentifier.slice(0, 50) + '...',
position: position.position,
positionPercent: Math.round(position.position * 100) + '%',
timestamp: position.timestamp,
scrollTop: position.scrollTop
})
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
const now = Math.floor(Date.now() / 1000)
const progressContent: ReadingProgressContent = {
progress: position.position,
ts: position.timestamp,
loc: position.scrollTop,
ver: '1'
}
const tags = generateProgressTags(articleIdentifier)
console.log('[progress] 📝 Creating event with:', {
kind: READING_PROGRESS_KIND,
content: progressContent,
tags: tags.map(t => `[${t.join(', ')}]`).join(', '),
created_at: now
})
const draft = await factory.create(async () => ({
kind: APP_DATA_KIND,
content: JSON.stringify(position),
tags: [
['d', dTag],
['client', 'boris']
],
created_at: Math.floor(Date.now() / 1000)
kind: READING_PROGRESS_KIND,
content: JSON.stringify(progressContent),
tags,
created_at: now
}))
console.log('[progress] ✍️ Signing event...')
const signed = await factory.sign(draft)
// Use unified write service
console.log('[progress] 📡 Publishing event:', {
id: signed.id,
kind: signed.kind,
pubkey: signed.pubkey.slice(0, 8) + '...',
content: signed.content,
tags: signed.tags
})
await publishEvent(relayPool, eventStore, signed)
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
console.log('[progress] ✅ Event published successfully, ID:', signed.id.slice(0, 16))
}
/**
* Load reading position from Nostr
* Load reading position from Nostr (kind 39802)
*/
export async function loadReadingPosition(
relayPool: RelayPool,
@@ -89,32 +177,32 @@ export async function loadReadingPosition(
pubkey: string,
articleIdentifier: string
): Promise<ReadingPosition | null> {
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
const dTag = generateDTag(articleIdentifier)
console.log('📖 [ReadingPosition] Loading position:', {
console.log('📖 [ReadingProgress] Loading position:', {
pubkey: pubkey.slice(0, 8) + '...',
identifier: articleIdentifier.slice(0, 32) + '...',
dTag: dTag.slice(0, 50) + '...'
})
// First, check if we already have the position in the local event store
// Check local event store first
try {
const localEvent = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
)
if (localEvent) {
const content = getReadingPositionContent(localEvent)
const content = getReadingProgressContent(localEvent)
if (content) {
console.log('✅ [ReadingPosition] Loaded from local store:', {
console.log('✅ [ReadingProgress] Loaded from local store:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
// Still fetch from relays in the background to get any updates
// Fetch from relays in background to get any updates
relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
kinds: [READING_PROGRESS_KIND],
authors: [pubkey],
'#d': [dTag]
})
@@ -125,23 +213,49 @@ export async function loadReadingPosition(
}
}
} catch (err) {
console.log('📭 No cached reading position found, fetching from relays...')
console.log('📭 No cached reading progress found, fetching from relays...')
}
// If not in local store, fetch from relays
// Fetch from relays
const result = await fetchFromRelays(
relayPool,
eventStore,
pubkey,
READING_PROGRESS_KIND,
dTag,
getReadingProgressContent
)
if (result) {
console.log('✅ [ReadingProgress] Loaded from relays')
return result
}
console.log('📭 No reading progress found')
return null
}
// Helper function to fetch from relays with timeout
async function fetchFromRelays(
relayPool: RelayPool,
eventStore: IEventStore,
pubkey: string,
kind: number,
dTag: string,
parser: (event: NostrEvent) => ReadingPosition | undefined
): Promise<ReadingPosition | null> {
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
console.log('⏱️ Reading position load timeout - no position found')
hasResolved = true
resolve(null)
}
}, 3000) // Shorter timeout for reading positions
}, 3000)
const sub = relayPool
.subscription(RELAYS, {
kinds: [APP_DATA_KIND],
kinds: [kind],
authors: [pubkey],
'#d': [dTag]
})
@@ -153,33 +267,20 @@ export async function loadReadingPosition(
hasResolved = true
try {
const event = await firstValueFrom(
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
eventStore.replaceable(kind, pubkey, dTag)
)
if (event) {
const content = getReadingPositionContent(event)
if (content) {
console.log('✅ [ReadingPosition] Loaded from relays:', {
position: content.position,
positionPercent: Math.round(content.position * 100) + '%',
timestamp: content.timestamp
})
resolve(content)
} else {
console.log('⚠️ [ReadingPosition] Event found but no valid content')
resolve(null)
}
const content = parser(event)
resolve(content || null)
} else {
console.log('📭 [ReadingPosition] No position found on relays')
resolve(null)
}
} catch (err) {
console.error('❌ Error loading reading position:', err)
resolve(null)
}
}
},
error: (err) => {
console.error('❌ Reading position subscription error:', err)
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true

View File

@@ -0,0 +1,299 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Filter, NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
type ProgressMapCallback = (progressMap: Map<string, number>) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'reading_progress_last_synced'
const PROGRESS_CACHE_KEY = 'reading_progress_cache_v1'
/**
* Shared reading progress controller
* Manages the user's reading progress (kind:39802) centrally
*/
class ReadingProgressController {
private progressListeners: ProgressMapCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentProgressMap: Map<string, number> = new Map()
private lastLoadedPubkey: string | null = null
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
onProgress(cb: ProgressMapCallback): () => void {
this.progressListeners.push(cb)
return () => {
this.progressListeners = this.progressListeners.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 emitProgress(progressMap: Map<string, number>): void {
console.log('[progress] 📡 Emitting to', this.progressListeners.length, 'listeners with', progressMap.size, 'items')
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
}
/**
* Get current reading progress map without triggering a reload
*/
getProgressMap(): Map<string, number> {
return new Map(this.currentProgressMap)
}
/**
* Load cached progress from localStorage for a pubkey
*/
private loadCachedProgress(pubkey: string): Map<string, number> {
try {
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
if (!raw) return new Map()
const parsed = JSON.parse(raw) as Record<string, Record<string, number>>
const forUser = parsed[pubkey] || {}
return new Map(Object.entries(forUser))
} catch {
return new Map()
}
}
/**
* Save current progress map to localStorage for the active pubkey
*/
private persistProgress(pubkey: string, progressMap: Map<string, number>): void {
try {
const raw = localStorage.getItem(PROGRESS_CACHE_KEY)
const parsed: Record<string, Record<string, number>> = raw ? JSON.parse(raw) : {}
parsed[pubkey] = Object.fromEntries(progressMap.entries())
localStorage.setItem(PROGRESS_CACHE_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[progress] ⚠️ Failed to persist reading progress cache:', err)
}
}
/**
* Get progress for a specific article by naddr
*/
getProgress(naddr: string): number | undefined {
return this.currentProgressMap.get(naddr)
}
/**
* Check if reading progress is loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
// Unsubscribe from any active timeline subscription
if (this.timelineSubscription) {
try {
this.timelineSubscription.unsubscribe()
} catch (err) {
console.warn('[progress] ⚠️ Failed to unsubscribe timeline on reset:', err)
}
this.timelineSubscription = null
}
this.currentProgressMap = new Map()
this.lastLoadedPubkey = null
this.emitProgress(this.currentProgressMap)
}
/**
* 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 updateLastSyncedAt(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('Failed to update last synced timestamp:', err)
}
}
/**
* Load and watch reading progress for a user
*/
async start(params: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = params
const startGeneration = this.generation
// Skip if already loaded for this pubkey and not forcing
if (!force && this.isLoadedFor(pubkey)) {
console.log('📊 [ReadingProgress] Already loaded for', pubkey.slice(0, 8))
return
}
console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '')
this.setLoading(true)
this.lastLoadedPubkey = pubkey
try {
// Seed from local cache immediately (survives refresh/flight mode)
const cached = this.loadCachedProgress(pubkey)
if (cached.size > 0) {
console.log('📊 [ReadingProgress] Seeded from cache:', cached.size, 'items')
this.currentProgressMap = cached
this.emitProgress(this.currentProgressMap)
}
// Subscribe to local timeline for immediate and reactive updates
// Clean up any previous subscription first
if (this.timelineSubscription) {
try {
this.timelineSubscription.unsubscribe()
} catch (err) {
console.warn('[progress] ⚠️ Failed to unsubscribe previous timeline:', err)
}
this.timelineSubscription = null
}
const timeline$ = eventStore.timeline({
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
})
const generationAtSubscribe = this.generation
this.timelineSubscription = timeline$.subscribe((localEvents: NostrEvent[]) => {
// Ignore if controller generation has changed (e.g., logout/login)
if (generationAtSubscribe !== this.generation) return
if (!Array.isArray(localEvents) || localEvents.length === 0) return
console.log('📊 [ReadingProgress] Timeline update with', localEvents.length, 'event(s)')
this.processEvents(localEvents)
})
// Query events from relays
// Force full sync if map is empty (first load) or if explicitly forced
const needsFullSync = force || this.currentProgressMap.size === 0
const lastSynced = needsFullSync ? null : this.getLastSyncedAt(pubkey)
const filter: Filter = {
kinds: [KINDS.ReadingProgress],
authors: [pubkey]
}
if (lastSynced && !needsFullSync) {
filter.since = lastSynced
console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString())
} else {
console.log('📊 [ReadingProgress] Full sync (map size:', this.currentProgressMap.size + ')')
}
const relayEvents = await queryEvents(relayPool, filter, { relayUrls: RELAYS })
if (startGeneration !== this.generation) {
console.log('📊 [ReadingProgress] Cancelled (generation changed)')
return
}
if (relayEvents.length > 0) {
// Add to event store
relayEvents.forEach(e => eventStore.add(e))
// Process and emit (merge with existing)
this.processEvents(relayEvents)
console.log('📊 [ReadingProgress] Loaded', relayEvents.length, 'events from relays')
// Update last synced
const now = Math.floor(Date.now() / 1000)
this.updateLastSyncedAt(pubkey, now)
} else {
console.log('📊 [ReadingProgress] No new events from relays')
}
} catch (err) {
console.error('📊 [ReadingProgress] Failed to load:', err)
} finally {
if (startGeneration === this.generation) {
this.setLoading(false)
}
}
}
/**
* Process events and update progress map
*/
private processEvents(events: NostrEvent[]): void {
console.log('[progress] 🔄 Processing', events.length, 'events')
const readsMap = new Map<string, ReadItem>()
// Merge with existing progress
for (const [id, progress] of this.currentProgressMap.entries()) {
readsMap.set(id, {
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress
})
}
console.log('[progress] 📦 Starting with', readsMap.size, 'existing items')
// Process new events
processReadingProgress(events, readsMap)
console.log('[progress] 📦 After processing:', readsMap.size, 'items')
// Convert back to progress map (naddr -> progress)
const newProgressMap = new Map<string, number>()
for (const [id, item] of readsMap.entries()) {
if (item.readingProgress !== undefined && item.type === 'article') {
newProgressMap.set(id, item.readingProgress)
console.log('[progress] ✅ Added:', id.slice(0, 50) + '...', '=', Math.round(item.readingProgress * 100) + '%')
}
}
console.log('[progress] 📊 Final progress map size:', newProgressMap.size)
this.currentProgressMap = newProgressMap
this.emitProgress(this.currentProgressMap)
// Persist for current user so it survives refresh/flight mode
if (this.lastLoadedPubkey) {
this.persistProgress(this.lastLoadedPubkey, this.currentProgressMap)
}
}
}
export const readingProgressController = new ReadingProgressController()

View File

@@ -8,7 +8,7 @@ import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -37,7 +37,7 @@ export interface ReadItem {
/**
* Fetches all reads from multiple sources:
* - Bookmarked articles (kind:30023) and article/website URLs
* - Articles/URLs with reading progress (kind:30078)
* - Articles/URLs with reading progress (kind:39802)
* - Manually marked as read articles/URLs (kind:7, kind:17)
*/
export async function fetchAllReads(
@@ -61,19 +61,19 @@ export async function fetchAllReads(
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
const [progressEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.ReadingProgress], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Reads] Data fetched:', {
readingPositions: readingPositionEvents.length,
readingProgress: progressEvents.length,
markedAsRead: markedAsReadArticles.length,
bookmarks: bookmarks.length
})
// Process reading positions and emit items
processReadingPositions(readingPositionEvents, readsMap)
// Process reading progress events (kind 39802)
processReadingProgress(progressEvents, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)

View File

@@ -60,6 +60,9 @@ export interface UserSettings {
paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
autoMarkAsReadOnCompletion?: boolean // default: false (opt-in)
// Bookmark filtering
hideBookmarksWithoutCreationDate?: boolean // default: false
}
export async function loadSettings(

View File

@@ -14,9 +14,12 @@ export async function publishEvent(
eventStore: IEventStore,
event: NostrEvent
): Promise<void> {
const isProgressEvent = event.kind === 39802
const logPrefix = isProgressEvent ? '[progress]' : ''
// Store the event in the local EventStore FIRST for immediate UI display
eventStore.add(event)
console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`)
console.log(`${logPrefix} 💾 Stored event in EventStore:`, event.id.slice(0, 8), `(kind ${event.kind})`)
// Check current connection status - are we online or in flight mode?
const connectedRelays = Array.from(relayPool.relays.values())
@@ -32,12 +35,13 @@ export async function publishEvent(
const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays)
console.log('📍 Event relay status:', {
console.log(`${logPrefix} 📍 Event relay status:`, {
targetRelays: RELAYS.length,
expectedSuccessRelays: expectedSuccessRelays.length,
isLocalOnly,
hasRemoteConnection,
eventId: event.id.slice(0, 8)
eventId: event.id.slice(0, 8),
connectedRelays: connectedRelays.length
})
// If we're in local-only mode, mark this event for later sync
@@ -46,12 +50,13 @@ export async function publishEvent(
}
// Publish to all configured relays in the background (non-blocking)
console.log(`${logPrefix} 📤 Publishing to relays:`, RELAYS)
relayPool.publish(RELAYS, event)
.then(() => {
console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8))
console.log(`${logPrefix} ✅ Event published to`, RELAYS.length, 'relay(s):', event.id.slice(0, 8))
})
.catch((error) => {
console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error)
console.warn(`${logPrefix} ⚠️ Failed to publish event to relays (event still saved locally):`, error)
// Surface common bunker signing errors for debugging
if (error instanceof Error && error.message.includes('permission')) {

View File

@@ -0,0 +1,250 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore, Helpers } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { KINDS } from '../config/kinds'
import { queryEvents } from './dataFetch'
import { BlogPostPreview } from './exploreService'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
type WritingsCallback = (posts: BlogPostPreview[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'writings_last_synced'
/**
* Shared writings controller
* Manages the user's nostr-native long-form articles (kind:30023) centrally,
* similar to highlightsController
*/
class WritingsController {
private writingsListeners: WritingsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentPosts: BlogPostPreview[] = []
private lastLoadedPubkey: string | null = null
private generation = 0
onWritings(cb: WritingsCallback): () => void {
this.writingsListeners.push(cb)
return () => {
this.writingsListeners = this.writingsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitWritings(posts: BlogPostPreview[]): void {
this.writingsListeners.forEach(cb => cb(posts))
}
/**
* Get current writings without triggering a reload
*/
getWritings(): BlogPostPreview[] {
return [...this.currentPosts]
}
/**
* Check if writings are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentPosts.length >= 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
this.currentPosts = []
this.lastLoadedPubkey = null
this.emitWritings(this.currentPosts)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[writings] Failed to save last synced timestamp:', err)
}
}
/**
* Convert NostrEvent to BlogPostPreview using applesauce Helpers
*/
private toPreview(event: NostrEvent): BlogPostPreview {
return {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
}
/**
* Sort posts by published/created date (most recent first)
*/
private sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] {
return posts.slice().sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
}
/**
* Load writings for a user (kind:30023)
* Streams results and stores in event store
*/
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitWritings(this.currentPosts)
return
}
// Increment generation to cancel any in-flight work
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8))
try {
const seenIds = new Set<string>()
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
kinds: [KINDS.BlogPost],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
}
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
// Check if this generation is still active
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
// Store in event store immediately
eventStore.add(evt)
// Dedupe by replaceable key (author + d-tag)
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const preview = this.toPreview(evt)
const existing = uniqueByReplaceable.get(key)
// Keep the newest version for replaceable events
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, preview)
// Stream to listeners
const sortedPosts = this.sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sortedPosts
this.emitWritings(sortedPosts)
}
}
}
)
// Check if still active after async operation
if (currentGeneration !== this.generation) {
console.log('[writings] ⚠️ Load cancelled (generation mismatch)')
return
}
// Store all events in event store
events.forEach(evt => eventStore.add(evt))
// Final processing - ensure we have the latest version of each replaceable
events.forEach(evt => {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${evt.pubkey}:${dTag}`
const existing = uniqueByReplaceable.get(key)
if (!existing || evt.created_at > existing.event.created_at) {
uniqueByReplaceable.set(key, this.toPreview(evt))
}
})
const sorted = this.sortPosts(Array.from(uniqueByReplaceable.values()))
this.currentPosts = sorted
this.lastLoadedPubkey = pubkey
this.emitWritings(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
console.log('[writings] ✅ Loaded', sorted.length, 'writings')
} catch (error) {
console.error('[writings] ❌ Failed to load writings:', error)
this.currentPosts = []
this.emitWritings(this.currentPosts)
} finally {
// Only clear loading if this generation is still active
if (currentGeneration === this.generation) {
this.setLoading(false)
}
}
}
}
// Singleton instance
export const writingsController = new WritingsController()

View File

@@ -118,6 +118,16 @@ export function hasContent(bookmark: IndividualBookmark): boolean {
return hasValidContent || hasId
}
// Check if bookmark has a real creation date (not "Now" / current time)
export function hasCreationDate(bookmark: IndividualBookmark): boolean {
if (!bookmark.created_at) return false
// If timestamp is missing or equals current time (within 1 second), consider it invalid
const now = Math.floor(Date.now() / 1000)
const createdAt = Math.floor(bookmark.created_at)
// If created_at is within 1 second of now, it's likely missing/placeholder
return Math.abs(createdAt - now) > 1
}
// Bookmark sets helpers (kind 30003)
export interface BookmarkSet {
name: string

View File

@@ -1,4 +1,6 @@
// Extract pubkeys from nprofile strings in content
import { READING_PROGRESS } from '../config/kinds'
export const extractNprofilePubkeys = (content: string): string[] => {
const nprofileRegex = /nprofile1[a-z0-9]+/gi
const matches = content.match(nprofileRegex) || []
@@ -123,3 +125,14 @@ export function createParallelReqStreams(
return { local$, remote$ }
}
/**
* Checks if content is long enough to track reading progress
* Minimum 1000 characters (roughly 150 words)
*/
export const shouldTrackReadingProgress = (html: string | undefined, markdown: string | undefined): boolean => {
const content = (html || markdown || '').trim()
// Strip HTML tags to get character count
const plainText = content.replace(/<[^>]*>/g, '').trim()
return plainText.length >= READING_PROGRESS.MIN_CONTENT_LENGTH
}

View File

@@ -1,16 +1,58 @@
import { ReadItem } from '../services/readsService'
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
import { Highlight } from '../types/highlights'
import { nip19 } from 'nostr-tools'
/**
* Filters ReadItems by reading progress
*/
export function filterByReadingProgress(
items: ReadItem[],
filter: ReadingProgressFilterType
filter: ReadingProgressFilterType,
highlights?: Highlight[]
): ReadItem[] {
// Build a map of article references to highlight count
// Normalize both coordinate and naddr formats for matching
const articleHighlightCount = new Map<string, number>()
if (highlights) {
highlights.forEach(h => {
if (h.eventReference) {
// eventReference could be a hex ID or a coordinate (30023:pubkey:identifier)
let normalizedRef = h.eventReference
// If it's a coordinate, convert to naddr format for matching
if (h.eventReference.includes(':')) {
const parts = h.eventReference.split(':')
if (parts.length === 3) {
const [kind, pubkey, identifier] = parts
try {
normalizedRef = nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier
})
} catch {
// If conversion fails, use the original reference
normalizedRef = h.eventReference
}
}
}
const count = articleHighlightCount.get(normalizedRef) || 0
articleHighlightCount.set(normalizedRef, count + 1)
}
if (h.urlReference) {
const count = articleHighlightCount.get(h.urlReference) || 0
articleHighlightCount.set(h.urlReference, count + 1)
}
})
}
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
switch (filter) {
case 'unopened':
@@ -21,6 +63,8 @@ export function filterByReadingProgress(
return progress > 0.10 && progress <= 0.94 && !isMarked
case 'completed':
return progress >= 0.95 || isMarked
case 'highlighted':
return hasHighlights
case 'all':
default:
return true

View File

@@ -0,0 +1,15 @@
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from '../services/exploreService'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export const toBlogPostPreview = (event: NostrEvent): BlogPostPreview => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
})