Compare commits

...

84 Commits

Author SHA1 Message Date
Gigi
b69a956247 chore(release): bump version to 0.6.23 2025-10-16 19:54:35 +02:00
Gigi
82a8dcf6eb chore(settings): link short commit hash to GitHub and remove timestamp/branch 2025-10-16 19:35:20 +02:00
Gigi
8e19e22289 feat(settings): display app version and git commit in settings footer 2025-10-16 19:32:18 +02:00
Gigi
e167b57810 fix(api): align article-og relay usage to RelayPool.request and remove open/close 2025-10-16 19:20:54 +02:00
Gigi
ba3b82e6b5 chore(app): add RouteDebug gated by ?debug=1 to log route state 2025-10-16 19:19:33 +02:00
Gigi
b5edfbb2c9 chore(api): add structured debug logs to article-og handler with ?debug=1 2025-10-16 19:17:12 +02:00
Gigi
48048f877a fix(vercel): limit /a/:naddr rewrite to bots 2025-10-16 19:16:29 +02:00
Gigi
bd1afc54c3 docs: update CHANGELOG.md for v0.6.22 2025-10-16 16:02:02 +02:00
Gigi
a2c4bed0f5 chore: bump version to 0.6.22 2025-10-16 16:01:19 +02:00
Gigi
9bad49fe5f feat(vercel): add rewrite rule for article OG endpoint
Route /a/:naddr requests to /api/article-og for dynamic social preview tags.
2025-10-16 16:00:36 +02:00
Gigi
2aa6536496 Merge pull request #17 from dergigi/social-preview
Add dynamic social preview for article deep-links
2025-10-16 15:58:52 +02:00
Gigi
bd6d8a0342 chore(api): remove debug logging from article-og endpoint 2025-10-16 15:50:00 +02:00
Gigi
dc8e86bc57 fix(api): use history.replaceState before redirecting to SPA
Set the browser history to /a/{naddr} before redirecting to /, so when the SPA loads it sees the correct URL path.
2025-10-16 15:41:22 +02:00
Gigi
32b843908e debug: add logging and debug endpoint to article-og
Add console logging for debugging and ?debug=1 query param to see request details in browser.
2025-10-16 15:34:50 +02:00
Gigi
5a71480459 fix(api): add base tag for proper asset loading
Use named parameter syntax in Vercel rewrite and add <base href="/"> tag to ensure assets load correctly from root when serving index.html through the API.
2025-10-16 15:27:13 +02:00
Gigi
17455aa47b fix(api): serve index.html to browsers with preserved URL
Instead of redirecting, serve the static index.html file directly. The Vercel rewrite preserves the /a/{naddr} URL, allowing client-side SPA routing to work correctly.
2025-10-16 15:20:10 +02:00
Gigi
4cc32c27de fix(api): detect crawlers and redirect browsers to SPA
Browsers get 302 redirect to / where the SPA handles routing client-side with the original /a/{naddr} URL preserved. Crawlers/bots get the full HTML with OG meta tags.
2025-10-16 14:43:29 +02:00
Gigi
99bfe209a5 fix(api): use meta refresh instead of SPA boot in OG endpoint
Browsers will immediately redirect to / and load the SPA client-side, while crawlers/bots ignore meta refresh and only see the OG meta tags.
2025-10-16 14:38:17 +02:00
Gigi
0a28bfbd50 fix(api): replace any type with Filter from nostr-tools 2025-10-16 14:32:35 +02:00
Gigi
ba9fb109f6 refactor(api): DRY improvements for article OG endpoint
- Extract fetchEventsFromRelays helper to eliminate duplication
- Add setCacheHeaders helper for consistent header setting
- Parallelize article and profile fetching for faster response
- Move relayPool.close() to finally block to prevent leaks
- Remove redundant cacheKey variable and sorting
2025-10-16 14:31:39 +02:00
Gigi
ec9d2fcb49 chore(meta): add social preview image to homepage OG tags 2025-10-16 14:23:44 +02:00
Gigi
f841043e03 chore(assets): add default social preview image (1200x630) 2025-10-16 14:22:04 +02:00
Gigi
94dc95e1f0 feat(api): dynamic OG HTML for /a/{naddr} using relay metadata 2025-10-16 14:21:49 +02:00
Gigi
32a5145d8f chore(vercel): route /a/* to article OG handler 2025-10-16 14:20:58 +02:00
Gigi
a856e8ca26 docs: update CHANGELOG.md for v0.6.21 2025-10-16 09:57:13 +02:00
Gigi
d54306cf92 chore: bump version to 0.6.21 2025-10-16 09:56:06 +02:00
Gigi
9fdb96b64e Merge pull request #16 from dergigi/reading-progress-filters-part-two
feat: add reading progress filters and reads/links tabs
2025-10-16 09:55:32 +02:00
Gigi
c50aa3a243 fix: resolve TypeScript errors from merge
- Remove unused readingPositions and markedAsReadIds from useBookmarksData
- Remove eventStore parameter from useBookmarksData call
- Add reads and links fields to MeCache interface
2025-10-16 09:53:20 +02:00
Gigi
adef1a922c chore: remove completed plan file 2025-10-16 09:49:43 +02:00
Gigi
99df4d6761 chore: merge master into reading-progress-filters-part-two
Resolved conflicts by keeping feature branch changes:
- Kept /me/reads and /me/links routes (not /me/archive)
- Kept ReadingProgressFilters component and readingProgressUtils
- Kept readsService, linksService, and readingDataProcessor
- Restored files that were renamed/deleted in master
2025-10-16 09:49:13 +02:00
Gigi
5f6a414953 fix: resolve all linter errors and type issues
- Remove unused state variables (readsMap, linksMap) by using only setters
- Move VALID_FILTERS constant outside component to fix exhaustive-deps warning
- Remove unused isReading variable in ReadingProgressIndicator
- Remove unused extractUrlFromBookmark function and IndividualBookmark import
- Fix type errors in linksFromBookmarks by extracting metadata from tags instead of non-existent properties
2025-10-16 09:36:17 +02:00
Gigi
ed17a68986 refactor: simplify filter icon colors to blue (except green for completed) 2025-10-16 09:33:04 +02:00
Gigi
bedf3daed1 feat: add URL routing for reading progress filters 2025-10-16 09:32:30 +02:00
Gigi
2c913cf7e8 feat: color reading progress filter icons when active 2025-10-16 09:30:16 +02:00
Gigi
aff5bff03b refactor: use neutral text color for 'started' reading progress state 2025-10-16 09:29:41 +02:00
Gigi
e90f902f0b feat: add amber color for 'started' reading progress state (0-10%) 2025-10-16 09:28:06 +02:00
Gigi
d763aa5f15 fix: merge reading progress even when timestamp is older than bookmark 2025-10-16 09:20:24 +02:00
Gigi
9d6b1f6f84 fix: call onItem callback directly for items already in reads map 2025-10-16 09:18:32 +02:00
Gigi
9eb2f35dbf debug: add console logging to trace reading progress enrichment 2025-10-16 09:13:34 +02:00
Gigi
5f33ad3ba0 fix(reads): use setState callback pattern for background enrichment
- Replace closure over tempMap with setState callback pattern
- Ensures we always work with latest state when merging progress
- Prevents stale closure issues that block state updates
- Apply same fix to both reads and links tabs
- Fixes reading progress not updating in UI
2025-10-16 09:13:19 +02:00
Gigi
3db4855532 fix(reads): use naddr format for IDs to match reading positions
- Convert bookmark coordinates to naddr format in deriveReadsFromBookmarks
- Reading positions store progress with naddr as ID
- Using naddr format enables proper merging of reading progress data
- Simplify getReadItemUrl to use item.id directly (already naddr)
- Fixes reading progress not showing in /me/reads tab
2025-10-16 09:11:21 +02:00
Gigi
3305be1da5 feat(reads): extract image, summary, and published date from bookmark tags
- Extract metadata from tags same way BookmarkItem does (DRY)
- Add image tag extraction for article images
- Add summary tag extraction for article summaries
- Add published_at tag extraction for publish dates
- Images and summaries now display in /me/reads tab
2025-10-16 09:08:57 +02:00
Gigi
fe55e87496 fix: remove unused import from readsFromBookmarks 2025-10-16 09:06:06 +02:00
Gigi
f78f1a3460 fix(reads): use bookmark.content for article titles
- IndividualBookmark doesn't have separate title/event fields
- After hydration, article titles are stored in content field
- Simplified extraction logic to just use bookmark.content
2025-10-16 09:06:00 +02:00
Gigi
e73d89739b fix(reads): extract article titles from events using applesauce helpers
- Use getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished from Helpers
- Extract metadata from bookmark.event when available
- Fallback to bookmark fields if event not hydrated
- Fixes 'Untitled' articles in Reads tab
2025-10-16 09:01:51 +02:00
Gigi
7e2b4b46c9 feat(me): populate reads/links from bookmarks instantly
- Add deriveReadsFromBookmarks helper to convert 30023 bookmarks to ReadItems
- Add deriveLinksFromBookmarks helper for web bookmarks (39701) and URLs
- Update loadReadsTab to show bookmarked articles immediately, enrich in background
- Update loadLinksTab to show bookmarked links immediately, enrich in background
- Background enrichment merges reading progress only for displayed items
- Preserve existing pull-to-refresh and empty state logic
2025-10-16 08:45:31 +02:00
Gigi
fddf79e0c6 feat: add named kind constants, streaming updates, and fix reads/links tabs
- Create src/config/kinds.ts with named Nostr kind constants
- Add streaming support to fetchAllReads and fetchLinks with onItem callbacks
- Update all services to use KINDS constants instead of magic numbers
- Add mergeReadItem utility for DRY state management
- Add fallbackTitleFromUrl for external links without titles
- Relax validation to allow external items without titles
- Update Me.tsx to use streaming with Map-based state for reads/links
- Fix refresh to merge new data instead of clearing state
- Fix empty states for Reads and Links tabs (no more infinite skeletons)
- Services updated: readsService, linksService, libraryService, bookmarkService, exploreService, highlights/fetchByAuthor
2025-10-16 08:27:10 +02:00
Gigi
cf2098a723 Merge pull request #15 from dergigi/revert-14-reading-progress-filters
Revert "Add reading progress filters and split Reads/Links tabs"
2025-10-16 08:06:06 +02:00
Gigi
5568437663 Revert "Add reading progress filters and split Reads/Links tabs" 2025-10-16 08:05:20 +02:00
Gigi
7bfd7fdf6c Merge pull request #14 from dergigi/reading-progress-filters
Add reading progress filters and split Reads/Links tabs
2025-10-16 01:46:32 +02:00
Gigi
e6876d141f fix: show skeletons during initial tab load for reads/links 2025-10-16 01:43:36 +02:00
Gigi
5bb81b3c22 fix: always show skeletons for reads/links when no data
Removed empty state messages like "No articles in your reads" and
"No links yet" - now just show loading skeletons until data arrives.

This is simpler and prevents showing empty states while data is still
being fetched in the background.

Users will only see:
- Skeletons when no data (loading or truly empty)
- "No articles/links match this filter" when filtered out
- Actual content when data is available
2025-10-16 01:40:37 +02:00
Gigi
1e8e58fa05 fix: show loading skeletons correctly for reads and links tabs
The bug was that showSkeletons checked if ANY tab had data, so if you
had highlights or bookmarks, it would never show skeletons for reads/links
even while they were still loading.

Fix: Each tab now checks its own loading state (loading && tabData.length === 0)
instead of using the shared showSkeletons variable.

This makes the logic simple and clear:
1. If loading AND no data → show skeletons
2. If not loading AND no data → show empty state
3. If has data but filtered out → show no match message
4. Otherwise → show content
2025-10-16 01:39:03 +02:00
Gigi
f44e36e4bf refactor: make code more DRY by extracting shared utilities
- Create readingProgressUtils.ts with filterByReadingProgress function
- Create readingDataProcessor.ts with shared processing functions:
  - processReadingPositions
  - processMarkedAsRead
  - filterValidItems
  - sortByReadingActivity
- Refactor readsService.ts to use shared utilities
- Refactor linksService.ts to use shared utilities
- Eliminate 100+ lines of duplicated code
- Simplify Me.tsx filter logic to 2 lines

Benefits:
- Single source of truth for reading progress filtering
- Easier to maintain and modify
- Less code duplication across services
- More testable with isolated utility functions
2025-10-16 01:36:28 +02:00
Gigi
11c7564f8c feat: split Reads tab into Reads and Links
- Reads: Only Nostr-native articles (kind:30023)
- Links: Only external URLs with reading progress
- Create linksService.ts for fetching external URL links
- Update readsService to filter only Nostr articles
- Add Links tab between Reads and Writings with same filtering
- Add /me/links route
- Update meCache to include links field
- Both tabs support reading progress filters
- Lazy loading for both tabs

This provides clear separation between native Nostr content and external web links.
2025-10-16 01:33:04 +02:00
Gigi
a064376bd8 fix: filter out 'Untitled' items from Reads tab
- Exclude Nostr articles without event data (can't fetch title)
- Exclude external URLs without proper titles
- Prevents cluttering Reads with items that have no meaningful title
- Only shows items we can properly identify and display
2025-10-16 01:25:31 +02:00
Gigi
292e8e9bda fix: only show external URLs in Reads if they have reading progress
- External URLs with 0% progress are now filtered out
- External URLs only appear if readingProgress > 0 OR marked as read
- Nostr articles still show even at 0% (bookmarked articles)
- Keeps Reads tab focused on actual reading activity for external links
2025-10-16 01:24:50 +02:00
Gigi
951a3699ca fix: replace spinners with skeleton placeholders in Me tabs
- Replace spinner in highlights tab with 'No highlights yet' message
- Replace spinner in reading-list tab with 'No bookmarks yet' message
- Only show these messages when loading is complete and arrays are empty
- Remove unused faSpinner import
- Consistent with skeleton placeholder pattern used elsewhere
2025-10-16 01:21:31 +02:00
Gigi
860ec70b1c feat: implement lazy loading for Me component tabs
- Add loadedTabs state to track which tabs have been loaded
- Create tab-specific loading functions (loadHighlightsTab, loadWritingsTab, loadReadingListTab, loadReadsTab)
- Only load data for active tab on mount and tab switches
- Show cached data immediately, refresh in background when revisiting tabs
- Update pull-to-refresh to only reload the active tab
- Show loading skeletons only on first load of each tab
- Works for both /me (own profile) and /p/ (other profiles)

This reduces initial load time from 30+ seconds to 2-5 seconds by only fetching data for the active tab.
2025-10-16 01:19:06 +02:00
Gigi
2b69c72939 refactor: simplify loading state to use unified logic
- Remove separate loadingReads state
- Keep single loading state true until ALL data is loaded
- Matches existing pattern used in other tabs
- Keeps code DRY and simple
2025-10-16 01:08:56 +02:00
Gigi
b98d774cbf fix: filter out reads without timestamps
- Exclude items without readingTimestamp or markedAt from reads
- Prevents 'Just Now' items from appearing in the reads list
- Only show reads with valid activity timestamps
2025-10-16 01:06:27 +02:00
Gigi
8972571a18 fix: keep showing skeletons while reads are loading
- Add separate loadingReads state to track reads fetching
- Show skeletons during the entire reads loading period
- Set loading=false after public data (highlights/writings) completes
- Prevents showing 'No articles match this filter' while reads are being fetched
2025-10-16 01:05:42 +02:00
Gigi
ab5d5dca58 debug: add logging to reads filtering 2025-10-16 00:59:28 +02:00
Gigi
e383356af1 feat: rename Archive to Reads and expand functionality
- Create new readsService to aggregate all read content from multiple sources
- Include bookmarked articles, reading progress tracked articles, and manually marked-as-read items
- Update Me component to use new reads service
- Update routes from /me/archive to /me/reads
- Update meCache to use ReadItem[] instead of BlogPostPreview[]
- Update filter logic to use actual reading progress data
- Support both Nostr-native articles and external URLs in reads
- Fetch and display article metadata from multiple sources
- Sort by most recent reading activity
2025-10-16 00:45:16 +02:00
Gigi
165d10c49b feat: split 'To read' filter into 'Unopened' and 'Started'
- Add 'unopened' filter (no progress, 0%) - uses fa-envelope icon
- Add 'started' filter (0-10% progress) - uses fa-envelope-open icon
- Remove 'to-read' filter
- Use classic/regular variant for envelope icons
- Update filter logic in BookmarkList and Me components
- New filter ranges:
  - Unopened: 0% (never opened)
  - Started: 0-10% (opened but not read far)
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:13:34 +02:00
Gigi
e0869c436b fix: adjust 'Reading' filter to 11-94% range
- Change 'reading' filter from 10-95% to 11-94%
- Creates clearer boundaries between filters:
  - To read: 0-10%
  - Reading: 11-94%
  - Completed: 95-100%
2025-10-16 00:10:20 +02:00
Gigi
95432fc276 fix: reading position filters now work correctly in bookmarks
- Match marked-as-read event IDs to bookmark coordinate IDs
- Use eventStore to lookup events and build coordinates from them
- Add both event ID and coordinate format to markedAsReadIds set
- This fixes filtering of bookmarked articles by reading progress
- Apply same fix to both Bookmarks and Explore components
2025-10-15 23:54:44 +02:00
Gigi
1982d25fa8 feat: add fancy animation to Mark as Read button
- Icon spins 360° with bounce effect (scale up during spin)
- Button background changes to vibrant green gradient (#10b981)
- Green pulsing box-shadow effect on activation
- Button scales up slightly on click for emphasis
- Holds green state for 1.5 seconds
- Smoothly fades to gray after animation
- Final state is gray button to indicate marked status
- Uses cubic-bezier easing for modern, smooth feel
- Total animation duration: 2.5 seconds
- Prevents interaction during animation
2025-10-15 23:39:14 +02:00
Gigi
2fc64b6028 feat: change 'To read' filter to show 0-10% progress
- Update 'to-read' filter range from 0-5% to 0-10%
- Update 'reading' filter to start at 10% instead of 5%
- Adjust filter comments to reflect new ranges
2025-10-15 23:37:59 +02:00
Gigi
6e8686a49d feat: treat marked-as-read articles as 100% progress
- Fetch marked-as-read articles in useBookmarksData and Explore
- Pass markedAsReadIds through component chain (Bookmarks -> ThreePaneLayout -> BookmarkList)
- Display 100% progress for marked articles in all views (Archive, Bookmarks, Explore)
- Update filter logic to treat marked articles as completed
- Marked articles show green 100% progress bar
- Marked articles only appear in 'completed' or 'all' filters
- Remove reading position tracking from Me.tsx (not needed when all are marked)
- Clean up unused imports and variables
2025-10-15 23:36:05 +02:00
Gigi
fd5ce80a06 feat: add auto-mark as read at 100% reading progress
- Add autoMarkAsReadAt100 setting (default: false)
- Add checkbox in Layout & Behavior settings
- Automatically mark article as read after 2 seconds at 100% progress
- Trigger same animation as manual mark as read button
- Move isNostrArticle computation earlier for useCallback deps
- Move handleMarkAsRead to useCallback for use in auto-mark effect
2025-10-15 23:28:50 +02:00
Gigi
ac4185e2cc feat: merge 'Completed' and 'Marked as Read' filters into one
- Remove 'marked' filter type from ReadingProgressFilterType
- Update ReadingProgressFilters component to show only 4 filters
- Keep checkmark icon for unified 'Completed' filter
- Completed filter now shows both:
  - Articles with 95%+ reading progress
  - Articles manually marked as read (no position data or 0%)
- Remove unused faBooks icon import
- Update filter logic in BookmarkList and Me components
2025-10-15 23:22:40 +02:00
Gigi
9217077283 fix: replace spinners with skeletons during refresh in archive/writings tabs
- Changed spinner to empty state message only when not loading
- During refresh, keeps showing cached content or skeletons
- Archive: shows 'No articles in your archive' only when done loading
- Writings: shows 'No articles written yet' only when done loading
- Prevents jarring transition from skeletons to spinner during refresh
2025-10-15 23:20:54 +02:00
Gigi
b7c14b5c7c fix: restore top padding to reading progress filters
- Remove padding-top: 0 override
- Now has equal spacing top and bottom (0.5rem)
2025-10-15 23:18:31 +02:00
Gigi
9b3cc41770 refactor: rename ArchiveFilters to ReadingProgressFilters
- More accurate naming: filters are based on reading progress/position
- Renamed component: ArchiveFilters -> ReadingProgressFilters
- Renamed type: ArchiveFilterType -> ReadingProgressFilterType
- Renamed variables: archiveFilter -> readingProgressFilter
- Renamed CSS class: archive-filters-wrapper -> reading-progress-filters-wrapper
- Updated all imports and references in BookmarkList and Me components
- Updated comments to reflect reading progress filtering
2025-10-15 23:17:55 +02:00
Gigi
4c4bd2214c feat: add top border to archive filters in bookmarks sidebar
- Matches the style of bookmark type filters at top
- Visually separates archive filters from bookmarks content
2025-10-15 23:14:56 +02:00
Gigi
93c31650f4 fix: remove double border between archive filters and view controls
- Add archive-filters-wrapper class
- Remove border-bottom from bookmark-filters in wrapper
- Prevents double border (bookmark-filters border-bottom + view-mode-controls border-top)
2025-10-15 23:14:20 +02:00
Gigi
7f0d99fc29 fix: remove duplicate border between archive filters and view controls
- Remove borderTop from archive filters div
- Keep only the border from view-mode-controls CSS
2025-10-15 23:12:26 +02:00
Gigi
eb6dbe1644 feat: add archive filters to bookmarks sidebar
- Add ArchiveFilters component to bookmarks sidebar
- Filter buttons shown above view-mode-controls row
- Filters: All, To Read (0-5%), Reading (5-95%), Completed (95%+), Marked
- Only shown when kind:30023 articles are present
- Filters only apply to kind:30023 articles
- Other bookmark types (videos, notes, web) remain visible
2025-10-15 23:10:31 +02:00
Gigi
474da25f77 fix: add autoScrollToPosition to useEffect dependency array
- Fixes react-hooks/exhaustive-deps warning
- Ensures effect reruns when auto-scroll setting changes
2025-10-15 23:08:21 +02:00
Gigi
02eaa1c8f8 feat: show reading progress in Explore and Bookmarks sidebar
- Add reading position loading to Explore component
- Add reading position loading to useBookmarksData hook
- Display progress bars in Explore tab blog posts
- Display progress bars in Bookmarks large preview view
- Progress shown as colored bar (green for completed, orange for in-progress)
- Only shown for kind:30023 articles with saved reading positions
- Requires syncReadingPosition setting to be enabled
2025-10-15 23:07:18 +02:00
Gigi
8800791723 feat: add auto-scroll to reading position setting
- Add autoScrollToPosition setting (default: true)
- Add checkbox in Layout & Behavior settings
- Only auto-scroll when setting is enabled
- Allows users to disable auto-scrolling while keeping sync enabled
2025-10-15 22:53:47 +02:00
Gigi
6758b9678b fix: update 'To Read' filter to show 0-5% progress articles
- Filter now shows articles with 0-5% reading progress
- Excludes manually marked as read articles (those without position data)
- Updates comment to reflect new logic
2025-10-15 22:51:40 +02:00
Gigi
85649ae283 Merge pull request #13 from dergigi/sync-reading-position
Add reading position sync and archive enhancements
2025-10-15 22:45:13 +02:00
30 changed files with 1764 additions and 211 deletions

View File

@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.22] - 2025-10-16
### Added
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
- User-agent detection serves appropriate content to crawlers vs browsers
- Falls back to default social preview image when articles have no cover image
- Social preview image for homepage and article links
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
- Homepage now includes social preview image in meta tags
### Changed
- Article deep-links now properly preserve URL when loading in browser
- Uses `history.replaceState()` to maintain correct article path
- Browser navigation works correctly on refresh and new tab opens
### Fixed
- Vercel rewrite configuration for article routes
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
- Regular SPA routing preserved for browser navigation
## [0.6.21] - 2025-10-16
### Added
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
- Automatically saves and syncs reading position as you scroll
- Visual reading progress indicator on article cards
- Reading progress shown in Explore and Bookmarks sidebar
- Auto-scroll to last reading position setting (configurable in Settings)
- Reading position displayed as colored progress bar on cards
- Reading progress filters for organizing articles
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
- Filter icons colored when active (blue for most, green for completed)
- URL routing support for reading progress filters
- Reading progress filters available in Archive tab and bookmarks sidebar
- Reads and Links tabs on `/me` page
- Reads tab shows nostr-native articles with reading progress
- Links tab shows external URLs with reading progress
- Both tabs populate instantly from bookmarks for fast loading
- Lazy loading for improved performance
- Auto-mark as read at 100% reading progress
- Articles automatically marked as read when scrolled to end
- Marked-as-read articles treated as 100% progress
- Fancy checkmark animation on Mark as Read button
- Click-to-open article navigation on highlights
- Clicking highlights in Explore and Me pages opens the source article
- Automatically scrolls to highlighted text position
### Changed
- Renamed Archive to Reads with expanded functionality
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
- Simplified filter icon colors to blue (except green for completed)
- Started reading progress state (0-10%) uses neutral text color
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
- Removed unused IEventStore import in ContentPanel
### Fixed
- Reading position calculation now accurately reaches 100%
- Reading position filters work correctly in bookmarks sidebar
- Filter out reads without timestamps or 'Untitled' items
- Show skeleton placeholders correctly during initial tab load
- External URLs in Reads tab only shown if they have reading progress
- Reading progress merges even when timestamp is older than bookmark
- Resolved all linter errors and TypeScript type issues
### Refactored
- Renamed ArchiveFilters component to ReadingProgressFilters
- Extracted shared utilities from readsFromBookmarks for DRY code
- Use setState callback pattern for background enrichment
- Use naddr format for article IDs to match reading positions
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
## [0.6.20] - 2025-10-15 ## [0.6.20] - 2025-10-15
### Added ### Added
@@ -1641,7 +1721,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD [Unreleased]: https://github.com/dergigi/boris/compare/v0.6.21...HEAD
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20 [0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19 [0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18 [0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18

304
api/article-og.ts Normal file
View File

@@ -0,0 +1,304 @@
import type { VercelRequest, VercelResponse } from '@vercel/node'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent, Filter } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
// Relay configuration (from src/config/relays.ts)
const RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.dergigi.com',
'wss://wot.dergigi.com',
'wss://relay.snort.social',
'wss://relay.current.fyi',
'wss://nostr-pub.wellorder.net',
'wss://purplepag.es',
'wss://relay.primal.net'
]
type CacheEntry = {
html: string
expires: number
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const memoryCache = new Map<string, CacheEntry>()
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
res.setHeader('Content-Type', 'text/html; charset=utf-8')
}
interface ArticleMetadata {
title: string
summary: string
image: string
author: string
published?: number
}
async function fetchEventsFromRelays(
relayPool: RelayPool,
relayUrls: string[],
filter: Filter,
timeoutMs: number
): Promise<NostrEvent[]> {
const events: NostrEvent[] = []
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => resolve(), timeoutMs)
// `request` emits NostrEvent objects directly
relayPool.request(relayUrls, filter).subscribe({
next: (event) => {
events.push(event)
},
error: () => resolve(),
complete: () => {
clearTimeout(timeout)
resolve()
}
})
})
// Sort by created_at and return most recent first
return events.sort((a, b) => b.created_at - a.created_at)
}
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
const relayPool = new RelayPool()
try {
// Decode naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Determine relay URLs
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
// Fetch article and profile in parallel
const [articleEvents, profileEvents] = await Promise.all([
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier || '']
}, 5000),
fetchEventsFromRelays(relayPool, relayUrls, {
kinds: [0],
authors: [pointer.pubkey]
}, 3000)
])
if (articleEvents.length === 0) {
return null
}
const article = articleEvents[0]
// Extract article metadata
const title = getArticleTitle(article) || 'Untitled Article'
const summary = getArticleSummary(article) || 'Read this article on Boris'
const image = getArticleImage(article) || '/boris-social-1200.png'
// Extract author name from profile
let authorName = pointer.pubkey.slice(0, 8) + '...'
if (profileEvents.length > 0) {
try {
const profileData = JSON.parse(profileEvents[0].content)
authorName = profileData.display_name || profileData.name || authorName
} catch {
// Use fallback
}
}
return {
title,
summary,
image,
author: authorName,
published: article.created_at
}
} catch (err) {
console.error('Failed to fetch article metadata:', err)
return null
} finally {
// No explicit close needed; pool manages connections internally
}
}
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
const baseUrl = 'https://read.withboris.com'
const articleUrl = `${baseUrl}/a/${naddr}`
const title = meta?.title || 'Boris Nostr Bookmarks'
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
const author = meta?.author || 'Boris'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
<link rel="canonical" href="${articleUrl}" />
<!-- Open Graph / Social Media -->
<meta property="og:type" content="article" />
<meta property="og:url" content="${articleUrl}" />
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(image)}" />
<meta property="og:site_name" content="Boris" />
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
<meta property="article:author" content="${escapeHtml(author)}" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="${articleUrl}" />
<meta name="twitter:title" content="${escapeHtml(title)}" />
<meta name="twitter:description" content="${escapeHtml(description)}" />
<meta name="twitter:image" content="${escapeHtml(image)}" />
</head>
<body>
<noscript>
<p>Redirecting to <a href="/">Boris</a>...</p>
</noscript>
</body>
</html>`
}
function isCrawler(userAgent: string | undefined): boolean {
if (!userAgent) return false
const crawlers = [
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
]
const ua = userAgent.toLowerCase()
return crawlers.some(crawler => ua.includes(crawler))
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
const naddr = (req.query.naddr as string | undefined)?.trim()
if (!naddr) {
return res.status(400).json({ error: 'Missing naddr parameter' })
}
const userAgent = req.headers['user-agent'] as string | undefined
const isCrawlerRequest = isCrawler(userAgent)
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
if (debugEnabled) {
console.log('[article-og] request', JSON.stringify({
naddr,
ua: userAgent || null,
isCrawlerRequest,
path: req.url || null
}))
res.setHeader('X-Boris-Debug', '1')
}
// If it's a regular browser (not a bot), serve HTML that loads SPA
// Use history.replaceState to set the URL before the SPA boots
if (!isCrawlerRequest) {
const articlePath = `/a/${naddr}`
// Serve a minimal HTML that sets up the URL and loads the SPA
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Boris - Loading Article...</title>
<script>
// Set the URL to the article path before SPA loads
if (window.location.pathname !== '${articlePath}') {
history.replaceState(null, '', '${articlePath}');
}
</script>
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
<script>
// Redirect to index.html which will load the SPA
// The history state is already set, so SPA will see the correct URL
window.location.replace('/');
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>`
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
}
return res.status(200).send(html)
}
// Check cache for bots/crawlers
const now = Date.now()
const cached = memoryCache.get(naddr)
if (cached && cached.expires > now) {
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
}
return res.status(200).send(cached.html)
}
try {
// Fetch metadata
const meta = await fetchArticleMetadata(naddr)
// Generate HTML
const html = generateHtml(naddr, meta)
// Cache the result
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
// Send response
setCacheHeaders(res)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
}
return res.status(200).send(html)
} catch (err) {
console.error('Error generating article OG HTML:', err)
// Fallback to basic HTML with SPA boot
const html = generateHtml(naddr, null)
setCacheHeaders(res, 3600)
if (debugEnabled) {
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
}
return res.status(200).send(html)
}
}

View File

@@ -18,6 +18,7 @@
<meta property="og:url" content="https://read.withboris.com/" /> <meta property="og:url" content="https://read.withboris.com/" />
<meta property="og:title" content="Boris - Nostr Bookmarks" /> <meta property="og:title" content="Boris - Nostr Bookmarks" />
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." /> <meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
<meta property="og:site_name" content="Boris" /> <meta property="og:site_name" content="Boris" />
<!-- Twitter Card --> <!-- Twitter Card -->
@@ -25,6 +26,7 @@
<meta name="twitter:url" content="https://read.withboris.com/" /> <meta name="twitter:url" content="https://read.withboris.com/" />
<meta name="twitter:title" content="Boris - Nostr Bookmarks" /> <meta name="twitter:title" content="Boris - Nostr Bookmarks" />
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." /> <meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
<!-- Default to system theme until settings load from Nostr --> <!-- Default to system theme until settings load from Nostr -->
<script> <script>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 KiB

View File

@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders' import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks' import Bookmarks from './components/Bookmarks'
import RouteDebug from './components/RouteDebug'
import Toast from './components/Toast' import Toast from './components/Toast'
import { useToast } from './hooks/useToast' import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus' import { useOnlineStatus } from './hooks/useOnlineStatus'
@@ -112,7 +113,25 @@ function AppRoutes({
} }
/> />
<Route <Route
path="/me/archive" path="/me/reads"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/reads/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/me/links"
element={ element={
<Bookmarks <Bookmarks
relayPool={relayPool} relayPool={relayPool}
@@ -285,6 +304,7 @@ function App() {
<BrowserRouter> <BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative"> <div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} /> <AppRoutes relayPool={relayPool} showToast={showToast} />
<RouteDebug />
</div> </div>
</BrowserRouter> </BrowserRouter>
{toastMessage && ( {toastMessage && (

View File

@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons' import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked' export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
@@ -22,17 +21,24 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
return ( return (
<div className="bookmark-filters"> <div className="bookmark-filters">
{filters.map(filter => ( {filters.map(filter => {
<button const isActive = selectedFilter === filter.type
key={filter.type} // Only "completed" gets green color, everything else uses default blue
onClick={() => onFilterChange(filter.type)} const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
title={filter.label} return (
aria-label={`Filter by ${filter.label}`} <button
> key={filter.type}
<FontAwesomeIcon icon={filter.icon} /> onClick={() => onFilterChange(filter.type)}
</button> className={`filter-btn ${isActive ? 'active' : ''}`}
))} title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div> </div>
) )
} }

View File

@@ -24,9 +24,15 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
addSuffix: true addSuffix: true
}) })
// Calculate progress percentage and determine color // Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0 const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise 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)
}
return ( return (
<Link <Link

View File

@@ -23,6 +23,7 @@ interface LargeViewProps {
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleSummary?: string articleSummary?: string
contentTypeIcon: IconDefinition contentTypeIcon: IconDefinition
readingProgress?: number // 0-1 reading progress (optional)
} }
export const LargeView: React.FC<LargeViewProps> = ({ export const LargeView: React.FC<LargeViewProps> = ({
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName, getAuthorDisplayName,
handleReadNow, handleReadNow,
articleSummary, articleSummary,
contentTypeIcon contentTypeIcon,
readingProgress
}) => { }) => {
const cachedImage = useImageCache(previewImage || undefined) const cachedImage = useImageCache(previewImage || undefined)
const isArticle = bookmark.kind === 30023 const isArticle = bookmark.kind === 30023
// Calculate progress display (matching readingProgressUtils.ts logic)
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
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 triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>) const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => { const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
</div> </div>
)} )}
{/* Reading progress indicator for articles - shown only if there's progress */}
{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: `${progressPercent}%`,
background: progressColor,
transition: 'width 0.3s ease, background 0.3s ease'
}}
/>
</div>
)}
<div className="large-footer"> <div className="large-footer">
<span className="bookmark-type-large"> <span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />

View File

@@ -52,7 +52,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const meTab = location.pathname === '/me' ? 'highlights' : const meTab = location.pathname === '/me' ? 'highlights' :
location.pathname === '/me/highlights' ? 'highlights' : location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' : location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname === '/me/archive' ? 'archive' : location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights' location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes // Extract tab from profile routes

View File

@@ -1,16 +1,17 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem' import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService' import { fetchHighlights } from '../services/highlightService'
import { fetchBookmarks } from '../services/bookmarkService' import { fetchBookmarks } from '../services/bookmarkService'
import { fetchReadArticlesWithData } from '../services/libraryService' import { fetchAllReads, ReadItem } from '../services/readsService'
import { fetchLinks } from '../services/linksService'
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -19,15 +20,18 @@ import BlogPostCard from './BlogPostCard'
import { BookmarkItem } from './BookmarkItem' import { BookmarkItem } from './BookmarkItem'
import IconButton from './IconButton' import IconButton from './IconButton'
import { ViewMode } from './Bookmarks' import { ViewMode } from './Bookmarks'
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache' import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
import { faBooks } from '../icons/customIcons' import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService' import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters' import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { mergeReadItem } from '../utils/readItemMerge'
interface MeProps { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
@@ -35,12 +39,15 @@ interface MeProps {
pubkey?: string // Optional pubkey for viewing other users' profiles pubkey?: string // Optional pubkey for viewing other users' profiles
} }
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings' type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const eventStore = Hooks.useEventStore()
const navigate = useNavigate() const navigate = useNavigate()
const { filter: urlFilter } = useParams<{ filter?: string }>()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights') const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
// Use provided pubkey or fall back to active account // Use provided pubkey or fall back to active account
@@ -48,14 +55,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey) const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
const [highlights, setHighlights] = useState<Highlight[]>([]) const [highlights, setHighlights] = useState<Highlight[]>([])
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([]) const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([]) const [writings, setWritings] = useState<BlogPostPreview[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
const [viewMode, setViewMode] = useState<ViewMode>('cards') const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0) const [refreshTrigger, setRefreshTrigger] = useState(0)
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all') const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map()) // Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
// Update local state when prop changes // Update local state when prop changes
useEffect(() => { useEffect(() => {
@@ -64,131 +79,246 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
} }
}, [propActiveTab]) }, [propActiveTab])
// Sync filter state with URL changes
useEffect(() => { useEffect(() => {
const loadData = async () => { const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
if (!viewingPubkey) { ? (urlFilter as ReadingProgressFilterType)
setLoading(false) : 'all'
return setReadingProgressFilter(filterFromUrl)
} }, [urlFilter])
// Handler to change reading progress filter and update URL
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
setReadingProgressFilter(filter)
if (activeTab === 'reads') {
if (filter === 'all') {
navigate('/me/reads', { replace: true })
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
}
}
// 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)
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)
}
}
const loadWritingsTab = async () => {
if (!viewingPubkey) return
const hasBeenLoaded = loadedTabs.has('writings')
try {
if (!hasBeenLoaded) setLoading(true)
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
setWritings(userWritings)
setLoadedTabs(prev => new Set(prev).add('writings'))
} catch (err) {
console.error('Failed to load writings:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
}
}
const loadReadingListTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('reading-list')
try {
if (!hasBeenLoaded) setLoading(true)
try { try {
setLoading(true) await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
setBookmarks(newBookmarks)
// Seed from cache if available to avoid empty flash (own profile only) })
if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReadArticles(cached.readArticles)
}
}
// Fetch highlights and writings (public data)
const [userHighlights, userWritings] = await Promise.all([
fetchHighlights(relayPool, viewingPubkey),
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
])
setHighlights(userHighlights)
setWritings(userWritings)
// Only fetch private data for own profile
if (isOwnProfile && activeAccount) {
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
setReadArticles(userReadArticles)
// Fetch bookmarks using callback pattern
let fetchedBookmarks: Bookmark[] = []
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
setBookmarks([])
}
// Update cache with all fetched data
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
} else {
setBookmarks([])
setReadArticles([])
}
} catch (err) { } catch (err) {
console.error('Failed to load data:', err) console.warn('Failed to load bookmarks:', err)
// No blocking error - user can pull-to-refresh setBookmarks([])
} finally {
setLoading(false)
} }
setLoadedTabs(prev => new Set(prev).add('reading-list'))
} catch (err) {
console.error('Failed to load reading list:', err)
} finally {
if (!hasBeenLoaded) setLoading(false)
} }
}
loadData() const loadReadsTab = async () => {
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) if (!viewingPubkey || !isOwnProfile || !activeAccount) return
// Load reading positions for read articles (only for own profile) const hasBeenLoaded = loadedTabs.has('reads')
useEffect(() => {
const loadPositions = async () => { try {
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) { if (!hasBeenLoaded) setLoading(true)
console.log('🔍 [Archive] Skipping position load:', {
isOwnProfile, // Ensure bookmarks are loaded
hasAccount: !!activeAccount, let fetchedBookmarks: Bookmark[] = bookmarks
hasRelayPool: !!relayPool, if (bookmarks.length === 0) {
hasEventStore: !!eventStore, try {
articlesCount: readArticles.length await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
}) fetchedBookmarks = newBookmarks
return setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
} }
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles') // Derive reads from bookmarks immediately
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialReads.map(item => [item.id, item]))
setReadsMap(initialMap)
setReads(initialReads)
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
const positions = new Map<string, number>() // Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
console.log('📈 [Reads] Enrichment item received:', {
id: item.id.slice(0, 20) + '...',
progress: item.readingProgress,
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
})
// Load positions for all read articles setReadsMap(prevMap => {
await Promise.all( // Only update if item exists in our current map
readArticles.map(async (post) => { if (!prevMap.has(item.id)) {
try { console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' return prevMap
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: post.author,
identifier: dTag
})
const articleUrl = `nostr:${naddr}`
const identifier = generateArticleIdentifier(articleUrl)
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
const savedPosition = await loadReadingPosition(
relayPool,
eventStore,
activeAccount.pubkey,
identifier
)
if (savedPosition && savedPosition.position > 0) {
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
positions.set(post.event.id, savedPosition.position)
} else {
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
}
} catch (error) {
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
} }
})
)
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles') const newMap = new Map(prevMap)
setReadingPositions(positions) const merged = mergeReadItem(newMap, item)
if (merged) {
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
// Update reads array after map is updated
setReads(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich reads:', err))
} catch (err) {
console.error('Failed to load reads:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
const loadLinksTab = async () => {
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
const hasBeenLoaded = loadedTabs.has('links')
try {
if (!hasBeenLoaded) setLoading(true)
// Ensure bookmarks are loaded
let fetchedBookmarks: Bookmark[] = bookmarks
if (bookmarks.length === 0) {
try {
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
fetchedBookmarks = newBookmarks
setBookmarks(newBookmarks)
})
} catch (err) {
console.warn('Failed to load bookmarks:', err)
fetchedBookmarks = []
}
}
// Derive links from bookmarks immediately
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
setLinksMap(initialMap)
setLinks(initialLinks)
setLoadedTabs(prev => new Set(prev).add('links'))
if (!hasBeenLoaded) setLoading(false)
// Background enrichment: merge reading progress and mark-as-read
// Only update items that are already in our map
fetchLinks(relayPool, viewingPubkey, (item) => {
setLinksMap(prevMap => {
// Only update if item exists in our current map
if (!prevMap.has(item.id)) return prevMap
const newMap = new Map(prevMap)
if (mergeReadItem(newMap, item)) {
// Update links array after map is updated
setLinks(Array.from(newMap.values()))
return newMap
}
return prevMap
})
}).catch(err => console.warn('Failed to enrich links:', err))
} catch (err) {
console.error('Failed to load links:', err)
if (!hasBeenLoaded) setLoading(false)
}
}
// Load active tab data
useEffect(() => {
if (!viewingPubkey || !activeTab) {
setLoading(false)
return
} }
loadPositions() // Load cached data immediately if available
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore]) if (isOwnProfile) {
const cached = getCachedMeData(viewingPubkey)
if (cached) {
setHighlights(cached.highlights)
setBookmarks(cached.bookmarks)
setReads(cached.reads || [])
setLinks(cached.links || [])
}
}
// Pull-to-refresh // Load data for active tab (refresh in background if already loaded)
switch (activeTab) {
case 'highlights':
loadHighlightsTab()
break
case 'writings':
loadWritingsTab()
break
case 'reading-list':
loadReadingListTab()
break
case 'reads':
loadReadsTab()
break
case 'links':
loadLinksTab()
break
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, viewingPubkey, refreshTrigger])
// Pull-to-refresh - reload active tab without clearing state
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => { onRefresh: () => {
// Just trigger refresh - loaders will merge new data
setRefreshTrigger(prev => prev + 1) setRefreshTrigger(prev => prev + 1)
}, },
maximumPullLength: 240, maximumPullLength: 240,
@@ -217,6 +347,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
return `/a/${naddr}` return `/a/${naddr}`
} }
const getReadItemUrl = (item: ReadItem) => {
if (item.type === 'article') {
// ID is already in naddr format
return `/a/${item.id}`
} else if (item.url) {
return `/r/${encodeURIComponent(item.url)}`
}
return '#'
}
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
if (item.event) {
return {
event: item.event,
title: item.title || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || item.event.pubkey
}
}
// Create a mock event for external URLs
const mockEvent = {
id: item.id,
pubkey: item.author || '',
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
kind: 1,
tags: [] as string[][],
content: item.title || item.url || 'Untitled',
sig: ''
} as const
return {
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
title: item.title || item.url || 'Untitled',
summary: item.summary,
image: item.image,
published: item.published,
author: item.author || ''
}
}
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => { const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
if (bookmark && bookmark.kind === 30023) { if (bookmark && bookmark.kind === 30023) {
// For kind:30023 articles, navigate to the article route // For kind:30023 articles, navigate to the article route
@@ -245,29 +418,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
const groups = groupIndividualBookmarks(filteredBookmarks) const groups = groupIndividualBookmarks(filteredBookmarks)
// Apply archive filter // Apply reading progress filter
const filteredReadArticles = readArticles.filter(post => { const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
const position = readingPositions.get(post.event.id) const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
switch (archiveFilter) {
case 'to-read':
// No position or 0% progress
return !position || position === 0
case 'reading':
// Has some progress but not completed (0 < position < 1)
return position !== undefined && position > 0 && position < 0.95
case 'completed':
// 95% or more read (we consider 95%+ as completed)
return position !== undefined && position >= 0.95
case 'marked':
// Manually marked as read (in archive but no reading position data)
// These are articles that were marked via the emoji reaction
return !position || position === 0
case 'all':
default:
return true
}
})
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, { key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
@@ -276,7 +429,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
] ]
// Show content progressively - no blocking error screens // Show content progressively - no blocking error screens
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0 const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
const showSkeletons = loading && !hasData const showSkeletons = loading && !hasData
const renderTabContent = () => { const renderTabContent = () => {
@@ -291,9 +444,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
} }
return highlights.length === 0 ? ( return highlights.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> No highlights yet.
</div> </div>
) : ( ) : (
<div className="highlights-list me-highlights-list"> <div className="highlights-list me-highlights-list">
@@ -320,9 +473,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
} }
return allIndividualBookmarks.length === 0 ? ( return allIndividualBookmarks.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> No bookmarks yet.
</div> </div>
) : ( ) : (
<div className="bookmarks-list"> <div className="bookmarks-list">
@@ -386,8 +539,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
case 'archive': case 'reads':
if (showSkeletons) { // Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('reads')) {
return ( return (
<div className="explore-grid"> <div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
@@ -396,32 +550,84 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
} }
return readArticles.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> // Show empty state if loaded but no reads
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> if (reads.length === 0 && loadedTabs.has('reads')) {
</div> return (
) : ( <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles read yet.
</div>
)
}
// Show reads with filters
return (
<> <>
{readArticles.length > 0 && ( <ReadingProgressFilters
<ArchiveFilters selectedFilter={readingProgressFilter}
selectedFilter={archiveFilter} onFilterChange={handleReadingProgressFilterChange}
onFilterChange={setArchiveFilter} />
/> {filteredReads.length === 0 ? (
)}
{filteredReadArticles.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter. No articles match this filter.
</div> </div>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid">
{filteredReadArticles.map((post) => ( {filteredReads.map((item) => (
<BlogPostCard <BlogPostCard
key={post.event.id} key={item.id}
post={post} post={convertReadItemToBlogPostPreview(item)}
href={getPostUrl(post)} href={getReadItemUrl(item)}
readingProgress={readingPositions.get(post.event.id)} readingProgress={item.readingProgress}
/> />
))} ))}
</div>
)}
</>
)
case 'links':
// Show loading skeletons only while initially loading
if (loading && !loadedTabs.has('links')) {
return (
<div className="explore-grid">
{Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))}
</div>
)
}
// Show empty state if loaded but no links
if (links.length === 0 && loadedTabs.has('links')) {
return (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links with reading progress yet.
</div>
)
}
// Show links with filters
return (
<>
<ReadingProgressFilters
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div> </div>
)} )}
</> </>
@@ -437,9 +643,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) )
} }
return writings.length === 0 ? ( return writings.length === 0 && !loading ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}> <div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> No articles written yet.
</div> </div>
) : ( ) : (
<div className="explore-grid"> <div className="explore-grid">
@@ -487,12 +693,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<span className="tab-label">Bookmarks</span> <span className="tab-label">Bookmarks</span>
</button> </button>
<button <button
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`} className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
data-tab="archive" data-tab="reads"
onClick={() => navigate('/me/archive')} onClick={() => navigate('/me/reads')}
> >
<FontAwesomeIcon icon={faBooks} /> <FontAwesomeIcon icon={faBooks} />
<span className="tab-label">Archive</span> <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>
</> </>
)} )}

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
onFilterChange: (filter: ReadingProgressFilterType) => void
}
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
const filters = [
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
{ 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: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
]
return (
<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
return (
<button
key={filter.type}
onClick={() => onFilterChange(filter.type)}
className={`filter-btn ${isActive ? 'active' : ''}`}
title={filter.label}
aria-label={`Filter by ${filter.label}`}
style={activeStyle}
>
<FontAwesomeIcon icon={filter.icon} />
</button>
)
})}
</div>
)
}
export default ReadingProgressFilters

View File

@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
}) => { }) => {
const clampedProgress = Math.min(100, Math.max(0, progress)) const clampedProgress = Math.min(100, Math.max(0, progress))
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
const progressDecimal = clampedProgress / 100
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
// Determine bar color based on state
let barColorClass = ''
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
if (isComplete) {
barColorClass = 'bg-green-500'
barColorStyle = undefined
} else if (isStarted) {
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
}
// Calculate left and right offsets based on sidebar states (desktop only) // Calculate left and right offsets based on sidebar states (desktop only)
const leftOffset = isSidebarCollapsed const leftOffset = isSidebarCollapsed
? 'var(--sidebar-collapsed-width)' ? 'var(--sidebar-collapsed-width)'
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
style={{ backgroundColor: 'var(--color-border)' }} style={{ backgroundColor: 'var(--color-border)' }}
> >
<div <div
className={`h-full rounded-full transition-all duration-300 relative ${ className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
isComplete
? 'bg-green-500'
: ''
}`}
style={{ style={{
width: `${clampedProgress}%`, width: `${clampedProgress}%`,
backgroundColor: isComplete ? undefined : 'var(--color-primary)' backgroundColor: barColorStyle
}} }}
> >
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" /> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${ className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
isComplete ? 'text-green-500' : '' isComplete ? 'text-green-500' : ''
}`} }`}
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }} style={{
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
}}
> >
{isComplete ? '✓' : `${clampedProgress}%`} {isComplete ? '✓' : `${clampedProgress}%`}
</div> </div>

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useLocation, useMatch } from 'react-router-dom'
export default function RouteDebug() {
const location = useLocation()
const matchArticle = useMatch('/a/:naddr')
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('debug') !== '1') return
const info: Record<string, unknown> = {
pathname: location.pathname,
search: location.search || null,
matchedArticleRoute: Boolean(matchArticle),
referrer: document.referrer || null
}
if (location.pathname === '/') {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
}
}, [location, matchArticle])
return null
}

View File

@@ -167,6 +167,21 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} /> <PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} /> <RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
</div> </div>
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
<span>Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}</span>
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
<span>
{' '}·
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
</a>
) : (
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
)}
</span>
) : null}
</div>
</div> </div>
) )
} }

15
src/config/kinds.ts Normal file
View File

@@ -0,0 +1,15 @@
// Nostr event kinds used throughout the application
export const KINDS = {
Highlights: 9802, // NIP-?? user highlights
BlogPost: 30023, // NIP-23 long-form article
AppData: 30078, // NIP-78 application data (reading positions)
List: 30001, // NIP-51 list (addressable)
ListReplaceable: 30003, // NIP-51 replaceable list
ListSimple: 10003, // NIP-51 simple list
WebBookmark: 39701, // NIP-B0 web bookmark
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
} as const
export type KindValue = typeof KINDS[keyof typeof KINDS]

View File

@@ -15,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService' import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService' import { rebroadcastEvents } from './rebroadcastService'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
@@ -34,7 +35,7 @@ export const fetchBookmarks = async (
const rawEvents = await queryEvents( const rawEvents = await queryEvents(
relayPool, relayPool,
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }, { kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
{} {}
) )
console.log('📊 Raw events fetched:', rawEvents.length, 'events') console.log('📊 Raw events fetched:', rawEvents.length, 'events')
@@ -71,7 +72,7 @@ export const fetchBookmarks = async (
}) })
// Check specifically for Primal's "reads" list // Check specifically for Primal's "reads" list
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads')) const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
if (primalReads) { if (primalReads) {
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8)) console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
} else { } else {

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers } from 'applesauce-core'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
await queryEvents( await queryEvents(
relayPool, relayPool,
{ kinds: [30023], authors: pubkeys, limit: 100 }, { kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
{ {
relayUrls, relayUrls,
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {

View File

@@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService' import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService' import { rebroadcastEvents } from '../rebroadcastService'
import { KINDS } from '../../config/kinds'
export const fetchHighlights = async ( export const fetchHighlights = async (
relayPool: RelayPool, relayPool: RelayPool,
@@ -21,7 +22,7 @@ export const fetchHighlights = async (
const seenIds = new Set<string>() const seenIds = new Set<string>()
const local$ = localRelays.length > 0 const local$ = localRelays.length > 0
? relayPool ? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] }) .req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe( .pipe(
onlyEvents(), onlyEvents(),
tap((event: NostrEvent) => { tap((event: NostrEvent) => {
@@ -36,7 +37,7 @@ export const fetchHighlights = async (
: new Observable<NostrEvent>((sub) => sub.complete()) : new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0 const remote$ = remoteRelays.length > 0
? relayPool ? relayPool
.req(remoteRelays, { kinds: [9802], authors: [pubkey] }) .req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
.pipe( .pipe(
onlyEvents(), onlyEvents(),
tap((event: NostrEvent) => { tap((event: NostrEvent) => {

View File

@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core' import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { MARK_AS_READ_EMOJI } from './reactionService' import { MARK_AS_READ_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService' import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch' import { queryEvents } from './dataFetch'
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
try { try {
// Fetch kind:7 and kind:17 reactions in parallel // Fetch kind:7 and kind:17 reactions in parallel
const [kind7Events, kind17Events] = await Promise.all([ const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }), queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS }) queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
]) ])
const readArticles: ReadArticle[] = [] const readArticles: ReadArticle[] = []
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
// Filter to only nostr-native articles (kind 30023) // Filter to only nostr-native articles (kind 30023)
const nostrArticles = readArticles.filter( const nostrArticles = readArticles.filter(
article => article.eventKind === 30023 && article.eventId article => article.eventKind === KINDS.BlogPost && article.eventId
) )
if (nostrArticles.length === 0) { if (nostrArticles.length === 0) {
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
const articleEvents = await queryEvents( const articleEvents = await queryEvents(
relayPool, relayPool,
{ kinds: [30023], ids: eventIds }, { kinds: [KINDS.BlogPost], ids: eventIds },
{ relayUrls: RELAYS } { relayUrls: RELAYS }
) )

View File

@@ -0,0 +1,90 @@
import { RelayPool } from 'applesauce-relay'
import { fetchReadArticles } from './libraryService'
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 { mergeReadItem } from '../utils/readItemMerge'
/**
* Fetches external URL links with reading progress from:
* - URLs with reading progress (kind:30078)
* - Manually marked as read URLs (kind:7, kind:17)
*/
export async function fetchLinks(
relayPool: RelayPool,
userPubkey: string,
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
const linksMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(linksMap, item)) {
onItem(linksMap.get(item.id)!)
} else if (!onItem) {
linksMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Links] Data fetched:', {
readingPositions: readingPositionEvents.length,
markedAsRead: markedAsReadArticles.length
})
// Process reading positions and emit external items
processReadingPositions(readingPositionEvents, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Process marked-as-read and emit external items
processMarkedAsRead(markedAsReadArticles, linksMap)
if (onItem) {
linksMap.forEach(item => {
if (item.type === 'external') {
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
if (hasProgress) emitItem(item)
}
})
}
// Filter for external URLs only with reading progress
const links = Array.from(linksMap.values())
.filter(item => {
// Only external URLs
if (item.type !== 'external') return false
// Only include if there's reading progress or marked as read
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
return hasProgress
})
// Apply common validation and sorting
const validLinks = filterValidItems(links)
const sortedLinks = sortByReadingActivity(validLinks)
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
return sortedLinks
} catch (error) {
console.error('Failed to fetch links:', error)
return []
}
}

View File

@@ -1,11 +1,14 @@
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { Bookmark } from '../types/bookmarks' import { Bookmark } from '../types/bookmarks'
import { BlogPostPreview } from './exploreService' import { BlogPostPreview } from './exploreService'
import { ReadItem } from './readsService'
export interface MeCache { export interface MeCache {
highlights: Highlight[] highlights: Highlight[]
bookmarks: Bookmark[] bookmarks: Bookmark[]
readArticles: BlogPostPreview[] readArticles: BlogPostPreview[]
reads?: ReadItem[]
links?: ReadItem[]
timestamp: number timestamp: number
} }

View File

@@ -0,0 +1,147 @@
import { NostrEvent } from 'nostr-tools'
import { ReadItem } from './readsService'
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
const READING_POSITION_PREFIX = 'boris:reading-position:'
interface ReadArticle {
id: string
url?: string
eventId?: string
eventKind?: number
markedAt: number
}
/**
* Processes reading position events into ReadItems
*/
export function processReadingPositions(
events: NostrEvent[],
readsMap: Map<string, ReadItem>
): void {
for (const event of events) {
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, '')
try {
const positionData = JSON.parse(event.content)
const position = positionData.position
const timestamp = positionData.timestamp
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)
continue
}
}
// Add or update the item
const existing = readsMap.get(itemId)
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
readsMap.set(itemId, {
...existing,
id: itemId,
source: 'reading-progress',
type: itemType,
url: itemUrl,
readingProgress: position,
readingTimestamp: timestamp
})
}
} catch (error) {
console.warn('Failed to parse reading position:', error)
}
}
}
/**
* Processes marked-as-read articles into ReadItems
*/
export function processMarkedAsRead(
articles: ReadArticle[],
readsMap: Map<string, ReadItem>
): void {
for (const article of articles) {
const existing = readsMap.get(article.id)
if (article.eventId && article.eventKind === 30023) {
// Nostr article
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
} else if (article.url) {
// External URL
readsMap.set(article.id, {
...existing,
id: article.id,
source: 'marked-as-read',
type: 'external',
url: article.url,
markedAsRead: true,
markedAt: article.markedAt,
readingTimestamp: existing?.readingTimestamp || article.markedAt
})
}
}
}
/**
* Sorts ReadItems by most recent reading activity
*/
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
return items.sort((a, b) => {
const timeA = a.readingTimestamp || a.markedAt || 0
const timeB = b.readingTimestamp || b.markedAt || 0
return timeB - timeA
})
}
/**
* Filters out items without timestamps and enriches external items with fallback titles
*/
export function filterValidItems(items: ReadItem[]): ReadItem[] {
return items
.filter(item => {
// Only include items that have a timestamp
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
(item.markedAt && item.markedAt > 0)
if (!hasTimestamp) return false
// For Nostr articles, we need the event to be valid
if (item.type === 'article' && !item.event) return false
// For external URLs, we need at least a URL
if (item.type === 'external' && !item.url) return false
return true
})
.map(item => {
// Add fallback title for external URLs without titles
if (item.type === 'external' && !item.title && item.url) {
return { ...item, title: fallbackTitleFromUrl(item.url) }
}
return item
})
}

View File

@@ -0,0 +1,197 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
import { queryEvents } from './dataFetch'
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 { mergeReadItem } from '../utils/readItemMerge'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
/**
* Fetches all reads from multiple sources:
* - Bookmarked articles (kind:30023) and article/website URLs
* - Articles/URLs with reading progress (kind:30078)
* - Manually marked as read articles/URLs (kind:7, kind:17)
*/
export async function fetchAllReads(
relayPool: RelayPool,
userPubkey: string,
bookmarks: Bookmark[],
onItem?: (item: ReadItem) => void
): Promise<ReadItem[]> {
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
const readsMap = new Map<string, ReadItem>()
// Helper to emit items as they're added/updated
const emitItem = (item: ReadItem) => {
if (onItem && mergeReadItem(readsMap, item)) {
onItem(readsMap.get(item.id)!)
} else if (!onItem) {
readsMap.set(item.id, item)
}
}
try {
// Fetch all data sources in parallel
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
fetchReadArticles(relayPool, userPubkey)
])
console.log('📊 [Reads] Data fetched:', {
readingPositions: readingPositionEvents.length,
markedAsRead: markedAsReadArticles.length,
bookmarks: bookmarks.length
})
// Process reading positions and emit items
processReadingPositions(readingPositionEvents, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// Process marked-as-read and emit items
processMarkedAsRead(markedAsReadArticles, readsMap)
if (onItem) {
readsMap.forEach(item => {
if (item.type === 'article') onItem(item)
})
}
// 3. Process bookmarked articles and article/website URLs
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const bookmarkType = classifyBookmarkType(bookmark)
// Only include articles
if (bookmarkType === 'article') {
// Kind:30023 nostr article
const coordinate = bookmark.id // Already in coordinate format
const existing = readsMap.get(coordinate)
if (!existing) {
const item: ReadItem = {
id: coordinate,
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
}
readsMap.set(coordinate, item)
if (onItem) emitItem(item)
}
}
}
// 4. Fetch full event data for nostr articles
const articleCoordinates = Array.from(readsMap.values())
.filter(item => item.type === 'article' && !item.event)
.map(item => item.id)
if (articleCoordinates.length > 0) {
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
// Parse coordinates and fetch events
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
for (const coord of articleCoordinates) {
try {
// Try to decode as naddr
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
} else {
// Try coordinate format (kind:pubkey:identifier)
const parts = coord.split(':')
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: parts[1],
identifier: parts[2]
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (articlesToFetch.length > 0) {
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
const events = await queryEvents(
relayPool,
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
{ relayUrls: RELAYS }
)
// Merge event data into ReadItems and emit
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
const item = readsMap.get(coordinate) || readsMap.get(event.id)
if (item) {
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
if (onItem) emitItem(item)
}
}
}
}
// 5. Filter for Nostr articles only and apply common validation/sorting
const articles = Array.from(readsMap.values())
.filter(item => item.type === 'article')
const validArticles = filterValidItems(articles)
const sortedReads = sortByReadingActivity(validArticles)
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
return sortedReads
} catch (error) {
console.error('Failed to fetch all reads:', error)
return []
}
}

View File

@@ -0,0 +1,69 @@
import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService'
import { KINDS } from '../config/kinds'
import { fallbackTitleFromUrl } from './readItemMerge'
/**
* Derives ReadItems from bookmarks for external URLs:
* - Web bookmarks (kind:39701)
* - Any bookmark with http(s) URLs in content or urlReferences
*/
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
const linksMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const urls: string[] = []
// Web bookmarks (kind:39701) - extract from 'd' tag
if (bookmark.kind === KINDS.WebBookmark) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
urls.push(url)
}
}
// Extract URLs from content if not already captured
if (bookmark.content) {
const urlRegex = /(https?:\/\/[^\s]+)/g
const matches = bookmark.content.match(urlRegex)
if (matches) {
urls.push(...matches)
}
}
// Extract metadata from tags (for web bookmarks and other types)
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
// Create ReadItem for each unique URL
for (const url of [...new Set(urls)]) {
if (!linksMap.has(url)) {
const item: ReadItem = {
id: url,
source: 'bookmark',
type: 'external',
url,
title: title || fallbackTitleFromUrl(url),
summary,
image,
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at
}
linksMap.set(url, item)
}
}
}
// Sort by most recent bookmark activity
return Array.from(linksMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0
return timeB - timeA
})
}

View File

@@ -0,0 +1,83 @@
import { ReadItem } from '../services/readsService'
/**
* Merges a ReadItem into a state map, returning whether the state changed.
* Uses most recent reading activity to determine precedence.
*/
export function mergeReadItem(
stateMap: Map<string, ReadItem>,
incoming: ReadItem
): boolean {
const existing = stateMap.get(incoming.id)
if (!existing) {
stateMap.set(incoming.id, incoming)
return true
}
// Always merge if incoming has reading progress data
const hasNewProgress = incoming.readingProgress !== undefined &&
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
// Merge by taking the most recent reading activity
const existingTime = existing.readingTimestamp || existing.markedAt || 0
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
// Keep existing data, but update with newer reading metadata
stateMap.set(incoming.id, {
...existing,
...incoming,
// Preserve event data if incoming doesn't have it
event: incoming.event || existing.event,
title: incoming.title || existing.title,
summary: incoming.summary || existing.summary,
image: incoming.image || existing.image,
published: incoming.published || existing.published,
author: incoming.author || existing.author,
// Always take reading progress if available
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
})
return true
}
// If timestamps are equal but incoming has additional data, merge it
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
stateMap.set(incoming.id, {
...existing,
...incoming,
event: incoming.event || existing.event,
title: incoming.title || existing.title,
summary: incoming.summary || existing.summary,
image: incoming.image || existing.image,
published: incoming.published || existing.published,
author: incoming.author || existing.author
})
return true
}
return false
}
/**
* Extracts a readable title from a URL when no title is available.
* Removes protocol, www, and shows domain + path.
*/
export function fallbackTitleFromUrl(url: string): string {
try {
const parsed = new URL(url)
let title = parsed.hostname.replace(/^www\./, '')
if (parsed.pathname && parsed.pathname !== '/') {
const path = parsed.pathname.slice(0, 40)
title += path.length < parsed.pathname.length ? path + '...' : path
}
return title
} catch {
// If URL parsing fails, just return the URL truncated
return url.length > 50 ? url.slice(0, 47) + '...' : url
}
}

View File

@@ -0,0 +1,30 @@
import { ReadItem } from '../services/readsService'
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
/**
* Filters ReadItems by reading progress
*/
export function filterByReadingProgress(
items: ReadItem[],
filter: ReadingProgressFilterType
): ReadItem[] {
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
switch (filter) {
case 'unopened':
return progress === 0 && !isMarked
case 'started':
return progress > 0 && progress <= 0.10 && !isMarked
case 'reading':
return progress > 0.10 && progress <= 0.94 && !isMarked
case 'completed':
return progress >= 0.95 || isMarked
case 'all':
default:
return true
}
})
}

View File

@@ -0,0 +1,71 @@
import { Bookmark } from '../types/bookmarks'
import { ReadItem } from '../services/readsService'
import { classifyBookmarkType } from './bookmarkTypeClassifier'
import { KINDS } from '../config/kinds'
import { nip19 } from 'nostr-tools'
/**
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
* Returns items with type='article', using hydrated data when available.
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
*/
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
const readsMap = new Map<string, ReadItem>()
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
for (const bookmark of allBookmarks) {
const bookmarkType = classifyBookmarkType(bookmark)
// Only include articles (kind:30023)
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
// Extract identifier from coordinate
const parts = coordinate.split(':')
const identifier = parts[2] || ''
// Convert to naddr format (reading positions use naddr as ID)
let naddr: string
try {
naddr = nip19.naddrEncode({
kind: KINDS.BlogPost,
pubkey: bookmark.pubkey,
identifier
})
} catch (e) {
console.warn('Failed to encode naddr for bookmark:', coordinate)
continue
}
// Extract metadata from tags (same as BookmarkItem does)
const title = bookmark.content || 'Untitled'
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
const item: ReadItem = {
id: naddr, // Use naddr format to match reading positions
source: 'bookmark',
type: 'article',
readingProgress: 0,
readingTimestamp: bookmark.added_at || bookmark.created_at,
title,
summary,
image,
published: published ? parseInt(published) : undefined,
author: bookmark.pubkey
}
readsMap.set(naddr, item)
}
}
// Sort by most recent bookmark activity
return Array.from(readsMap.values()).sort((a, b) => {
const timeA = a.readingTimestamp || 0
const timeB = b.readingTimestamp || 0
return timeB - timeA
})
}

View File

@@ -1,5 +1,16 @@
{ {
"rewrites": [ "rewrites": [
{
"source": "/a/:naddr",
"has": [
{
"type": "header",
"key": "user-agent",
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
}
],
"destination": "/api/article-og?naddr=:naddr"
},
{ {
"source": "/(.*)", "source": "/(.*)",
"destination": "/index.html" "destination": "/index.html"

View File

@@ -1,8 +1,68 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
function getGitMetadata() {
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
let commit = envSha
let branch = envRef
try {
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {}
try {
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
} catch {}
return { commit, branch }
}
function getPackageVersion() {
try {
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
return pkg.version as string
} catch {
return '0.0.0'
}
}
const { commit, branch } = getGitMetadata()
const version = getPackageVersion()
const buildTime = new Date().toISOString()
function getCommitUrl(commit: string): string {
if (!commit) return ''
const provider = process.env.VERCEL_GIT_PROVIDER || ''
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
if (provider.toLowerCase() === 'github' && owner && slug) {
return `https://github.com/${owner}/${slug}/commit/${commit}`
}
try {
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
if (remote.includes('github.com')) {
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
const https = remote.startsWith('git@')
? `https://github.com/${remote.split(':')[1]}`
: remote
const cleaned = https.replace(/\.git$/, '')
return `${cleaned}/commit/${commit}`
}
} catch {}
return ''
}
const commitUrl = getCommitUrl(commit)
export default defineConfig({ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(version),
__GIT_COMMIT__: JSON.stringify(commit),
__GIT_BRANCH__: JSON.stringify(branch),
__BUILD_TIME__: JSON.stringify(buildTime),
__GIT_COMMIT_URL__: JSON.stringify(commitUrl)
},
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({